From 9274d6c9df9c97c4ee88b1bb8375465e7464fe84 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Tue, 13 Oct 2026 17:11:48 +1300 Subject: [PATCH] Validate with opis/json-schema, which supports OpenAPI 3.1 Bodies are validated against JSON Schema 2020-12, and OpenAPI 3.0 documents are translated first: `nullable` and the boolean `exclusiveMinimum` and `exclusiveMaximum`. A mismatch carries `Schema\SchemaError`s rather than the engine's own arrays, so changing the engine again changes nothing for users. Validators get the document's OpenAPI version and translate what they need. --- README.md | 16 +++ composer.json | 2 +- src/OpenApiSchemaMismatchException.php | 66 +++------ src/OpenApiSpecificationLoader.php | 53 +++---- src/OpenApiVerifier.php | 26 ++-- src/Schema/OpenApi30Translator.php | 87 +++++++++++ src/Schema/OpisSchemaValidator.php | 66 +++++++++ src/Schema/SchemaError.php | 20 +++ src/Schema/SchemaValidator.php | 23 +++ tests/Adapters/ClientAdapterTest.php | 4 +- tests/Adapters/LaravelAdapterTest.php | 4 +- tests/Adapters/Psr15AdapterTest.php | 2 +- tests/Adapters/SlimAdapterTest.php | 6 +- tests/Fixtures/UserController.php | 8 +- tests/OpenApiSchemaMismatchExceptionTest.php | 144 ++++++------------- tests/OpenApiSpecificationLoaderTest.php | 17 ++- tests/OpenApiVerifierTest.php | 97 +++++++++++++ tests/specifications/users.json | 8 +- tests/specifications/users.yaml | 8 +- 19 files changed, 432 insertions(+), 225 deletions(-) create mode 100644 src/Schema/OpenApi30Translator.php create mode 100644 src/Schema/OpisSchemaValidator.php create mode 100644 src/Schema/SchemaError.php create mode 100644 src/Schema/SchemaValidator.php diff --git a/README.md b/README.md index a80b2fb..519805c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,22 @@ How the response is looked up: * **Media type**: the response's `Content-Type`, including `+json` types such as `application/problem+json`. Without a match it falls back to `application/json`. Only JSON bodies are validated. +Bodies are validated with [opis/json-schema](https://github.com/opis/json-schema), which supports JSON Schema +2020-12, the dialect of OpenAPI 3.1. OpenAPI 3.0 documents are translated first: `nullable` and the boolean form of +`exclusiveMinimum` and `exclusiveMaximum` become their 2020-12 equivalents. + +Another engine can be used by passing a `Schema\SchemaValidator` to the verifier. It gets each schema as the document +writes it, together with the document's OpenAPI version, so it can handle 3.0 its own way or use +`Schema\OpenApi30Translator`. + +A mismatch carries one `Schema\SchemaError` per problem, with a JSON pointer into the body, the failing keyword and a +message. `getErrorSummary()` groups them: + +``` +required - The required properties (name) are missing + - /data/* (2 more) +``` + ### Guzzle and PSR-18 clients Verifying on the client side needs no framework. It works against a running server, a mock handler or anything else a client can talk to. diff --git a/composer.json b/composer.json index 6783bbd..2b0d34b 100644 --- a/composer.json +++ b/composer.json @@ -53,9 +53,9 @@ "require": { "php": ">=8.2", "ext-json": "*", - "justinrainbow/json-schema": "^5.2.13 || ^6", "nyholm/psr7": "^1.1", "nyholm/psr7-server": "^1.0", + "opis/json-schema": "^2.4", "psr/http-client": "^1.0", "psr/http-message": "^2.0", "psr/http-server-middleware": "^1.0", diff --git a/src/OpenApiSchemaMismatchException.php b/src/OpenApiSchemaMismatchException.php index 7d18ffa..9ded664 100644 --- a/src/OpenApiSchemaMismatchException.php +++ b/src/OpenApiSchemaMismatchException.php @@ -2,22 +2,15 @@ namespace Radebatz\OpenApi\Verifier; -/** - * @phpstan-type JsonSchemaValidationError array{ - * property: string, - * pointer: string, - * message: string, - * constraint: string|array{name: string, params: array}, - * context: int - * } - */ +use Radebatz\OpenApi\Verifier\Schema\SchemaError; + class OpenApiSchemaMismatchException extends \Exception { - /** @var list */ + /** @var list */ protected array $errors = []; /** - * @return list + * @return list */ public function getErrors(): array { @@ -25,7 +18,7 @@ public function getErrors(): array } /** - * @param list $errors + * @param list $errors */ public function setErrors(array $errors): OpenApiSchemaMismatchException { @@ -34,49 +27,32 @@ public function setErrors(array $errors): OpenApiSchemaMismatchException return $this; } + /** + * The errors grouped by keyword and message, one line per failing pointer. + * + * Array indices in a pointer are replaced by `*`, so the same error on every item of a list + * is one line with a count. + */ public function getErrorSummary(): ?string { if (!$this->errors) { return null; } - // sanitize - $errorTypeMap = []; + $groups = []; foreach ($this->errors as $error) { - $wildcarded = preg_replace('/\[\d+\]/', '[*]', $error['property']); - - // ensures compatibility with both, JsonSchema 5.x and 6.x - $constraintName = $error['constraint']['name'] - ?? $error['constraint']; - - $errorType = sprintf('%s|%s', $constraintName, $error['message']); - if (!array_key_exists($errorType, $errorTypeMap)) { - $errorTypeMap[$errorType] = [ - 'properties' => [ - $wildcarded => 1, - ], - 'constraint' => $constraintName, - 'message' => $error['message'], - ]; - } elseif (!array_key_exists($wildcarded, $errorTypeMap[$errorType]['properties'])) { - // track usage of the same wildcard-property - $errorTypeMap[$errorType]['properties'][$wildcarded] = 1; - } else { - $errorTypeMap[$errorType]['properties'][$wildcarded]++; - } + $pointer = (string) preg_replace('#/\d+(?=/|$)#', '/*', $error->pointer) ?: '/'; + $key = $error->keyword . '|' . $error->message; + $groups[$key]['keyword'] = $error->keyword; + $groups[$key]['message'] = $error->message; + $groups[$key]['pointers'][$pointer] = ($groups[$key]['pointers'][$pointer] ?? 0) + 1; } $summary = []; - foreach ($errorTypeMap as $errorType) { - $summary[] = sprintf('%s - %s', $errorType['constraint'], $errorType['message']); - - foreach ($errorType['properties'] as $propertyName => $propertyAmount) { - // if the property errored multiple times, add the amount to the error-message - if (1 === $propertyAmount) { - $summary[] = sprintf(' - %s', $propertyName); - } else { - $summary[] = sprintf(' - %s (%s more)', $propertyName, $propertyAmount - 1); - } + foreach ($groups as $group) { + $summary[] = sprintf('%s - %s', $group['keyword'], $group['message']); + foreach ($group['pointers'] as $pointer => $count) { + $summary[] = 1 === $count ? sprintf(' - %s', $pointer) : sprintf(' - %s (%s more)', $pointer, $count - 1); } } diff --git a/src/OpenApiSpecificationLoader.php b/src/OpenApiSpecificationLoader.php index 921f775..be652b7 100644 --- a/src/OpenApiSpecificationLoader.php +++ b/src/OpenApiSpecificationLoader.php @@ -45,29 +45,23 @@ protected function resolveSpecification($specification) throw new \InvalidArgumentException('Invalid specification'); } - return $this->fixNullable($resolved); + return $resolved; } /** - * Get a schema url for the response matching the given parameters. - * - * `$path` may be a concrete path (`/user/1`) or a template from the specification (`/user/{id}`). + * The document's OpenAPI version, `3.0.0` if it does not say. */ - public function getResponseSchemaUrlFor(string $method, string $path, int $statusCode, ?string $contentType = null): ?string + public function getOpenApiVersion(): string { - if (!($schema = $this->getResponseSchemaFor($method, $path, $statusCode, $contentType)) instanceof \stdClass) { - return null; - } - - // copy, so the components do not end up inside the specification itself - $schema = clone $schema; - $schema->components = $this->property($this->specification, 'components') ?? new \stdClass(); - - return 'data://application/json;base64,' . base64_encode((string) json_encode($schema)); + return (string) ($this->property($this->specification, 'openapi') ?? '3.0.0'); } /** * Get the schema of the response matching the given parameters, if there is one. + * + * `$path` may be a concrete path (`/user/1`) or a template from the specification (`/user/{id}`). + * The schema is as the document writes it, in the dialect of `getOpenApiVersion()`, and + * carries the specification's `components`, so `#/components/...` references resolve within it. */ public function getResponseSchemaFor(string $method, string $path, int $statusCode, ?string $contentType = null): ?\stdClass { @@ -79,9 +73,15 @@ public function getResponseSchemaFor(string $method, string $path, int $statusCo $operation = $this->property($pathItem, strtolower($method)); $response = $this->resolveResponse($this->property($operation, 'responses'), $statusCode); $mediaType = $this->resolveMediaType($this->property($response, 'content'), $contentType); - $schema = $this->property($mediaType, 'schema'); + if (null === ($schema = $this->property($mediaType, 'schema'))) { + return null; + } - return null !== $schema ? (object) $schema : null; + // copy, so the components do not end up inside the specification itself + $schema = is_object($schema) ? clone $schema : (object) $schema; + $schema->components = $this->property($this->specification, 'components') ?? new \stdClass(); + + return $schema; } /** @@ -218,25 +218,4 @@ protected function property(mixed $node, string $name): mixed return null; } - - // https://github.com/justinrainbow/json-schema/issues/551 - protected function fixNullable(&$node) - { - if (is_object($node) && property_exists($node, 'nullable') && property_exists($node, 'type')) { - $anyOf = [ - ['type' => $node->type, 'format' => property_exists($node, 'format') ? $node->format : ''], - ['type' => null], - ]; - unset($node->type); - $node->anyOf = $anyOf; - } - - if (is_iterable($node) || is_object($node)) { - foreach ($node as &$value) { - $this->fixNullable($value); - } - } - - return $node; - } } diff --git a/src/OpenApiVerifier.php b/src/OpenApiVerifier.php index ca0c4f4..5d92e32 100644 --- a/src/OpenApiVerifier.php +++ b/src/OpenApiVerifier.php @@ -2,10 +2,10 @@ namespace Radebatz\OpenApi\Verifier; -use JsonSchema\Uri\UriRetriever; -use JsonSchema\Validator; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Radebatz\OpenApi\Verifier\Schema\OpisSchemaValidator; +use Radebatz\OpenApi\Verifier\Schema\SchemaValidator; /** * Verifies responses against an OpenAPI specification. @@ -14,16 +14,21 @@ */ final class OpenApiVerifier { - public function __construct(private readonly OpenApiSpecificationLoader $specificationLoader) - { + private readonly SchemaValidator $schemaValidator; + + public function __construct( + private readonly OpenApiSpecificationLoader $specificationLoader, + ?SchemaValidator $schemaValidator = null, + ) { + $this->schemaValidator = $schemaValidator ?? new OpisSchemaValidator(); } /** * @param object|string $specification the specification object or filename */ - public static function fromSpecification(object|string $specification): self + public static function fromSpecification(object|string $specification, ?SchemaValidator $schemaValidator = null): self { - return new self(new OpenApiSpecificationLoader($specification)); + return new self(new OpenApiSpecificationLoader($specification), $schemaValidator); } public function getSpecificationLoader(): OpenApiSpecificationLoader @@ -68,16 +73,13 @@ public function verify(RequestInterface $request, ResponseInterface $response, ? */ public function verifyResponseBody(string $method, string $path, int $statusCode, string $body, ?string $contentType = null): bool { - if (!$schemaUrl = $this->specificationLoader->getResponseSchemaUrlFor($method, $path, $statusCode, $contentType)) { + if (!($schema = $this->specificationLoader->getResponseSchemaFor($method, $path, $statusCode, $contentType)) instanceof \stdClass) { return false; } - $validator = new Validator(); - $validator->check(json_decode($body), (new UriRetriever())->retrieve($schemaUrl)); - - if (!$validator->isValid()) { + if ([] !== ($errors = $this->schemaValidator->validate(json_decode($body), $schema, $this->specificationLoader->getOpenApiVersion()))) { throw (new OpenApiSchemaMismatchException(sprintf('Schema mismatch: %s[%s]:%s', $method, $statusCode, $path))) - ->setErrors($validator->getErrors()); + ->setErrors($errors); } return true; diff --git a/src/Schema/OpenApi30Translator.php b/src/Schema/OpenApi30Translator.php new file mode 100644 index 0000000..69702d4 --- /dev/null +++ b/src/Schema/OpenApi30Translator.php @@ -0,0 +1,87 @@ +, {type: null}]` + * - boolean `exclusiveMinimum`/`exclusiveMaximum` take the value of `minimum`/`maximum` + * + * Only boolean values are rewritten, so a property that happens to be named `nullable` is not. + */ +final class OpenApi30Translator +{ + /** + * Whether schemas from a document of this OpenAPI version need translating. + */ + public function needsTranslation(string $openApiVersion): bool + { + return str_starts_with($openApiVersion, '3.0'); + } + + /** + * @return \stdClass a translated copy; the given schema is left as it is + */ + public function translate(\stdClass $schema): \stdClass + { + // a deep copy, since the schema shares its components with the loaded specification + $copy = json_decode((string) json_encode($schema)); + + // translated on their own, so they stay at the root even when the schema gets wrapped + $components = $copy->components ?? null; + unset($copy->components); + + $translated = $this->translateNode($copy); + if (null !== $components) { + $translated->components = $this->translateNode($components); + } + + return $translated; + } + + private function translateNode(mixed $node): mixed + { + if (is_array($node)) { + return array_map($this->translateNode(...), $node); + } + + if (!is_object($node)) { + return $node; + } + + foreach (get_object_vars($node) as $name => $value) { + $node->{$name} = $this->translateNode($value); + } + + foreach (['exclusiveMinimum' => 'minimum', 'exclusiveMaximum' => 'maximum'] as $exclusive => $bound) { + if (is_bool($node->{$exclusive} ?? null)) { + if ($node->{$exclusive} && property_exists($node, $bound)) { + $node->{$exclusive} = $node->{$bound}; + unset($node->{$bound}); + } else { + unset($node->{$exclusive}); + } + } + } + + if (is_bool($node->nullable ?? null)) { + $nullable = $node->nullable; + unset($node->nullable); + + if ($nullable) { + if (property_exists($node, 'type')) { + $node->type = array_values(array_unique([...(array) $node->type, 'null'])); + if (is_array($node->enum ?? null) && !in_array(null, $node->enum, true)) { + $node->enum[] = null; + } + } else { + $node = (object) ['anyOf' => [$node, (object) ['type' => 'null']]]; + } + } + } + + return $node; + } +} diff --git a/src/Schema/OpisSchemaValidator.php b/src/Schema/OpisSchemaValidator.php new file mode 100644 index 0000000..404baad --- /dev/null +++ b/src/Schema/OpisSchemaValidator.php @@ -0,0 +1,66 @@ +validator = (new Validator()) + ->setMaxErrors($maxErrors) + ->setStopAtFirstError(false); + $this->formatter = new ErrorFormatter(); + $this->translator = new OpenApi30Translator(); + } + + public function validate(mixed $data, \stdClass $schema, string $openApiVersion): array + { + if ($this->translator->needsTranslation($openApiVersion)) { + $schema = $this->translator->translate($schema); + } + + $error = $this->validator->validate($data, $schema)->error(); + + return $error instanceof ValidationError ? $this->leaves($error) : []; + } + + /** + * Only the innermost errors say what is wrong; the ones above them say where it was nested. + * + * @return list + */ + private function leaves(ValidationError $error): array + { + if ([] === $error->subErrors()) { + $path = $error->data()->fullPath(); + + return [new SchemaError( + [] === $path ? '' : '/' . implode('/', $path), + $error->keyword(), + $this->formatter->formatErrorMessage($error), + )]; + } + + $leaves = []; + foreach ($error->subErrors() as $subError) { + array_push($leaves, ...$this->leaves($subError)); + } + + return $leaves; + } +} diff --git a/src/Schema/SchemaError.php b/src/Schema/SchemaError.php new file mode 100644 index 0000000..d589068 --- /dev/null +++ b/src/Schema/SchemaError.php @@ -0,0 +1,20 @@ + empty if the data is valid + */ + public function validate(mixed $data, \stdClass $schema, string $openApiVersion): array; +} diff --git a/tests/Adapters/ClientAdapterTest.php b/tests/Adapters/ClientAdapterTest.php index c650112..b569f36 100644 --- a/tests/Adapters/ClientAdapterTest.php +++ b/tests/Adapters/ClientAdapterTest.php @@ -27,9 +27,9 @@ public static function responses(): iterable { // path, body, expected error (null: valid) yield 'users' => ['/users', '{"data":[{"id":1,"name":"joe","email":"joe@cool.com"}]}', null]; - yield 'users mismatch' => ['/users', '{"data":[{"id":1,"email":"joe@cool.com"}]}', 'The property name is required']; + yield 'users mismatch' => ['/users', '{"data":[{"id":1,"email":"joe@cool.com"}]}', 'The required properties (name) are missing']; yield 'user, by template' => ['/user/1', '{"data":{"id":1,"name":"joe","email":"joe@cool.com"}}', null]; - yield 'user mismatch, by template' => ['/user/1', '{"data":{"id":1,"name":"joe"}}', 'The property email is required']; + yield 'user mismatch, by template' => ['/user/1', '{"data":{"id":1,"name":"joe"}}', 'The required properties (email) are missing']; yield 'not in the specification' => ['/nope', 'anything', null]; } diff --git a/tests/Adapters/LaravelAdapterTest.php b/tests/Adapters/LaravelAdapterTest.php index d8f147b..bc477db 100644 --- a/tests/Adapters/LaravelAdapterTest.php +++ b/tests/Adapters/LaravelAdapterTest.php @@ -46,7 +46,7 @@ public function failVerificationUsers(): void }); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/required - The property name is required/'); + $this->expectExceptionMessageMatches('/required - The required properties \(name\) are missing/'); $this->get('/users'); } @@ -72,7 +72,7 @@ public function failVerificationUser(): void }); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/required - The property email is required/'); + $this->expectExceptionMessageMatches('/required - The required properties \(email\) are missing/'); $this->get('/user/1'); } diff --git a/tests/Adapters/Psr15AdapterTest.php b/tests/Adapters/Psr15AdapterTest.php index 8c9219c..7e452c3 100644 --- a/tests/Adapters/Psr15AdapterTest.php +++ b/tests/Adapters/Psr15AdapterTest.php @@ -65,6 +65,6 @@ public function callsOnMismatchFirst(): void } catch (OpenApiSchemaMismatchException) { } - $this->assertStringContainsString('The property email is required', (string) $seen); + $this->assertStringContainsString('The required properties (email) are missing', (string) $seen); } } diff --git a/tests/Adapters/SlimAdapterTest.php b/tests/Adapters/SlimAdapterTest.php index 32bdca1..cd57e42 100644 --- a/tests/Adapters/SlimAdapterTest.php +++ b/tests/Adapters/SlimAdapterTest.php @@ -36,7 +36,7 @@ public function passVerificationUsers(): void public function failVerificationUsers(): void { $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/required - The property name is required/'); + $this->expectExceptionMessageMatches('/required - The required properties \(name\) are missing/'); $this->runApp('GET', '/users', valid: false); } @@ -53,7 +53,7 @@ public function passVerificationUser(): void public function failVerificationUser(): void { $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/required - The property email is required/'); + $this->expectExceptionMessageMatches('/required - The required properties \(email\) are missing/'); $this->runApp('GET', '/user/1', valid: false); } @@ -63,7 +63,7 @@ public function verifierRegisteredAfterRouting(): void { // the path is matched against the specification's templates, so the order no longer matters $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/required - The property email is required/'); + $this->expectExceptionMessageMatches('/required - The required properties \(email\) are missing/'); $this->runApp('GET', '/user/1', valid: false, verifierFirst: false); } diff --git a/tests/Fixtures/UserController.php b/tests/Fixtures/UserController.php index 99fd319..3dccdf1 100644 --- a/tests/Fixtures/UserController.php +++ b/tests/Fixtures/UserController.php @@ -31,14 +31,14 @@ class UserController description: 'Users', content: new OAT\JsonContent( allOf: [ - new OAT\Schema(ref: '#components/schemas/paginate'), + new OAT\Schema(ref: '#/components/schemas/paginate'), new OAT\Schema( required: ['data'], properties: [ new OAT\Property( property: 'data', type: 'array', - items: new OAT\Items(ref: '#components/schemas/user') + items: new OAT\Items(ref: '#/components/schemas/user') ), ], ), @@ -47,7 +47,7 @@ class UserController ), new OAT\Response( response: 401, - ref: '#components/responses/401' + ref: '#/components/responses/401' ), ], )] @@ -84,7 +84,7 @@ public function index() ), new OAT\Response( response: 401, - ref: '#components/responses/401' + ref: '#/components/responses/401' ), ], )] diff --git a/tests/OpenApiSchemaMismatchExceptionTest.php b/tests/OpenApiSchemaMismatchExceptionTest.php index 1bc73f4..237ddd5 100644 --- a/tests/OpenApiSchemaMismatchExceptionTest.php +++ b/tests/OpenApiSchemaMismatchExceptionTest.php @@ -6,138 +6,74 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException; +use Radebatz\OpenApi\Verifier\Schema\SchemaError; class OpenApiSchemaMismatchExceptionTest extends TestCase { - private OpenApiSchemaMismatchException $subject; - - protected function setUp(): void - { - $this->subject = new OpenApiSchemaMismatchException(); - } - #[Test] public function getErrorsReturnsSetData(): void { - $value = [[ - 'property' => 'some-property', - 'pointer' => 'some-pointer', - 'message' => 'some-message', - 'constraint' => 'some-constraint', - 'context' => PHP_INT_MAX, - ]]; - - $this->subject->setErrors($value); + $errors = [new SchemaError('/data/0/name', 'type', 'some-message')]; - self::assertSame( - $value, - $this->subject->getErrors(), - ); + $this->assertSame($errors, (new OpenApiSchemaMismatchException())->setErrors($errors)->getErrors()); } - #[Test] - #[DataProvider('errorDataProvider')] - public function getErrorSummaryReturnsExpectedResult( - array $errors, - null|string $expected_result, - ): void { - $this->subject->setErrors($errors); - - self::assertSame( - $expected_result, - $this->subject->getErrorSummary(), - ); - } - - public static function errorDataProvider(): \Generator + public static function errors(): iterable { - // no errors, no message - yield [ - [], - null, + yield 'no errors' => [[], null]; + + yield 'single error' => [ + [new SchemaError('/data/name', 'type', 'some-message')], + << 'some-property', - 'pointer' => 'some-pointer', - 'message' => 'some-message', - 'constraint' => 'some-constraint', - 'context' => PHP_INT_MAX, - ], - ], + yield 'whole document' => [ + [new SchemaError('', 'type', 'some-message')], << 'some-property', - 'pointer' => 'some-pointer', - 'message' => 'some-message', - 'constraint' => ['name' => 'some-constraint', 'params' => []], - 'context' => PHP_INT_MAX, - ], - ], + yield 'same keyword and message, two pointers' => [ + [new SchemaError('/data/b', 'type', 'some-message'), new SchemaError('/data/a', 'type', 'some-message')], << [ [ - [ - 'property' => 'some-property2', - 'pointer' => 'some-pointer2', - 'message' => 'some-message', - 'constraint' => ['name' => 'some-constraint', 'params' => []], - 'context' => PHP_INT_MAX, - ], - [ - 'property' => 'some-property', - 'pointer' => 'some-pointer', - 'message' => 'some-message', - 'constraint' => ['name' => 'some-constraint', 'params' => []], - 'context' => PHP_INT_MAX, - ], + new SchemaError('/data/0/name', 'required', 'some-message'), + new SchemaError('/data/1/name', 'required', 'some-message'), + new SchemaError('/data/12/name', 'required', 'some-message'), ], << 'some-property[2]', - 'pointer' => 'some-pointer2', - 'message' => 'some-message', - 'constraint' => ['name' => 'some-constraint', 'params' => []], - 'context' => abs(PHP_INT_MAX), - ], - [ - 'property' => 'some-property[2]', - 'pointer' => 'some-pointer', - 'message' => 'some-message', - 'constraint' => ['name' => 'some-constraint', 'params' => []], - 'context' => PHP_INT_MAX, - ], - ], + yield 'different messages are separate groups' => [ + [new SchemaError('/a', 'type', 'one'), new SchemaError('/b', 'type', 'two')], <<assertSame($expected, (new OpenApiSchemaMismatchException())->setErrors($errors)->getErrorSummary()); + } } diff --git a/tests/OpenApiSpecificationLoaderTest.php b/tests/OpenApiSpecificationLoaderTest.php index 71a76bc..97606c5 100644 --- a/tests/OpenApiSpecificationLoaderTest.php +++ b/tests/OpenApiSpecificationLoaderTest.php @@ -35,7 +35,7 @@ public function loadSpecification($filename, $valid) $this->assertTrue(is_object($specificationLoader)); } - public static function schemaUrls() + public static function responseSchemas() { return [ 'ucmethod-json-ok' => [__DIR__ . '/specifications/users.json', 'GET', '/users', 200, true], @@ -48,14 +48,19 @@ public static function schemaUrls() } #[Test] - #[DataProvider('schemaUrls')] - public function getSchemaUrlFor($specification, $method, $path, $statusCode, $valid) + #[DataProvider('responseSchemas')] + public function getResponseSchemaFor($specification, $method, $path, $statusCode, $valid) { $specificationLoader = new OpenApiSpecificationLoader($specification); - $schemaUrl = $specificationLoader->getResponseSchemaUrlFor($method, $path, $statusCode); + $schema = $specificationLoader->getResponseSchemaFor($method, $path, $statusCode); - $assert = $valid ? 'assertNotNull' : 'assertNull'; - $this->{$assert}($schemaUrl); + if ($valid) { + $this->assertNotNull($schema); + // carries the components, so its references resolve + $this->assertObjectHasProperty('components', $schema); + } else { + $this->assertNull($schema); + } } } diff --git a/tests/OpenApiVerifierTest.php b/tests/OpenApiVerifierTest.php index 1204249..d9f5761 100644 --- a/tests/OpenApiVerifierTest.php +++ b/tests/OpenApiVerifierTest.php @@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase; use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException; use Radebatz\OpenApi\Verifier\OpenApiVerifier; +use Radebatz\OpenApi\Verifier\Schema\SchemaError; +use Radebatz\OpenApi\Verifier\Schema\SchemaValidator; class OpenApiVerifierTest extends TestCase { @@ -81,6 +83,101 @@ public function verifyResponseBody(string $path, int $statusCode, string $body, $this->assertSame($expected, $this->verifier()->verifyResponseBody('get', $path, $statusCode, $body, $contentType)); } + public static function dialects(): iterable + { + $ref = ['$ref' => '#/components/schemas/thing']; + + // version, schema, body, expected: true verified, null mismatch + yield '3.0 nullable type, null' => ['3.0.0', ['type' => 'string', 'nullable' => true], 'null', true]; + yield '3.0 nullable type, wrong type' => ['3.0.0', ['type' => 'string', 'nullable' => true], '1', null]; + yield '3.0 not nullable, null' => ['3.0.0', ['type' => 'string'], 'null', null]; + yield '3.0 nullable enum, null' => ['3.0.0', ['type' => 'string', 'enum' => ['a', 'b'], 'nullable' => true], 'null', true]; + yield '3.0 nullable enum, not listed' => ['3.0.0', ['type' => 'string', 'enum' => ['a', 'b'], 'nullable' => true], '"c"', null]; + yield '3.0 nullable without type, null' => ['3.0.0', $ref + ['nullable' => true], 'null', true]; + yield '3.0 nullable without type, the schema' => ['3.0.0', $ref + ['nullable' => true], '{"id":1}', true]; + yield '3.0 nullable without type, neither' => ['3.0.0', $ref + ['nullable' => true], '{}', null]; + yield '3.0 swagger-php null branch, null' => ['3.0.0', ['oneOf' => [$ref, ['type' => 'object', 'nullable' => true, 'enum' => [null]]]], 'null', true]; + yield '3.0 swagger-php null branch, the schema' => ['3.0.0', ['oneOf' => [$ref, ['type' => 'object', 'nullable' => true, 'enum' => [null]]]], '{"id":1}', true]; + yield '3.0 exclusiveMinimum true, at the bound' => ['3.0.0', ['type' => 'integer', 'minimum' => 5, 'exclusiveMinimum' => true], '5', null]; + yield '3.0 exclusiveMinimum true, above' => ['3.0.0', ['type' => 'integer', 'minimum' => 5, 'exclusiveMinimum' => true], '6', true]; + yield '3.0 exclusiveMinimum false, at the bound' => ['3.0.0', ['type' => 'integer', 'minimum' => 5, 'exclusiveMinimum' => false], '5', true]; + yield '3.0 exclusiveMaximum true, at the bound' => ['3.0.0', ['type' => 'integer', 'maximum' => 5, 'exclusiveMaximum' => true], '5', null]; + yield '3.0 property named nullable is left alone' => ['3.0.0', ['type' => 'object', 'required' => ['nullable'], 'properties' => ['nullable' => ['type' => 'boolean']]], '{"nullable":true}', true]; + yield '3.0 property named nullable, missing' => ['3.0.0', ['type' => 'object', 'required' => ['nullable'], 'properties' => ['nullable' => ['type' => 'boolean']]], '{}', null]; + yield '3.1 type list, null' => ['3.1.0', ['type' => ['string', 'null']], 'null', true]; + yield '3.1 nullable is not a keyword' => ['3.1.0', ['type' => 'string', 'nullable' => true], 'null', null]; + yield '3.1 numeric exclusiveMinimum' => ['3.1.0', ['type' => 'integer', 'exclusiveMinimum' => 5], '5', null]; + yield '3.1 reference' => ['3.1.0', $ref, '{"id":1}', true]; + } + + #[Test] + #[DataProvider('dialects')] + public function verifyDialect(string $version, array $schema, string $body, ?bool $expected): void + { + $specification = [ + 'openapi' => $version, + 'paths' => ['/thing' => ['get' => ['responses' => ['200' => self::json($schema)]]]], + 'components' => ['schemas' => ['thing' => ['type' => 'object', 'required' => ['id']]]], + ]; + + if (null === $expected) { + $this->expectException(OpenApiSchemaMismatchException::class); + } + + $verifier = OpenApiVerifier::fromSpecification(json_decode(json_encode($specification))); + $this->assertSame($expected, $verifier->verifyResponseBody('get', '/thing', 200, $body)); + } + + #[Test] + public function translationLeavesTheSpecificationAlone(): void + { + $specification = json_decode(json_encode([ + 'openapi' => '3.0.0', + 'paths' => ['/thing' => ['get' => ['responses' => ['200' => self::json(['$ref' => '#/components/schemas/thing'])]]]], + 'components' => ['schemas' => ['thing' => ['type' => 'string', 'nullable' => true]]], + ])); + $before = json_encode($specification); + + $verifier = OpenApiVerifier::fromSpecification($specification); + $this->assertTrue($verifier->verifyResponseBody('get', '/thing', 200, 'null')); + $this->assertTrue($verifier->verifyResponseBody('get', '/thing', 200, 'null')); + + $this->assertSame($before, json_encode($specification)); + } + + #[Test] + public function aValidatorGetsTheSchemaAsWrittenAndTheVersion(): void + { + $validator = new class implements SchemaValidator { + public ?string $version = null; + + public ?\stdClass $schema = null; + + public function validate(mixed $data, \stdClass $schema, string $openApiVersion): array + { + $this->version = $openApiVersion; + $this->schema = $schema; + + return [new SchemaError('', 'custom', 'always fails')]; + } + }; + + $specification = json_decode(json_encode([ + 'openapi' => '3.0.3', + 'paths' => ['/thing' => ['get' => ['responses' => ['200' => self::json(['type' => 'string', 'nullable' => true])]]]], + ])); + + try { + OpenApiVerifier::fromSpecification($specification, $validator)->verifyResponseBody('get', '/thing', 200, '"x"'); + $this->fail('No exception thrown'); + } catch (OpenApiSchemaMismatchException $oasme) { + $this->assertSame('custom', $oasme->getErrors()[0]->keyword); + } + + $this->assertSame('3.0.3', $validator->version); + $this->assertTrue($validator->schema->nullable); + } + #[Test] public function verifyTakesThePsrMessages(): void { diff --git a/tests/specifications/users.json b/tests/specifications/users.json index a2b7ef3..c45241a 100644 --- a/tests/specifications/users.json +++ b/tests/specifications/users.json @@ -39,7 +39,7 @@ "schema": { "allOf": [ { - "$ref": "#components/schemas/paginate" + "$ref": "#/components/schemas/paginate" }, { "required": [ @@ -49,7 +49,7 @@ "data": { "type": "array", "items": { - "$ref": "#components/schemas/user" + "$ref": "#/components/schemas/user" } } }, @@ -61,7 +61,7 @@ } }, "401": { - "$ref": "#components/responses/401" + "$ref": "#/components/responses/401" } }, "security": [ @@ -106,7 +106,7 @@ } }, "401": { - "$ref": "#components/responses/401" + "$ref": "#/components/responses/401" } }, "security": [ diff --git a/tests/specifications/users.yaml b/tests/specifications/users.yaml index 0ddad94..0bc87dc 100644 --- a/tests/specifications/users.yaml +++ b/tests/specifications/users.yaml @@ -31,10 +31,10 @@ paths: application/json: schema: allOf: - - { $ref: '#components/schemas/paginate' } - - { required: [data], properties: { data: { type: array, items: { $ref: '#components/schemas/user' } } }, type: object } + - { $ref: '#/components/schemas/paginate' } + - { required: [data], properties: { data: { type: array, items: { $ref: '#/components/schemas/user' } } }, type: object } '401': - $ref: '#components/responses/401' + $ref: '#/components/responses/401' security: - JWT: [] @@ -62,7 +62,7 @@ paths: data: { $ref: '#/components/schemas/user' } type: object '401': - $ref: '#components/responses/401' + $ref: '#/components/responses/401' security: - JWT: []