Skip to content

Commit 7b6f26b

Browse files
authored
Merge branch 'master' into multipart-encoding-required
2 parents 56b2272 + 2f578e3 commit 7b6f26b

25 files changed

+727
-101
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@
2424
"php": ">=7.2",
2525
"ext-bcmath": "*",
2626
"ext-json": "*",
27-
"cebe/php-openapi": "^1.6",
27+
"devizzent/cebe-php-openapi": "^1.0",
2828
"league/uri": "^6.3",
2929
"psr/cache": "^1.0 || ^2.0 || ^3.0",
3030
"psr/http-message": "^1.0",
3131
"psr/http-server-middleware": "^1.0",
3232
"respect/validation": "^1.1.3 || ^2.0",
3333
"riverline/multipart-parser": "^2.0.3",
34+
"symfony/polyfill-php80": "^1.27",
3435
"webmozart/assert": "^1.4"
3536
},
3637
"require-dev": {

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
parameters:
2+
phpVersion: 70200
23
level: 6
34
paths:
45
- src

src/PSR7/Exception/Validation/AddressValidationFailed.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66

77
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
88
use League\OpenAPIValidation\PSR7\OperationAddress;
9+
use League\OpenAPIValidation\Schema\Exception\SchemaMismatch;
910
use Throwable;
1011

12+
use function implode;
13+
use function rtrim;
1114
use function sprintf;
1215

1316
abstract class AddressValidationFailed extends ValidationFailed
@@ -42,6 +45,21 @@ public static function fromAddr(OperationAddress $address): self
4245
return $ex;
4346
}
4447

48+
public function getVerboseMessage(): string
49+
{
50+
$previous = $this->getPrevious();
51+
if (! $previous instanceof SchemaMismatch) {
52+
return $this->getMessage();
53+
}
54+
55+
return sprintf(
56+
'%s. [%s in %s]',
57+
$this->getMessage(),
58+
rtrim($previous->getMessage(), '.'),
59+
implode('->', $previous->dataBreadCrumb()->buildChain())
60+
);
61+
}
62+
4563
public function getAddress(): OperationAddress
4664
{
4765
return $this->address;

src/PSR7/OperationAddress.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
use League\OpenAPIValidation\PSR7\Exception\Validation\InvalidPath;
88
use League\OpenAPIValidation\Schema\Exception\InvalidSchema;
99

10+
use function explode;
1011
use function implode;
1112
use function preg_match;
13+
use function preg_match_all;
1214
use function preg_quote;
1315
use function preg_replace;
1416
use function preg_split;
1517
use function sprintf;
1618
use function strtok;
19+
use function trim;
1720

1821
use const PREG_SPLIT_DELIM_CAPTURE;
1922

@@ -62,7 +65,28 @@ public function path(): string
6265

6366
public function hasPlaceholders(): bool
6467
{
65-
return preg_match(self::PATH_PLACEHOLDER, $this->path()) === 1;
68+
return (bool) $this->countPlaceholders();
69+
}
70+
71+
public function countPlaceholders(): int
72+
{
73+
return preg_match_all(self::PATH_PLACEHOLDER, $this->path()) ?? 0;
74+
}
75+
76+
public function countExactMatchParts(string $comparisonPath): int
77+
{
78+
$comparisonPathParts = explode('/', trim($comparisonPath, '/'));
79+
$pathParts = explode('/', trim($this->path(), '/'));
80+
$exactMatchCount = 0;
81+
foreach ($comparisonPathParts as $key => $comparisonPathPart) {
82+
if ($comparisonPathPart !== $pathParts[$key]) {
83+
continue;
84+
}
85+
86+
$exactMatchCount++;
87+
}
88+
89+
return $exactMatchCount;
6690
}
6791

6892
/**

src/PSR7/PathFinder.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@
88
use cebe\openapi\spec\PathItem;
99
use cebe\openapi\spec\Server;
1010

11+
use function array_keys;
12+
use function array_unique;
1113
use function count;
1214
use function ltrim;
15+
use function max;
1316
use function parse_url;
1417
use function preg_match;
18+
use function preg_match_all;
1519
use function preg_replace;
1620
use function rtrim;
1721
use function sprintf;
1822
use function strtolower;
23+
use function trim;
1924
use function usort;
2025

2126
use const PHP_URL_PATH;
@@ -206,6 +211,60 @@ private function prioritizeStaticPaths(array $paths): array
206211
return 0;
207212
});
208213

214+
return $this->attemptNarrowDown($paths);
215+
}
216+
217+
/**
218+
* Some paths are more static than others.
219+
*
220+
* @param OperationAddress[] $paths
221+
*
222+
* @return OperationAddress[]
223+
*/
224+
private function attemptNarrowDown(array $paths): array
225+
{
226+
if (count($paths) === 1) {
227+
return $paths;
228+
}
229+
230+
$partCounts = [];
231+
$placeholderCounts = [];
232+
foreach ($paths as $path) {
233+
$partCounts[] = $this->countParts($path->path());
234+
$placeholderCounts[] = $path->countPlaceholders();
235+
}
236+
237+
$partCounts[] = $this->countParts($this->path);
238+
if (count(array_unique($partCounts)) === 1 && count(array_unique($placeholderCounts)) > 1) {
239+
// All paths have the same number of parts but there are differing placeholder counts. We can narrow down!
240+
return $this->filterToHighestExactMatchingParts($paths);
241+
}
242+
209243
return $paths;
210244
}
245+
246+
/**
247+
* Scores all paths by how many parts match exactly with $this->path and returns only the highest scoring group
248+
*
249+
* @param OperationAddress[] $paths
250+
*
251+
* @return OperationAddress[]
252+
*/
253+
private function filterToHighestExactMatchingParts(array $paths): array
254+
{
255+
$scoredCandidates = [];
256+
foreach ($paths as $candidate) {
257+
$score = $candidate->countExactMatchParts($this->path);
258+
$scoredCandidates[$score][] = $candidate;
259+
}
260+
261+
$highestScoreKey = max(array_keys($scoredCandidates));
262+
263+
return $scoredCandidates[$highestScoreKey];
264+
}
265+
266+
private function countParts(string $path): int
267+
{
268+
return preg_match_all('#/#', trim($path, '/')) + 1;
269+
}
211270
}

src/PSR7/SpecFinder.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ public function findSecuritySpecs(OperationAddress $addr): array
159159
return $securitySpecs;
160160
}
161161

162-
// @phpstan-ignore-next-line security is set on root level (fallback option)
163162
return $this->openApi->security;
164163
}
165164

src/PSR7/Validators/BodyValidator/FormUrlencodedValidator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function validate(OperationAddress $addr, MessageInterface $message): voi
5050

5151
// 0. Multipart body message MUST be described with a set of object properties
5252
if ($schema->type !== CebeType::OBJECT) {
53-
throw TypeMismatch::becauseTypeDoesNotMatch('object', $schema->type);
53+
throw TypeMismatch::becauseTypeDoesNotMatch(['object'], $schema->type);
5454
}
5555

5656
// 1. Parse message body

src/PSR7/Validators/BodyValidator/MultipartValidator.php

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
use Riverline\MultiPartParser\Converters\PSR7;
2828
use Riverline\MultiPartParser\StreamedPart;
2929

30+
use function array_diff_assoc;
31+
use function array_map;
3032
use function array_replace;
33+
use function array_shift;
34+
use function explode;
3135
use function in_array;
3236
use function is_array;
3337
use function json_decode;
@@ -36,6 +40,8 @@
3640
use function preg_match;
3741
use function str_replace;
3842
use function strpos;
43+
use function strtolower;
44+
use function substr;
3945

4046
use const JSON_ERROR_NONE;
4147

@@ -71,7 +77,7 @@ public function validate(OperationAddress $addr, MessageInterface $message): voi
7177

7278
// 0. Multipart body message MUST be described with a set of object properties
7379
if ($schema->type !== CebeType::OBJECT) {
74-
throw TypeMismatch::becauseTypeDoesNotMatch('object', $schema->type);
80+
throw TypeMismatch::becauseTypeDoesNotMatch(['object'], $schema->type);
7581
}
7682

7783
if ($message->getBody()->getSize()) {
@@ -109,27 +115,20 @@ private function validatePlainBodyMultipart(
109115
110116
foreach ($parts as $part) {
111117
// 2.1 parts encoding
112-
$partContentType = $part->getHeader(self::HEADER_CONTENT_TYPE);
113-
$encodingContentType = $this->detectEncondingContentType($encoding, $part, $schema->properties[$partName]);
114-
if (strpos($encodingContentType, '*') === false) {
115-
// strict comparison (ie "image/jpeg")
116-
if ($encodingContentType !== $partContentType) {
117-
throw InvalidBody::becauseBodyDoesNotMatchSchemaMultipart(
118-
$partName,
119-
$partContentType,
120-
$addr
121-
);
122-
}
123-
} else {
124-
// loose comparison (ie "image/*")
125-
$encodingContentType = str_replace('*', '.*', $encodingContentType);
126-
if (! preg_match('#' . $encodingContentType . '#', $partContentType)) {
127-
throw InvalidBody::becauseBodyDoesNotMatchSchemaMultipart(
128-
$partName,
129-
$partContentType,
130-
$addr
131-
);
132-
}
118+
$partContentType = $part->getHeader(self::HEADER_CONTENT_TYPE);
119+
$validContentTypes = $this->detectEncodingContentTypes($encoding, $part, $schema->properties[$partName]);
120+
$match = false;
121+
122+
foreach ($validContentTypes as $contentType) {
123+
$match = $match || $this->contentTypeMatches($contentType, $partContentType);
124+
}
125+
126+
if (! $match) {
127+
throw InvalidBody::becauseBodyDoesNotMatchSchemaMultipart(
128+
$partName,
129+
$partContentType,
130+
$addr
131+
);
133132
}
134133

135134
// 2.2. parts headers
@@ -187,7 +186,10 @@ private function parseMultipartData(OperationAddress $addr, StreamedPart $docume
187186
return $multipartData;
188187
}
189188

190-
private function detectEncondingContentType(Encoding $encoding, StreamedPart $part, Schema $partSchema): string
189+
/**
190+
* @return string[]
191+
*/
192+
private function detectEncodingContentTypes(Encoding $encoding, StreamedPart $part, Schema $partSchema): array
191193
{
192194
$contentType = $encoding->contentType;
193195

@@ -211,7 +213,69 @@ private function detectEncondingContentType(Encoding $encoding, StreamedPart $pa
211213
}
212214
}
213215

214-
return $contentType;
216+
return array_map('trim', explode(',', $contentType));
217+
}
218+
219+
private function contentTypeMatches(string $expected, string $match): bool
220+
{
221+
$expectedNormalized = $this->normalizedContentTypeParts($expected);
222+
$matchNormalized = $this->normalizedContentTypeParts($match);
223+
$expectedType = array_shift($expectedNormalized);
224+
$matchType = array_shift($matchNormalized);
225+
226+
if (strpos($expectedType, '*') === false) {
227+
// strict comparison (ie "image/jpeg")
228+
if ($expectedType !== $matchType) {
229+
return false;
230+
}
231+
} else {
232+
// loose comparison (ie "image/*")
233+
$expectedType = str_replace('*', '.*', $expectedType);
234+
if (! preg_match('#' . $expectedType . '#', $matchType)) {
235+
return false;
236+
}
237+
}
238+
239+
// Any expected parameter values must also match
240+
return ! array_diff_assoc($expectedNormalized, $matchNormalized);
241+
}
242+
243+
/**
244+
* Per RFC-7231 Section 3.1.1.1:
245+
* "The type, subtype, and parameter name tokens are case-insensitive. Parameter values might or might not be case-sensitive..."
246+
*
247+
* And section 3.1.1.2: "A charset is identified by a case-insensitive token."
248+
*
249+
* The following are equivalent:
250+
*
251+
* text/html;charset=utf-8
252+
* text/html;charset=UTF-8
253+
* Text/HTML;Charset="utf-8"
254+
* text/html; charset="utf-8"
255+
*
256+
* @return array<int|string, string>
257+
*/
258+
private function normalizedContentTypeParts(string $contentType): array
259+
{
260+
$parts = array_map('trim', explode(';', $contentType));
261+
$result = [strtolower(array_shift($parts))];
262+
263+
foreach ($parts as $part) {
264+
[$parameter, $value] = explode('=', $part, 2);
265+
$parameter = strtolower($parameter);
266+
267+
if ($value[0] === '"') { // quoted-string
268+
$value = str_replace('\\', '', substr($value, 1, -1));
269+
}
270+
271+
if ($parameter === 'charset') {
272+
$value = strtolower($value);
273+
}
274+
275+
$result[$parameter] = $value;
276+
}
277+
278+
return $result;
215279
}
216280

217281
/**

0 commit comments

Comments
 (0)