diff --git a/composer.json b/composer.json index 2cc61a43..d4d34932 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "php": ">=7.2", "ext-bcmath": "*", "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", 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 b7f13b71..36d88d6c 100644 --- a/src/PSR7/Validators/BodyValidator/MultipartValidator.php +++ b/src/PSR7/Validators/BodyValidator/MultipartValidator.php @@ -73,7 +73,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()) { diff --git a/src/PSR7/Validators/SerializedParameter.php b/src/PSR7/Validators/SerializedParameter.php index 6f05474f..b790e784 100644 --- a/src/PSR7/Validators/SerializedParameter.php +++ b/src/PSR7/Validators/SerializedParameter.php @@ -97,7 +97,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); @@ -172,7 +172,7 @@ protected function convertToSerializationStyle($value, ?CebeSchema $schema) } if (! is_iterable($value)) { - throw TypeMismatch::becauseTypeDoesNotMatch('iterable', $value); + throw TypeMismatch::becauseTypeDoesNotMatch(['iterable'], $value); } foreach ($value as &$val) { 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/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/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/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/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/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/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