diff --git a/composer.json b/composer.json index 139686ec..48919f1c 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,14 @@ "require": { "php": ">=7.2", "ext-json": "*", - "cebe/php-openapi": "^1.6", + "devizzent/cebe-php-openapi": "^1.0", "league/uri": "^6.3", "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/http-message": "^1.0", "psr/http-server-middleware": "^1.0", "respect/validation": "^1.1.3 || ^2.0", "riverline/multipart-parser": "^2.0.3", + "symfony/polyfill-php80": "^1.27", "webmozart/assert": "^1.4" }, "require-dev": { @@ -44,7 +45,8 @@ "symfony/cache": "^5.1" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": true }, "prefer-stable": true } diff --git a/phpstan.neon b/phpstan.neon index 55917b0a..90669f82 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,9 +1,9 @@ parameters: + phpVersion: 70200 level: 6 paths: - src - tests treatPhpDocTypesAsCertain: false ignoreErrors: - - '#Caught class Respect\\Validation\\Exceptions\\ExceptionInterface not found.#' - '#Call to an undefined static method Respect\\Validation\\Validator::numeric\(\).#' diff --git a/src/PSR7/Exception/Validation/AddressValidationFailed.php b/src/PSR7/Exception/Validation/AddressValidationFailed.php index cf962b5a..8856a6cf 100644 --- a/src/PSR7/Exception/Validation/AddressValidationFailed.php +++ b/src/PSR7/Exception/Validation/AddressValidationFailed.php @@ -6,8 +6,11 @@ use League\OpenAPIValidation\PSR7\Exception\ValidationFailed; use League\OpenAPIValidation\PSR7\OperationAddress; +use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use Throwable; +use function implode; +use function rtrim; use function sprintf; abstract class AddressValidationFailed extends ValidationFailed @@ -42,6 +45,21 @@ public static function fromAddr(OperationAddress $address): self return $ex; } + public function getVerboseMessage(): string + { + $previous = $this->getPrevious(); + if (! $previous instanceof SchemaMismatch) { + return $this->getMessage(); + } + + return sprintf( + '%s. [%s in %s]', + $this->getMessage(), + rtrim($previous->getMessage(), '.'), + implode('->', $previous->dataBreadCrumb()->buildChain()) + ); + } + public function getAddress(): OperationAddress { return $this->address; diff --git a/src/PSR7/OperationAddress.php b/src/PSR7/OperationAddress.php index 0ec96e37..12a59b3b 100644 --- a/src/PSR7/OperationAddress.php +++ b/src/PSR7/OperationAddress.php @@ -7,13 +7,16 @@ use League\OpenAPIValidation\PSR7\Exception\Validation\InvalidPath; use League\OpenAPIValidation\Schema\Exception\InvalidSchema; +use function explode; use function implode; use function preg_match; +use function preg_match_all; use function preg_quote; use function preg_replace; use function preg_split; use function sprintf; use function strtok; +use function trim; use const PREG_SPLIT_DELIM_CAPTURE; @@ -62,7 +65,28 @@ public function path(): string public function hasPlaceholders(): bool { - return preg_match(self::PATH_PLACEHOLDER, $this->path()) === 1; + return (bool) $this->countPlaceholders(); + } + + public function countPlaceholders(): int + { + return preg_match_all(self::PATH_PLACEHOLDER, $this->path()) ?? 0; + } + + public function countExactMatchParts(string $comparisonPath): int + { + $comparisonPathParts = explode('/', trim($comparisonPath, '/')); + $pathParts = explode('/', trim($this->path(), '/')); + $exactMatchCount = 0; + foreach ($comparisonPathParts as $key => $comparisonPathPart) { + if ($comparisonPathPart !== $pathParts[$key]) { + continue; + } + + $exactMatchCount++; + } + + return $exactMatchCount; } /** diff --git a/src/PSR7/PathFinder.php b/src/PSR7/PathFinder.php index 00ab1929..25075a6d 100644 --- a/src/PSR7/PathFinder.php +++ b/src/PSR7/PathFinder.php @@ -8,14 +8,19 @@ use cebe\openapi\spec\PathItem; use cebe\openapi\spec\Server; +use function array_keys; +use function array_unique; use function count; use function ltrim; +use function max; use function parse_url; use function preg_match; +use function preg_match_all; use function preg_replace; use function rtrim; use function sprintf; use function strtolower; +use function trim; use function usort; use const PHP_URL_PATH; @@ -206,6 +211,60 @@ private function prioritizeStaticPaths(array $paths): array return 0; }); + return $this->attemptNarrowDown($paths); + } + + /** + * Some paths are more static than others. + * + * @param OperationAddress[] $paths + * + * @return OperationAddress[] + */ + private function attemptNarrowDown(array $paths): array + { + if (count($paths) === 1) { + return $paths; + } + + $partCounts = []; + $placeholderCounts = []; + foreach ($paths as $path) { + $partCounts[] = $this->countParts($path->path()); + $placeholderCounts[] = $path->countPlaceholders(); + } + + $partCounts[] = $this->countParts($this->path); + if (count(array_unique($partCounts)) === 1 && count(array_unique($placeholderCounts)) > 1) { + // All paths have the same number of parts but there are differing placeholder counts. We can narrow down! + return $this->filterToHighestExactMatchingParts($paths); + } + return $paths; } + + /** + * Scores all paths by how many parts match exactly with $this->path and returns only the highest scoring group + * + * @param OperationAddress[] $paths + * + * @return OperationAddress[] + */ + private function filterToHighestExactMatchingParts(array $paths): array + { + $scoredCandidates = []; + foreach ($paths as $candidate) { + $score = $candidate->countExactMatchParts($this->path); + $scoredCandidates[$score][] = $candidate; + } + + $highestScoreKey = max(array_keys($scoredCandidates)); + + return $scoredCandidates[$highestScoreKey]; + } + + private function countParts(string $path): int + { + return preg_match_all('#/#', trim($path, '/')) + 1; + } } diff --git a/src/PSR7/SpecFinder.php b/src/PSR7/SpecFinder.php index a3ad854b..07326b7d 100644 --- a/src/PSR7/SpecFinder.php +++ b/src/PSR7/SpecFinder.php @@ -23,9 +23,9 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use Webmozart\Assert\Assert; +use function is_array; use function json_decode; use function json_encode; -use function property_exists; use function substr; final class SpecFinder @@ -152,15 +152,14 @@ public function findSecuritySpecs(OperationAddress $addr): array $opSpec = $this->findOperationSpec($addr); // 1. Collect security params - if (property_exists($opSpec->getSerializableData(), 'security')) { - // security is set on operation level - $securitySpecs = $opSpec->security; - } else { - // security is set on root level (fallback option) - $securitySpecs = $this->openApi->security; + $securitySpecs = $opSpec->security; + + // security is set on operation level + if (is_array($securitySpecs)) { + return $securitySpecs; } - return $securitySpecs; + return $this->openApi->security; } /** diff --git a/src/PSR7/Validators/BodyValidator/FormUrlencodedValidator.php b/src/PSR7/Validators/BodyValidator/FormUrlencodedValidator.php index 05426caa..cbba0cee 100644 --- a/src/PSR7/Validators/BodyValidator/FormUrlencodedValidator.php +++ b/src/PSR7/Validators/BodyValidator/FormUrlencodedValidator.php @@ -50,7 +50,7 @@ public function validate(OperationAddress $addr, MessageInterface $message): voi // 0. Multipart body message MUST be described with a set of object properties if ($schema->type !== CebeType::OBJECT) { - throw TypeMismatch::becauseTypeDoesNotMatch('object', $schema->type); + throw TypeMismatch::becauseTypeDoesNotMatch(['object'], $schema->type); } // 1. Parse message body diff --git a/src/PSR7/Validators/BodyValidator/MultipartValidator.php b/src/PSR7/Validators/BodyValidator/MultipartValidator.php index 211c642b..0dc990cc 100644 --- a/src/PSR7/Validators/BodyValidator/MultipartValidator.php +++ b/src/PSR7/Validators/BodyValidator/MultipartValidator.php @@ -26,18 +26,22 @@ use Psr\Http\Message\UploadedFileInterface; use Riverline\MultiPartParser\Converters\PSR7; use Riverline\MultiPartParser\StreamedPart; -use RuntimeException; +use function array_diff_assoc; +use function array_map; use function array_merge_recursive; +use function array_shift; +use function explode; use function in_array; use function is_array; use function json_decode; use function json_last_error; use function json_last_error_msg; use function preg_match; -use function sprintf; use function str_replace; use function strpos; +use function strtolower; +use function substr; use const JSON_ERROR_NONE; @@ -73,7 +77,7 @@ public function validate(OperationAddress $addr, MessageInterface $message): voi // 0. Multipart body message MUST be described with a set of object properties if ($schema->type !== CebeType::OBJECT) { - throw TypeMismatch::becauseTypeDoesNotMatch('object', $schema->type); + throw TypeMismatch::becauseTypeDoesNotMatch(['object'], $schema->type); } if ($message->getBody()->getSize()) { @@ -108,36 +112,23 @@ private function validatePlainBodyMultipart( foreach ($encodings as $partName => $encoding) { $parts = $document->getPartsByName($partName); // multiple parts share a name? - if (! $parts) { - throw new RuntimeException(sprintf( - 'Specified body part %s is not found', - $partName - )); - } foreach ($parts as $part) { // 2.1 parts encoding - $partContentType = $part->getHeader(self::HEADER_CONTENT_TYPE); - $encodingContentType = $this->detectEncondingContentType($encoding, $part, $schema->properties[$partName]); - if (strpos($encodingContentType, '*') === false) { - // strict comparison (ie "image/jpeg") - if ($encodingContentType !== $partContentType) { - throw InvalidBody::becauseBodyDoesNotMatchSchemaMultipart( - $partName, - $partContentType, - $addr - ); - } - } else { - // loose comparison (ie "image/*") - $encodingContentType = str_replace('*', '.*', $encodingContentType); - if (! preg_match('#' . $encodingContentType . '#', $partContentType)) { - throw InvalidBody::becauseBodyDoesNotMatchSchemaMultipart( - $partName, - $partContentType, - $addr - ); - } + $partContentType = $part->getHeader(self::HEADER_CONTENT_TYPE); + $validContentTypes = $this->detectEncodingContentTypes($encoding, $part, $schema->properties[$partName]); + $match = false; + + foreach ($validContentTypes as $contentType) { + $match = $match || $this->contentTypeMatches($contentType, $partContentType); + } + + if (! $match) { + throw InvalidBody::becauseBodyDoesNotMatchSchemaMultipart( + $partName, + $partContentType, + $addr + ); } // 2.2. parts headers @@ -195,7 +186,10 @@ private function parseMultipartData(OperationAddress $addr, StreamedPart $docume return $multipartData; } - private function detectEncondingContentType(Encoding $encoding, StreamedPart $part, Schema $partSchema): string + /** + * @return string[] + */ + private function detectEncodingContentTypes(Encoding $encoding, StreamedPart $part, Schema $partSchema): array { $contentType = $encoding->contentType; @@ -219,7 +213,69 @@ private function detectEncondingContentType(Encoding $encoding, StreamedPart $pa } } - return $contentType; + return array_map('trim', explode(',', $contentType)); + } + + private function contentTypeMatches(string $expected, string $match): bool + { + $expectedNormalized = $this->normalizedContentTypeParts($expected); + $matchNormalized = $this->normalizedContentTypeParts($match); + $expectedType = array_shift($expectedNormalized); + $matchType = array_shift($matchNormalized); + + if (strpos($expectedType, '*') === false) { + // strict comparison (ie "image/jpeg") + if ($expectedType !== $matchType) { + return false; + } + } else { + // loose comparison (ie "image/*") + $expectedType = str_replace('*', '.*', $expectedType); + if (! preg_match('#' . $expectedType . '#', $matchType)) { + return false; + } + } + + // Any expected parameter values must also match + return ! array_diff_assoc($expectedNormalized, $matchNormalized); + } + + /** + * Per RFC-7231 Section 3.1.1.1: + * "The type, subtype, and parameter name tokens are case-insensitive. Parameter values might or might not be case-sensitive..." + * + * And section 3.1.1.2: "A charset is identified by a case-insensitive token." + * + * The following are equivalent: + * + * text/html;charset=utf-8 + * text/html;charset=UTF-8 + * Text/HTML;Charset="utf-8" + * text/html; charset="utf-8" + * + * @return array + */ + private function normalizedContentTypeParts(string $contentType): array + { + $parts = array_map('trim', explode(';', $contentType)); + $result = [strtolower(array_shift($parts))]; + + foreach ($parts as $part) { + [$parameter, $value] = explode('=', $part, 2); + $parameter = strtolower($parameter); + + if ($value[0] === '"') { // quoted-string + $value = str_replace('\\', '', substr($value, 1, -1)); + } + + if ($parameter === 'charset') { + $value = strtolower($value); + } + + $result[$parameter] = $value; + } + + return $result; } /** @@ -253,7 +309,7 @@ private function validateServerRequestMultipart( foreach ($encodings as $partName => $encoding) { if (! isset($body[$partName])) { - throw new RuntimeException(sprintf('Specified body part %s is not found', $partName)); + continue; } $part = $body[$partName]; diff --git a/src/PSR7/Validators/SerializedParameter.php b/src/PSR7/Validators/SerializedParameter.php index 1fe17fb2..959d7fbe 100644 --- a/src/PSR7/Validators/SerializedParameter.php +++ b/src/PSR7/Validators/SerializedParameter.php @@ -11,15 +11,15 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\Exception\TypeMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function explode; use function in_array; use function is_array; use function is_float; use function is_int; +use function is_iterable; use function is_numeric; use function is_scalar; use function is_string; @@ -28,7 +28,10 @@ use function key; use function preg_match; use function reset; +use function str_starts_with; +use function strpos; use function strtolower; +use function substr; use const JSON_ERROR_NONE; @@ -43,6 +46,9 @@ final class SerializedParameter self::STYLE_SPACE_DELIMITED => ' ', self::STYLE_PIPE_DELIMITED => '|', ]; + private const STYLE_LABEL = 'label'; + private const STYLE_SIMPLE = 'simple'; + private const STYLE_MATRIX = 'matrix'; /** @var CebeSchema */ private $schema; @@ -52,13 +58,16 @@ final class SerializedParameter private $style; /** @var bool|null */ private $explode; + /** @var string|null */ + private $in; - public function __construct(CebeSchema $schema, ?string $contentType = null, ?string $style = null, ?bool $explode = null) + public function __construct(CebeSchema $schema, ?string $contentType = null, ?string $style = null, ?bool $explode = null, ?string $in = null) { $this->schema = $schema; $this->contentType = $contentType; $this->style = $style; $this->explode = $explode; + $this->in = $in; } public static function fromSpec(CebeParameter $parameter): self @@ -68,11 +77,11 @@ public static function fromSpec(CebeParameter $parameter): self if ($parameter->schema !== null) { Validator::not(Validator::notEmpty())->assert($content); - return new self($parameter->schema, null, $parameter->style, $parameter->explode); + return new self($parameter->schema, null, $parameter->style, $parameter->explode, $parameter->in); } Validator::length(1, 1)->assert($content); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { // If there is a `schema`, `content` must be empty. // If there isn't a `schema`, a `content` with exactly 1 property must exist. // @see https://swagger.io/docs/specification/describing-parameters/#schema-vs-content @@ -82,7 +91,7 @@ public static function fromSpec(CebeParameter $parameter): self $schema = reset($content)->schema; $contentType = key($content); - return new self($schema, $contentType, $parameter->style, $parameter->explode); + return new self($schema, $contentType, $parameter->style, $parameter->explode, $parameter->in); } /** @@ -97,7 +106,7 @@ public function deserialize($value) if ($this->isJsonContentType()) { // Value MUST be a string. if (! is_string($value)) { - throw TypeMismatch::becauseTypeDoesNotMatch('string', $value); + throw TypeMismatch::becauseTypeDoesNotMatch(['string'], $value); } $decodedValue = json_decode($value, true); @@ -125,8 +134,14 @@ private function isJsonContentType(): bool */ private function castToSchemaType($value, ?string $type) { - if (($type === CebeType::BOOLEAN) && is_scalar($value) && preg_match('#^(true|false)$#i', (string) $value)) { - return is_string($value) ? strtolower($value) === 'true' : (bool) $value; + if ($type === CebeType::BOOLEAN && is_scalar($value)) { + if (preg_match('#^(true|false)$#i', (string) $value)) { + return is_string($value) ? strtolower($value) === 'true' : (bool) $value; + } + + if (preg_match('#^(0|1)$#i', (string) $value)) { + return (string) $value === '1'; + } } if ( @@ -165,12 +180,100 @@ private function castToSchemaType($value, ?string $type) * @return mixed */ protected function convertToSerializationStyle($value, ?CebeSchema $schema) + { + switch ($this->in) { + case 'path': + return $this->convertToSerializationStyleForPath($value, $schema); + + default: + return $this->convertToSerializationStyleForQuery($value, $schema); + } + } + + /** + * @param mixed $value + * @param CebeSchema|null $schema - optional schema of value to convert it in case of DeepObject serialisation + * + * @return mixed + */ + protected function convertToSerializationStyleForPath($value, ?CebeSchema $schema) + { + switch ($this->style) { + case self::STYLE_SIMPLE: + case null: + // default style simple + $value = explode(',', $value); + break; + case self::STYLE_LABEL: + if (! str_starts_with($value, '.')) { + throw TypeMismatch::becauseTypeDoesNotMatch(['label-array'], $value); + } + + $value = substr($value, 1); + if ($this->explode === true) { + $value = explode('.', $value); + } else { + $value = explode(',', $value); + } + + break; + case self::STYLE_MATRIX: + if (! str_starts_with($value, ';')) { + throw TypeMismatch::becauseTypeDoesNotMatch(['matrix-array'], $value); + } + + $value = substr($value, 1); + if ($this->explode === true) { + $value = explode(';', $value); + foreach ($value as &$val) { + $eqpos = strpos($val, '='); + if ($eqpos === false) { + throw TypeMismatch::becauseTypeDoesNotMatch(['matrix-array'], $value); + } + + $val = substr($val, $eqpos + 1); + } + } else { + $eqpos = strpos($value, '='); + if ($eqpos === false) { + throw TypeMismatch::becauseTypeDoesNotMatch(['matrix-array'], $value); + } + + $value = substr($value, $eqpos + 1); + $value = explode(',', $value); + } + + break; + } + + if (! is_iterable($value)) { + throw TypeMismatch::becauseTypeDoesNotMatch(['iterable'], $value); + } + + foreach ($value as &$val) { + $val = $this->castToSchemaType($val, $schema->items->type ?? null); + } + + return $value; + } + + /** + * @param mixed $value + * @param CebeSchema|null $schema - optional schema of value to convert it in case of DeepObject serialisation + * + * @return mixed + */ + protected function convertToSerializationStyleForQuery($value, ?CebeSchema $schema) { if (in_array($this->style, [self::STYLE_FORM, self::STYLE_SPACE_DELIMITED, self::STYLE_PIPE_DELIMITED], true)) { if ($this->explode === false) { $value = explode(self::STYLE_DELIMITER_MAP[$this->style], $value); } + if (! is_iterable($value)) { + throw TypeMismatch::becauseTypeDoesNotMatch(['iterable'], $value); + } + foreach ($value as &$val) { $val = $this->castToSchemaType($val, $schema->items->type ?? null); } diff --git a/src/Schema/Exception/TypeMismatch.php b/src/Schema/Exception/TypeMismatch.php index f275625f..8435244b 100644 --- a/src/Schema/Exception/TypeMismatch.php +++ b/src/Schema/Exception/TypeMismatch.php @@ -5,20 +5,22 @@ namespace League\OpenAPIValidation\Schema\Exception; use function gettype; +use function implode; use function sprintf; // Validation for 'type' keyword failed against a given data class TypeMismatch extends KeywordMismatch { /** - * @param mixed $value + * @param string[] $expected + * @param mixed $value * * @return TypeMismatch */ - public static function becauseTypeDoesNotMatch(string $expected, $value): self + public static function becauseTypeDoesNotMatch(array $expected, $value): self { - $exception = new self(sprintf("Value expected to be '%s', '%s' given.", $expected, gettype($value))); - $exception->data = $value; + $exception = new self(sprintf("Value expected to be '%s', but '%s' given.", implode(', ', $expected), gettype($value))); + $exception->data = $value; $exception->keyword = 'type'; return $exception; diff --git a/src/Schema/Keywords/AllOf.php b/src/Schema/Keywords/AllOf.php index 1b74366a..685ea5ed 100644 --- a/src/Schema/Keywords/AllOf.php +++ b/src/Schema/Keywords/AllOf.php @@ -9,9 +9,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; final class AllOf extends BaseKeyword { @@ -47,7 +46,7 @@ public function validate($data, array $allOf): void try { Validator::arrayVal()->assert($allOf); Validator::each(Validator::instance(CebeSchema::class))->assert($allOf); - } catch (Exception | ExceptionInterface $exception) { + } catch (Throwable $exception) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($exception); } diff --git a/src/Schema/Keywords/AnyOf.php b/src/Schema/Keywords/AnyOf.php index 023c8fca..b2dab8b9 100644 --- a/src/Schema/Keywords/AnyOf.php +++ b/src/Schema/Keywords/AnyOf.php @@ -11,9 +11,8 @@ use League\OpenAPIValidation\Schema\Exception\NotEnoughValidSchemas; use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; class AnyOf extends BaseKeyword { @@ -49,7 +48,7 @@ public function validate($data, array $anyOf): void try { Validator::arrayVal()->assert($anyOf); Validator::each(Validator::instance(CebeSchema::class))->assert($anyOf); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Enum.php b/src/Schema/Keywords/Enum.php index f687213e..8c82d057 100644 --- a/src/Schema/Keywords/Enum.php +++ b/src/Schema/Keywords/Enum.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function count; use function in_array; @@ -34,7 +33,7 @@ public function validate($data, array $enum): void try { Validator::arrayType()->assert($enum); Validator::trueVal()->assert(count($enum) >= 1); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Items.php b/src/Schema/Keywords/Items.php index 2f66ed72..51f1708f 100644 --- a/src/Schema/Keywords/Items.php +++ b/src/Schema/Keywords/Items.php @@ -9,9 +9,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function sprintf; @@ -43,7 +42,7 @@ public function validate($data, CebeSchema $itemsSchema): void try { Validator::arrayVal()->assert($data); Validator::instance(CebeSchema::class)->assert($itemsSchema); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MaxItems.php b/src/Schema/Keywords/MaxItems.php index 151979dd..bdfe558f 100644 --- a/src/Schema/Keywords/MaxItems.php +++ b/src/Schema/Keywords/MaxItems.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function count; use function sprintf; @@ -32,7 +31,7 @@ public function validate($data, int $maxItems): void Validator::arrayType()->assert($data); Validator::intVal()->assert($maxItems); Validator::trueVal()->assert($maxItems >= 0); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MaxLength.php b/src/Schema/Keywords/MaxLength.php index 15b96c04..12785754 100644 --- a/src/Schema/Keywords/MaxLength.php +++ b/src/Schema/Keywords/MaxLength.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function mb_strlen; use function sprintf; @@ -37,7 +36,7 @@ public function validate($data, int $maxLength): void Validator::stringType()->assert($data); Validator::intType()->assert($maxLength); Validator::trueVal()->assert($maxLength >= 0); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MaxProperties.php b/src/Schema/Keywords/MaxProperties.php index bca6dbe7..3fd98a18 100644 --- a/src/Schema/Keywords/MaxProperties.php +++ b/src/Schema/Keywords/MaxProperties.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function count; use function sprintf; @@ -31,7 +30,7 @@ public function validate($data, int $maxProperties): void try { Validator::arrayType()->assert($data); Validator::trueVal()->assert($maxProperties >= 0); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Maximum.php b/src/Schema/Keywords/Maximum.php index 898297c2..559a015b 100644 --- a/src/Schema/Keywords/Maximum.php +++ b/src/Schema/Keywords/Maximum.php @@ -6,10 +6,9 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Rules\NumericVal; use Respect\Validation\Validator; +use Throwable; use function class_exists; use function sprintf; @@ -49,7 +48,7 @@ public function validate($data, $maximum, bool $exclusiveMaximum = false): void Validator::numeric()->assert($data); Validator::numeric()->assert($maximum); } - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MinItems.php b/src/Schema/Keywords/MinItems.php index 74601a0d..1938708f 100644 --- a/src/Schema/Keywords/MinItems.php +++ b/src/Schema/Keywords/MinItems.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function count; use function sprintf; @@ -35,7 +34,7 @@ public function validate($data, int $minItems): void Validator::arrayType()->assert($data); Validator::intVal()->assert($minItems); Validator::trueVal()->assert($minItems >= 0); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MinLength.php b/src/Schema/Keywords/MinLength.php index 09a9c436..6340fcd2 100644 --- a/src/Schema/Keywords/MinLength.php +++ b/src/Schema/Keywords/MinLength.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function mb_strlen; use function sprintf; @@ -39,7 +38,7 @@ public function validate($data, int $minLength): void Validator::stringType()->assert($data); Validator::intVal()->assert($minLength); Validator::trueVal()->assert($minLength >= 0); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MinProperties.php b/src/Schema/Keywords/MinProperties.php index 934b5f2b..989c77d0 100644 --- a/src/Schema/Keywords/MinProperties.php +++ b/src/Schema/Keywords/MinProperties.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function count; use function sprintf; @@ -34,7 +33,7 @@ public function validate($data, int $minProperties): void try { Validator::arrayType()->assert($data); Validator::trueVal()->assert($minProperties >= 0); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Minimum.php b/src/Schema/Keywords/Minimum.php index c41b7024..6e641ef2 100644 --- a/src/Schema/Keywords/Minimum.php +++ b/src/Schema/Keywords/Minimum.php @@ -6,10 +6,9 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Rules\NumericVal; use Respect\Validation\Validator; +use Throwable; use function class_exists; use function sprintf; @@ -49,7 +48,7 @@ public function validate($data, $minimum, bool $exclusiveMinimum = false): void Validator::numeric()->assert($data); Validator::numeric()->assert($minimum); } - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/MultipleOf.php b/src/Schema/Keywords/MultipleOf.php index 59d5cbab..c1990f35 100644 --- a/src/Schema/Keywords/MultipleOf.php +++ b/src/Schema/Keywords/MultipleOf.php @@ -6,16 +6,18 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Rules\NumericVal; use Respect\Validation\Validator; +use Throwable; use function class_exists; +use function round; use function sprintf; class MultipleOf extends BaseKeyword { + private const EPSILON = 0.00000001; + /** * The value of "multipleOf" MUST be a number, strictly greater than 0. * A numeric instance is only valid if division by this keyword's value results in an integer. @@ -35,13 +37,13 @@ public function validate($data, $multipleOf): void Validator::numeric()->assert($data); Validator::numeric()->positive()->assert($multipleOf); } - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } - $value = (float) ($data / $multipleOf); - if ($value - (int) $value !== 0.0) { - throw KeywordMismatch::fromKeyword('multipleOf', $data, sprintf('Division by %d did not resulted in integer', $multipleOf)); + $value = round($data / $multipleOf, 8); + if ($value - (int) $value > self::EPSILON) { + throw KeywordMismatch::fromKeyword('multipleOf', $data, sprintf('Division by %s did not resulted in integer', $multipleOf)); } } } diff --git a/src/Schema/Keywords/Not.php b/src/Schema/Keywords/Not.php index ae718ff7..0adfb61f 100644 --- a/src/Schema/Keywords/Not.php +++ b/src/Schema/Keywords/Not.php @@ -10,9 +10,8 @@ use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; class Not extends BaseKeyword { @@ -43,7 +42,7 @@ public function validate($data, CebeSchema $not): void { try { Validator::instance(CebeSchema::class)->assert($not); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Nullable.php b/src/Schema/Keywords/Nullable.php index d96b8e13..9569ac27 100644 --- a/src/Schema/Keywords/Nullable.php +++ b/src/Schema/Keywords/Nullable.php @@ -6,6 +6,9 @@ use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; +use function in_array; +use function is_string; + class Nullable extends BaseKeyword { /** @@ -17,8 +20,13 @@ class Nullable extends BaseKeyword */ public function validate($data, bool $nullable): void { - if (! $nullable && ($data === null)) { + if (! $nullable && ($data === null) && ! $this->nullableByType()) { throw KeywordMismatch::fromKeyword('nullable', $data, 'Value cannot be null'); } } + + public function nullableByType(): bool + { + return ! is_string($this->parentSchema->type) && in_array('null', $this->parentSchema->type); + } } diff --git a/src/Schema/Keywords/OneOf.php b/src/Schema/Keywords/OneOf.php index 91ab85cb..f5b3c8fb 100644 --- a/src/Schema/Keywords/OneOf.php +++ b/src/Schema/Keywords/OneOf.php @@ -12,9 +12,8 @@ use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\Exception\TooManyValidSchemas; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function count; use function sprintf; @@ -53,7 +52,7 @@ public function validate($data, array $oneOf): void try { Validator::arrayVal()->assert($oneOf); Validator::each(Validator::instance(CebeSchema::class))->assert($oneOf); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Pattern.php b/src/Schema/Keywords/Pattern.php index be405cc2..c2ef4bb9 100644 --- a/src/Schema/Keywords/Pattern.php +++ b/src/Schema/Keywords/Pattern.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function preg_match; use function sprintf; @@ -34,7 +33,7 @@ public function validate($data, string $pattern): void try { Validator::stringType()->assert($data); Validator::stringType()->assert($pattern); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Properties.php b/src/Schema/Keywords/Properties.php index 1b185a24..52ae621a 100644 --- a/src/Schema/Keywords/Properties.php +++ b/src/Schema/Keywords/Properties.php @@ -10,9 +10,8 @@ use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function array_diff; use function array_key_exists; @@ -70,7 +69,7 @@ public function validate($data, array $properties, $additionalProperties): void Validator::arrayType()->assert($data); Validator::arrayVal()->assert($properties); Validator::each(Validator::instance(CebeSchema::class))->assert($properties); - } catch (Exception | ExceptionInterface $exception) { + } catch (Throwable $exception) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($exception); } diff --git a/src/Schema/Keywords/Required.php b/src/Schema/Keywords/Required.php index bf472aab..b64eefcc 100644 --- a/src/Schema/Keywords/Required.php +++ b/src/Schema/Keywords/Required.php @@ -9,9 +9,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; use League\OpenAPIValidation\Schema\SchemaValidator; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function array_unique; use function count; @@ -54,7 +53,7 @@ public function validate($data, array $required): void Validator::arrayType()->assert($required); Validator::each(Validator::stringType())->assert($required); Validator::trueVal()->assert(count(array_unique($required)) === count($required)); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/Keywords/Type.php b/src/Schema/Keywords/Type.php index 17d027f2..0e35e2da 100644 --- a/src/Schema/Keywords/Type.php +++ b/src/Schema/Keywords/Type.php @@ -11,6 +11,7 @@ use League\OpenAPIValidation\Schema\Exception\TypeMismatch; use League\OpenAPIValidation\Schema\TypeFormats\FormatsContainer; use RuntimeException; +use TypeError; use function class_exists; use function is_array; @@ -32,51 +33,80 @@ class Type extends BaseKeyword * An instance matches successfully if its primitive type is one of the * types defined by keyword. Recall: "number" includes "integer". * - * @param mixed $data + * @param mixed $data + * @param string|string[] $types * * @throws TypeMismatch */ - public function validate($data, string $type, ?string $format = null): void + public function validate($data, $types, ?string $format = null): void { - switch ($type) { - case CebeType::OBJECT: - if (! is_object($data) && ! (is_array($data) && ArrayHelper::isAssoc($data)) && $data !== []) { - throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::OBJECT, $data); - } - - break; - case CebeType::ARRAY: - if (! is_array($data) || ArrayHelper::isAssoc($data)) { - throw TypeMismatch::becauseTypeDoesNotMatch('array', $data); - } - - break; - case CebeType::BOOLEAN: - if (! is_bool($data)) { - throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::BOOLEAN, $data); - } - - break; - case CebeType::NUMBER: - if (is_string($data) || ! is_numeric($data)) { - throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::NUMBER, $data); - } - - break; - case CebeType::INTEGER: - if (! is_int($data)) { - throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::INTEGER, $data); - } - - break; - case CebeType::STRING: - if (! is_string($data)) { - throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::STRING, $data); - } - - break; - default: - throw InvalidSchema::becauseTypeIsNotKnown($type); + if (! is_array($types) && ! is_string($types)) { + throw new TypeError('$types only can be array or string'); + } + + if (! is_array($types)) { + $types = [$types]; + } + + $matchedType = false; + foreach ($types as $type) { + switch ($type) { + case CebeType::OBJECT: + if (! is_object($data) && ! (is_array($data) && ArrayHelper::isAssoc($data)) && $data !== []) { + break; + } + + $matchedType = $type; + break; + case CebeType::ARRAY: + if (! is_array($data) || ArrayHelper::isAssoc($data)) { + break; + } + + $matchedType = $type; + break; + case CebeType::BOOLEAN: + if (! is_bool($data)) { + break; + } + + $matchedType = $type; + break; + case CebeType::NUMBER: + if (is_string($data) || ! is_numeric($data)) { + break; + } + + $matchedType = $type; + break; + case CebeType::INTEGER: + if (! is_int($data)) { + break; + } + + $matchedType = $type; + break; + case CebeType::STRING: + if (! is_string($data)) { + break; + } + + $matchedType = $type; + break; + case CebeType::NULL: + if ($data !== null) { + break; + } + + $matchedType = $type; + break; + default: + throw InvalidSchema::becauseTypeIsNotKnown($type); + } + } + + if ($matchedType === false) { + throw TypeMismatch::becauseTypeDoesNotMatch($types, $data); } // 2. Validate format now @@ -85,7 +115,7 @@ public function validate($data, string $type, ?string $format = null): void return; } - $formatValidator = FormatsContainer::getFormat($type, $format); // callable or FQCN + $formatValidator = FormatsContainer::getFormat($matchedType, $format); // callable or FQCN if ($formatValidator === null) { return; } @@ -99,7 +129,7 @@ public function validate($data, string $type, ?string $format = null): void } if (! $formatValidator($data)) { - throw FormatMismatch::fromFormat($format, $data, $type); + throw FormatMismatch::fromFormat($format, $data, $matchedType); } } } diff --git a/src/Schema/Keywords/UniqueItems.php b/src/Schema/Keywords/UniqueItems.php index 3878198e..edb67075 100644 --- a/src/Schema/Keywords/UniqueItems.php +++ b/src/Schema/Keywords/UniqueItems.php @@ -6,9 +6,8 @@ use League\OpenAPIValidation\Schema\Exception\InvalidSchema; use League\OpenAPIValidation\Schema\Exception\KeywordMismatch; -use Respect\Validation\Exceptions\Exception; -use Respect\Validation\Exceptions\ExceptionInterface; use Respect\Validation\Validator; +use Throwable; use function array_map; use function array_unique; @@ -39,7 +38,7 @@ public function validate($data, bool $uniqueItems): void try { Validator::arrayType()->assert($data); - } catch (Exception | ExceptionInterface $e) { + } catch (Throwable $e) { throw InvalidSchema::becauseDefensiveSchemaValidationFailed($e); } diff --git a/src/Schema/TypeFormats/NumberDouble.php b/src/Schema/TypeFormats/NumberDouble.php index 4d3fc386..d7f80b3b 100644 --- a/src/Schema/TypeFormats/NumberDouble.php +++ b/src/Schema/TypeFormats/NumberDouble.php @@ -14,7 +14,6 @@ class NumberDouble */ public function __invoke($value): bool { - // treat integers as valid floats return is_float($value + 0) || is_int($value + 0); } } diff --git a/src/Schema/TypeFormats/NumberFloat.php b/src/Schema/TypeFormats/NumberFloat.php index c788375d..8240f5df 100644 --- a/src/Schema/TypeFormats/NumberFloat.php +++ b/src/Schema/TypeFormats/NumberFloat.php @@ -14,7 +14,6 @@ class NumberFloat */ public function __invoke($value): bool { - // treat integers as valid floats return is_float($value + 0) || is_int($value + 0); } } diff --git a/tests/PSR7/BaseValidatorTest.php b/tests/PSR7/BaseValidatorTest.php index dd82c382..bb794e22 100644 --- a/tests/PSR7/BaseValidatorTest.php +++ b/tests/PSR7/BaseValidatorTest.php @@ -28,7 +28,7 @@ protected function makeGoodResponse(string $path, string $method): ResponseInter { switch ($method . ' ' . $path) { case 'get /path1': - $body = ['propA' => 1]; + $body = ['propA' => 1, 'propD' => [1, 'string', null]]; return (new Response()) ->withHeader('Content-Type', 'application/json') diff --git a/tests/PSR7/CookieDeserializeTest.php b/tests/PSR7/CookieDeserializeTest.php index 9107f3de..04a92683 100644 --- a/tests/PSR7/CookieDeserializeTest.php +++ b/tests/PSR7/CookieDeserializeTest.php @@ -13,14 +13,27 @@ final class CookieDeserializeTest extends BaseValidatorTest { - public function testItDeserializesServerRequestCookieParametersGreen(): void + /** + * @return mixed[][] + */ + public function dataProviderCookiesGreen(): array + { + return [ + ['num' , '-1.2'], + ['int' , '414'], + ['bool', 'true'], + ['bool', '1'], + ['bool', '0'], + ]; + } + + /** + * @dataProvider dataProviderCookiesGreen + */ + public function testItDeserializesServerRequestCookieParametersGreen(string $cookieName, string $cookieValue): void { $request = (new ServerRequest('get', new Uri('/deserialize-cookies'))) - ->withCookieParams([ - 'num' => '-1.2', - 'int' => '414', - 'bool' => 'true', - ]); + ->withCookieParams([$cookieName => $cookieValue]); $validator = (new ValidatorBuilder())->fromYamlFile($this->apiSpecFile)->getServerRequestValidator(); $validator->validate($request); @@ -37,7 +50,7 @@ public function dataProviderCookiesRed(): array ['num', 'ac'], ['int', 'ac'], ['int', '1.0'], - ['bool', '1'], + ['bool', '2'], ['bool', 'yes'], ['bool', ''], ]; diff --git a/tests/PSR7/HeadersTest.php b/tests/PSR7/HeadersTest.php index 8dc518f3..8bf7d3f5 100644 --- a/tests/PSR7/HeadersTest.php +++ b/tests/PSR7/HeadersTest.php @@ -22,12 +22,27 @@ public function testItValidatesRequestQueryArgumentsGreen(): void $this->addToAssertionCount(1); } - public function testItDeserializesRequestHeaderParametersGreen(): void + /** + * @return mixed[][] + */ + public function dataProviderDeserializesRequestHeaderGreen(): array + { + return [ + ['num', '-1.2'], + ['int', '414'], + ['bool', 'true'], + ['bool', '1'], + ['bool', '0'], + ]; + } + + /** + * @dataProvider dataProviderDeserializesRequestHeaderGreen + */ + public function testItDeserializesRequestHeaderParametersGreen(string $headerName, string $headerValue): void { $request = (new ServerRequest('get', new Uri('/deserialize-headers'))) - ->withHeader('num', '-1.2') - ->withHeader('int', '414') - ->withHeader('bool', 'true'); + ->withHeader($headerName, $headerValue); $validator = (new ValidatorBuilder())->fromYamlFile($this->apiSpecFile)->getServerRequestValidator(); $validator->validate($request); @@ -44,7 +59,7 @@ public function dataProviderDeserializesRequestHeaderRed(): array ['num', 'ac'], ['int', 'ac'], ['int', '1.0'], - ['bool', '1'], + ['bool', '2'], ['bool', 'yes'], ['bool', ''], ]; diff --git a/tests/PSR7/PathFinderTest.php b/tests/PSR7/PathFinderTest.php index 36d95765..8154d958 100644 --- a/tests/PSR7/PathFinderTest.php +++ b/tests/PSR7/PathFinderTest.php @@ -116,4 +116,56 @@ public function testItFindsMatchingOperationForMultipleServersWithSamePath(): vo $this->assertCount(1, $opAddrs); $this->assertEquals('/products/{id}', $opAddrs[0]->path()); } + + public function testItPrioritisesOperatorsThatAreMoreStatic(): void + { + $spec = <<search(); + + $this->assertCount(1, $opAddrs); + $this->assertEquals('/products/{product}/images/thumbnails', $opAddrs[0]->path()); + } + + public function testItPrioritises2EquallyDynamicPaths(): void + { + $spec = <<search(); + + $this->assertCount(2, $opAddrs); + $this->assertEquals('/products/{product}/images/thumbnails/{size}', $opAddrs[0]->path()); + $this->assertEquals('/products/{product}/images/{image}/primary', $opAddrs[1]->path()); + } } diff --git a/tests/PSR7/PathParametersTest.php b/tests/PSR7/PathParametersTest.php index 59b1e665..c9520ec5 100644 --- a/tests/PSR7/PathParametersTest.php +++ b/tests/PSR7/PathParametersTest.php @@ -80,4 +80,59 @@ public function testParsesFormat(): void $validator->validate($request); $this->addToAssertionCount(1); } + + public function testItValidatesPathParameterArray(): void + { + // dot in path template must be handled with care + $specFile = __DIR__ . '/../stubs/pathParams.yaml'; + $request = new ServerRequest('get', '/array/1,2,3,99'); + + $validator = (new ValidatorBuilder())->fromYamlFile($specFile)->getServerRequestValidator(); + $validator->validate($request); + $this->addToAssertionCount(1); + } + + public function testItValidatesPathParameterSimpleArray(): void + { + // dot in path template must be handled with care + $specFile = __DIR__ . '/../stubs/pathParams.yaml'; + $request = new ServerRequest('get', '/arrayLabel/.1,2,3,99'); + + $validator = (new ValidatorBuilder())->fromYamlFile($specFile)->getServerRequestValidator(); + $validator->validate($request); + $this->addToAssertionCount(1); + } + + public function testItValidatesPathParameterExplodedSimpleArray(): void + { + // dot in path template must be handled with care + $specFile = __DIR__ . '/../stubs/pathParams.yaml'; + $request = new ServerRequest('get', '/arrayLabelExploded/.1.2.3.99'); + + $validator = (new ValidatorBuilder())->fromYamlFile($specFile)->getServerRequestValidator(); + $validator->validate($request); + $this->addToAssertionCount(1); + } + + public function testItValidatesPathParameterMatrixArray(): void + { + // dot in path template must be handled with care + $specFile = __DIR__ . '/../stubs/pathParams.yaml'; + $request = new ServerRequest('get', '/arrayMatrix/;id=1,2,3,99'); + + $validator = (new ValidatorBuilder())->fromYamlFile($specFile)->getServerRequestValidator(); + $validator->validate($request); + $this->addToAssertionCount(1); + } + + public function testItValidatesPathParameterExplodedMatrixArray(): void + { + // dot in path template must be handled with care + $specFile = __DIR__ . '/../stubs/pathParams.yaml'; + $request = new ServerRequest('get', '/arrayMatrixExploded/;id=1;id=2;id=3;id=99'); + + $validator = (new ValidatorBuilder())->fromYamlFile($specFile)->getServerRequestValidator(); + $validator->validate($request); + $this->addToAssertionCount(1); + } } diff --git a/tests/PSR7/Validators/BodyValidator/MultipartValidatorTest.php b/tests/PSR7/Validators/BodyValidator/MultipartValidatorTest.php index ee85c0c0..e93300c0 100644 --- a/tests/PSR7/Validators/BodyValidator/MultipartValidatorTest.php +++ b/tests/PSR7/Validators/BodyValidator/MultipartValidatorTest.php @@ -11,6 +11,7 @@ use League\OpenAPIValidation\PSR7\Exception\Validation\InvalidHeaders; use League\OpenAPIValidation\PSR7\ValidatorBuilder; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\UploadedFileInterface; use function filesize; use function GuzzleHttp\Psr7\parse_request; @@ -127,6 +128,54 @@ public function dataProviderMultipartGreen(): array Content-Disposition: form-data; name="image"; filename="file1.txt" Content-Type: image/whatever +[file content goes there] +------WebKitFormBoundaryWfPNVh4wuWBlyEyQ-- +HTTP +, + ], + // specified headers for one part (multiple, with charset) + [ + <<validate($serverRequest); } - public function testValidateMultipartServerRequestGreen(): void + /** + * @return mixed[][] + */ + public function dataProviderMultipartServerRequestGreen(): array { - $specFile = __DIR__ . '/../../../stubs/multipart.yaml'; - $imagePath = __DIR__ . '/../../../stubs/image.jpg'; $imageSize = filesize($imagePath); - $serverRequest = (new ServerRequest('post', new Uri('/multipart'))) - ->withHeader('Content-Type', 'multipart/form-data') - ->withParsedBody([ - 'id' => 'bc8e1430-a963-11e9-a2a3-2a2ae2dbcce4', - 'address' => [ - 'street' => 'Some street', - 'city' => 'some city', + return [ + // Normal multipart message + [ + 'post', + '/multipart', + [ + 'id' => 'bc8e1430-a963-11e9-a2a3-2a2ae2dbcce4', + 'address' => [ + 'street' => 'Some street', + 'city' => 'some city', + ], + ], + [ + 'profileImage' => new UploadedFile($imagePath, $imageSize, 0), + ], + ], + // Missing optional field with defined encoding + [ + 'post', + '/multipart/encoding', + [], + [ + 'image' => new UploadedFile($imagePath, $imageSize, 0), ], - ]) - ->withUploadedFiles([ - 'profileImage' => new UploadedFile($imagePath, $imageSize, 0), - ]); + ], + ]; + } + + /** + * @param string[] $body + * @param array $files + * + * @dataProvider dataProviderMultipartServerRequestGreen + */ + public function testValidateMultipartServerRequestGreen(string $method, string $uri, array $body = [], array $files = []): void + { + $specFile = __DIR__ . '/../../../stubs/multipart.yaml'; + + $serverRequest = (new ServerRequest($method, new Uri($uri))) + ->withHeader('Content-Type', 'multipart/form-data') + ->withParsedBody($body) + ->withUploadedFiles($files); $validator = (new ValidatorBuilder())->fromYamlFile($specFile)->getServerRequestValidator(); $validator->validate($serverRequest); diff --git a/tests/PSR7/Validators/SerializedParameterTest.php b/tests/PSR7/Validators/SerializedParameterTest.php index 471bbbaa..087bc203 100644 --- a/tests/PSR7/Validators/SerializedParameterTest.php +++ b/tests/PSR7/Validators/SerializedParameterTest.php @@ -31,7 +31,7 @@ public function testDeserializeThrowsSchemaMismatchExceptionIfValueIsNotStringWh $subject = new SerializedParameter($this->createMock(Schema::class), 'application/json'); $this->expectException(SchemaMismatch::class); - $this->expectExceptionMessage("Value expected to be 'string', 'array' given"); + $this->expectExceptionMessage("Value expected to be 'string', but 'array' given"); $subject->deserialize(['green', 'red']); } diff --git a/tests/Schema/Keywords/MultipleOfTest.php b/tests/Schema/Keywords/MultipleOfTest.php index 009f0930..68a3db31 100644 --- a/tests/Schema/Keywords/MultipleOfTest.php +++ b/tests/Schema/Keywords/MultipleOfTest.php @@ -20,6 +20,7 @@ public function validDatasets(): array [10.0, 2], [10, .5], [9.9, .3], + [.94, .01], ]; } @@ -33,6 +34,8 @@ public function invalidDatasets(): array [10.0, 3], [10, .11], [9.9, .451], + [.94, .03], + [1, .3333333], ]; } diff --git a/tests/stubs/api.yaml b/tests/stubs/api.yaml index 46e2a2ec..90b59b8a 100644 --- a/tests/stubs/api.yaml +++ b/tests/stubs/api.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.2 +openapi: 3.1.0 info: title: Weather API version: 0.0.1 diff --git a/tests/stubs/multipart.yaml b/tests/stubs/multipart.yaml index df4fa9e9..b018b4c6 100644 --- a/tests/stubs/multipart.yaml +++ b/tests/stubs/multipart.yaml @@ -66,13 +66,37 @@ paths: multipart/form-data: schema: type: object + required: + - image properties: image: type: string format: binary + description: + type: string encoding: image: contentType: specific/type + description: + contentType: text/plain + responses: + 204: + description: good post + /multipart/encoding/multiple: + post: + summary: --- + operationId: post-multipart-encoding-multiple + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + data: + type: string + encoding: + data: + contentType: application/xml; charset="utf-8", application/json, text/* responses: 204: description: good post diff --git a/tests/stubs/pathParams.yaml b/tests/stubs/pathParams.yaml index 3d9b2190..33be5bda 100644 --- a/tests/stubs/pathParams.yaml +++ b/tests/stubs/pathParams.yaml @@ -79,3 +79,84 @@ paths: responses: 204: description: No response + /array/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: array + items: + type: integer + get: + summary: Read data + operationId: read-array-int + responses: + 204: + description: No reponse + /arrayLabel/{id}: + parameters: + - name: id + in: path + required: true + style: label + schema: + type: array + items: + type: integer + get: + summary: Read data + operationId: read-array-int-label + responses: + 204: + description: No reponse + /arrayLabelExploded/{id}: + parameters: + - name: id + in: path + required: true + style: label + explode: true + schema: + type: array + items: + type: integer + get: + summary: Read data + operationId: read-array-int-label-explode + responses: + 204: + description: No reponse + /arrayMatrix/{id}: + parameters: + - name: id + in: path + required: true + style: matrix + schema: + type: array + items: + type: integer + get: + summary: Read data + operationId: read-array-int-matrix + responses: + 204: + description: No reponse + /arrayMatrixExploded/{id}: + parameters: + - name: id + in: path + required: true + style: matrix + explode: true + schema: + type: array + items: + type: integer + get: + summary: Read data + operationId: read-array-int-matrix + responses: + 204: + description: No reponse \ No newline at end of file diff --git a/tests/stubs/schemas.yaml b/tests/stubs/schemas.yaml index ae7f00b8..3b891e1a 100644 --- a/tests/stubs/schemas.yaml +++ b/tests/stubs/schemas.yaml @@ -13,6 +13,13 @@ components: type: array items: type: string + propD: + type: array + items: + type: + - string + - integer + - 'null' required: - propA - propB