diff --git a/composer.json b/composer.json
index fd97455..26eb80c 100644
--- a/composer.json
+++ b/composer.json
@@ -45,7 +45,10 @@
"autoload-dev": {
"psr-4": {
"Cortex\\OpenApi\\Tests\\": "tests/"
- }
+ },
+ "files": [
+ "tests/Expectations.php"
+ ]
},
"scripts": {
"test": "pest --no-coverage",
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index e4fcd01..2d71067 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -5,5 +5,7 @@ parameters:
level: 10
paths:
- src
+ stubFiles:
+ - tests/phpstan/pest-expectations.stub
tmpDir: .phpstan-cache
checkBenevolentUnionTypes: true
diff --git a/phpunit.dist.xml b/phpunit.dist.xml
index 8a4d3b6..0c12bb9 100644
--- a/phpunit.dist.xml
+++ b/phpunit.dist.xml
@@ -9,11 +9,6 @@
./tests
-
-
-
-
-
./src
diff --git a/tests/Expectations.php b/tests/Expectations.php
new file mode 100644
index 0000000..501f0c5
--- /dev/null
+++ b/tests/Expectations.php
@@ -0,0 +1,67 @@
+> $expectedErrors
+ */
+function assertOpenApiValidationErrors(OpenApi $openApi, array $expectedErrors): void
+{
+ try {
+ $openApi->validate();
+ test()->fail('Expected ValidationException');
+ } catch (ValidationException $validationException) {
+ foreach ($expectedErrors as $pointer => $messages) {
+ expect($validationException->errors())->toHaveKey($pointer);
+ foreach ($messages as $message) {
+ expect($validationException->errors()[$pointer])->toContain($message);
+ }
+ }
+ }
+}
+
+expect()->extend('toFailOpenApiValidation', function (array $expectedErrors): mixed {
+ if (! $this->value instanceof OpenApi) {
+ test()->fail('Expected an OpenApi instance.');
+ }
+
+ assertOpenApiValidationErrors($this->value, $expectedErrors);
+
+ return $this;
+});
+
+expect()->extend('toFailOpenApiValidationAt', function (string $pointer, string $message): mixed {
+ if (! $this->value instanceof OpenApi) {
+ test()->fail('Expected an OpenApi instance.');
+ }
+
+ assertOpenApiValidationErrors($this->value, [
+ $pointer => [$message],
+ ]);
+
+ return $this;
+});
+
+expect()->extend('toPassOpenApiValidation', function (): mixed {
+ if (! $this->value instanceof OpenApi) {
+ test()->fail('Expected an OpenApi instance.');
+ }
+
+ try {
+ $this->value->validate();
+ } catch (ValidationException $validationException) {
+ test()->fail(
+ 'Expected OpenAPI document to pass validation: ' . $validationException->getMessage(),
+ );
+ }
+
+ expect($this->value)->toBeInstanceOf(OpenApi::class);
+
+ return $this;
+});
diff --git a/tests/Unit/Validation/ComponentsTest.php b/tests/Unit/Validation/ComponentsTest.php
new file mode 100644
index 0000000..9ec5736
--- /dev/null
+++ b/tests/Unit/Validation/ComponentsTest.php
@@ -0,0 +1,23 @@
+info(Info::create('x', '1.0.0'))
+ ->components(
+ Components::create()->schema('Invalid Key!', Schema::object()),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/components/schemas',
+ 'The string should match pattern: ^[a-zA-Z0-9._-]+$',
+ );
+});
diff --git a/tests/Unit/Validation/DocumentTest.php b/tests/Unit/Validation/DocumentTest.php
new file mode 100644
index 0000000..bd92979
--- /dev/null
+++ b/tests/Unit/Validation/DocumentTest.php
@@ -0,0 +1,98 @@
+info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()->responses(Response::ok()),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('accepts a minimal valid 3.1.1 document', function (): void {
+ $openApi = OpenApi::create(OpenApiVersion::V3_1_1)
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()->responses(Response::ok()),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('accepts a document with components, tags, and schemas', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('Example', '1.0.0'))
+ ->tags(Tag::create('Users'))
+ ->components(Components::create()->schema('User', Schema::object()->properties(Schema::string('id'))))
+ ->paths(
+ PathItem::create('/users')->operations(
+ Operation::get()->tags('Users')->responses(
+ Response::ok()->content(MediaType::json(Schema::string())),
+ ),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects a document missing info', function (): void {
+ $openApi = OpenApi::create();
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/',
+ 'The required properties (info) are missing',
+ );
+});
+
+it('accepts a components-only document without paths or webhooks', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->components(Components::create()->schema('User', Schema::object()));
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('accepts a webhooks-only document without paths or components', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->webhooks([
+ 'user.created' => PathItem::create('/user.created')->operations(
+ Operation::post()->responses(Response::ok()),
+ ),
+ ]);
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects a document with info but no paths, components, or webhooks', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'));
+
+ expect($openApi)->toFailOpenApiValidation([
+ '/' => [
+ 'The required properties (paths) are missing',
+ 'The required properties (components) are missing',
+ 'The required properties (webhooks) are missing',
+ ],
+ ]);
+});
diff --git a/tests/Unit/Validation/ExampleTest.php b/tests/Unit/Validation/ExampleTest.php
new file mode 100644
index 0000000..98b4294
--- /dev/null
+++ b/tests/Unit/Validation/ExampleTest.php
@@ -0,0 +1,28 @@
+info(Info::create('x', '1.0.0'))
+ ->components(
+ Components::create()
+ ->example('Bad', Example::create()
+ ->value([
+ 'id' => 1,
+ ])
+ ->externalValue('https://example.com/examples/user.json')),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/components/examples/Bad',
+ 'The data must not match schema',
+ );
+});
diff --git a/tests/Unit/Validation/LicenseTest.php b/tests/Unit/Validation/LicenseTest.php
new file mode 100644
index 0000000..9ea419f
--- /dev/null
+++ b/tests/Unit/Validation/LicenseTest.php
@@ -0,0 +1,27 @@
+info(
+ Info::create('x', '1.0.0')
+ ->license(License::create('MIT')
+ ->identifier('MIT')
+ ->url('https://spdx.org/licenses/MIT.html')),
+ )
+ ->components(Components::create()->schema('Empty', Schema::object()));
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/info/license',
+ 'The data must not match schema',
+ );
+});
diff --git a/tests/Unit/Validation/ParameterTest.php b/tests/Unit/Validation/ParameterTest.php
new file mode 100644
index 0000000..3e02bb6
--- /dev/null
+++ b/tests/Unit/Validation/ParameterTest.php
@@ -0,0 +1,68 @@
+info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/items/{id}')->operations(
+ Operation::get()
+ ->parameters(
+ Parameter::path('id', Schema::string()),
+ Parameter::query('filter', Schema::string()),
+ Parameter::header('X-Request-Id', Schema::string()),
+ Parameter::cookie('session', Schema::string()),
+ )
+ ->responses(Response::ok()),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects a parameter with neither schema nor content', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()
+ ->parameters(Parameter::query('q'))
+ ->responses(Response::ok()),
+ ),
+ );
+
+ expect($openApi)->toFailOpenApiValidation([
+ '/paths/~1ping/get/parameters/0' => [
+ 'The required properties (schema) are missing',
+ 'The required properties (content) are missing',
+ ],
+ ]);
+});
+
+it('rejects a path parameter where required is overridden to false', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/items/{id}')->operations(
+ Operation::get()
+ ->parameters(Parameter::path('id', Schema::string())->required(false))
+ ->responses(Response::ok()),
+ ),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/paths/~1items~1%7Bid%7D/get/parameters/0/required',
+ 'The data must match the const value',
+ );
+});
diff --git a/tests/Unit/Validation/PathsTest.php b/tests/Unit/Validation/PathsTest.php
new file mode 100644
index 0000000..4e97f76
--- /dev/null
+++ b/tests/Unit/Validation/PathsTest.php
@@ -0,0 +1,24 @@
+info(Info::create('x', '1.0.0'))
+ ->path('noslash', PathItem::create('noslash')->operations(
+ Operation::get()->responses(Response::ok()),
+ ));
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/paths',
+ 'Unevaluated object properties not allowed: noslash',
+ );
+});
diff --git a/tests/Unit/Validation/RequestBodyTest.php b/tests/Unit/Validation/RequestBodyTest.php
new file mode 100644
index 0000000..2f378ee
--- /dev/null
+++ b/tests/Unit/Validation/RequestBodyTest.php
@@ -0,0 +1,43 @@
+info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/items')->operations(
+ Operation::post()
+ ->requestBody(RequestBody::create()->json(Schema::object()))
+ ->responses(Response::created()),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects a requestBody with no content', function (): void {
+ // An inline requestBody that serialises to {} is dropped by buildArray in Operation,
+ // so we register it in components.requestBodies where the entry is always preserved.
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->components(
+ Components::create()->requestBody('Body', RequestBody::create()),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/components/requestBodies/Body',
+ 'The data (array) must match the type: object',
+ );
+});
diff --git a/tests/Unit/Validation/ResponseTest.php b/tests/Unit/Validation/ResponseTest.php
new file mode 100644
index 0000000..f139f3b
--- /dev/null
+++ b/tests/Unit/Validation/ResponseTest.php
@@ -0,0 +1,95 @@
+info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()->responses(Response::status(99)),
+ ),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/paths/~1ping/get/responses',
+ 'Unevaluated object properties not allowed: 99',
+ );
+});
+
+it('accepts wildcard and default response keys', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()->responses(
+ Response::ok(),
+ Response::default()->description('Unexpected error'),
+ Response::status('4XX')->description('Client error'),
+ ),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects a response object missing its description', function (): void {
+ // 503 has no default description, so the response serialises to {} which fails required:[description]
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()->responses(Response::status(503)),
+ ),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/paths/~1ping/get/responses/503',
+ 'The data (array) must match the type: object',
+ );
+});
+
+it('accepts response links and response headers', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/users/{id}')->operations(
+ Operation::get()->responses(
+ Response::ok()
+ ->header('X-Rate-Limit', Header::create()->schema(Schema::integer()))
+ ->link('GetUser', Link::create()->operationId('getUser')),
+ ),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects a link with neither operationRef nor operationId', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->paths(
+ PathItem::create('/ping')->operations(
+ Operation::get()->responses(
+ Response::ok()->link('Empty', Link::create()),
+ ),
+ ),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/paths/~1ping/get/responses/200/links/Empty',
+ 'The data (array) must match the type: object',
+ );
+});
diff --git a/tests/Unit/Validation/SecuritySchemeTest.php b/tests/Unit/Validation/SecuritySchemeTest.php
new file mode 100644
index 0000000..3a89298
--- /dev/null
+++ b/tests/Unit/Validation/SecuritySchemeTest.php
@@ -0,0 +1,54 @@
+info(Info::create('x', '1.0.0'))
+ ->components(
+ Components::create()
+ ->securityScheme('apiKey', SecurityScheme::apiKey('X-API-Key', In::Header))
+ ->securityScheme('bearer', SecurityScheme::http('bearer'))
+ ->securityScheme('mutualTls', SecurityScheme::mutualTls())
+ ->securityScheme('oauth2', SecurityScheme::oauth2(
+ OAuthFlows::create()->authorizationCode(
+ OAuthFlow::create()
+ ->authorizationUrl('https://example.com/oauth/authorize')
+ ->tokenUrl('https://example.com/oauth/token')
+ ->scopes([
+ 'read' => 'Read access',
+ ]),
+ ),
+ ))
+ ->securityScheme(
+ 'oidc',
+ SecurityScheme::openIdConnect('https://example.com/.well-known/openid-configuration'),
+ ),
+ );
+
+ expect($openApi)->toPassOpenApiValidation();
+});
+
+it('rejects an oauth2 security scheme without flows', function (): void {
+ $openApi = OpenApi::create()
+ ->info(Info::create('x', '1.0.0'))
+ ->components(
+ Components::create()
+ ->securityScheme('oauth2', SecurityScheme::oauth2()),
+ );
+
+ expect($openApi)->toFailOpenApiValidationAt(
+ '/components/securitySchemes/oauth2',
+ 'The required properties (flows) are missing',
+ );
+});
diff --git a/tests/Unit/Validation/ValidationExceptionTest.php b/tests/Unit/Validation/ValidationExceptionTest.php
new file mode 100644
index 0000000..2c1beed
--- /dev/null
+++ b/tests/Unit/Validation/ValidationExceptionTest.php
@@ -0,0 +1,56 @@
+validate();
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $validationException) {
+ expect($validationException->getMessage())->toContain('OpenAPI document failed meta-schema validation:');
+ }
+});
+
+it('validation exception message contains json-encoded errors', function (): void {
+ $openApi = OpenApi::create();
+
+ try {
+ $openApi->validate();
+ } catch (ValidationException $validationException) {
+ // The message should contain both the prefix and JSON-encoded error details
+ expect($validationException->getMessage())->toStartWith('OpenAPI document failed meta-schema validation: ');
+ $jsonPart = substr(
+ $validationException->getMessage(),
+ strlen('OpenAPI document failed meta-schema validation: '),
+ );
+ expect(json_decode($jsonPart, true))->not->toBeNull();
+ }
+});
+
+it('validation exception carries a structured errors array', function (): void {
+ $openApi = OpenApi::create(); // missing info — fails validation
+
+ try {
+ $openApi->validate();
+ expect(true)->toBeFalse('Expected ValidationException');
+ } catch (ValidationException $validationException) {
+ $errors = $validationException->errors();
+ expect($errors)->toBeArray();
+ expect($errors)->not->toBeEmpty();
+ expect($errors)->toHaveKey('/');
+ expect($errors['/'])->toBeArray();
+ expect($errors['/'][0])->toBe('The required properties (info) are missing');
+ }
+});
+
+it('ValidationException constructed without errors returns empty array from errors()', function (): void {
+ $e = new ValidationException('Something went wrong');
+ expect($e->errors())->toBe([]);
+});
diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php
deleted file mode 100644
index e497554..0000000
--- a/tests/Unit/ValidationTest.php
+++ /dev/null
@@ -1,120 +0,0 @@
-info(Info::create('x', '1.0.0'))
- ->paths(
- PathItem::create('/ping')->operations(
- Operation::get()->responses(Response::ok()),
- ),
- );
-
- $openApi->validate();
-})->throwsNoExceptions();
-
-it('accepts a minimal valid 3.1.1 document', function (): void {
- $openApi = OpenApi::create(OpenApiVersion::V3_1_1)
- ->info(Info::create('x', '1.0.0'))
- ->paths(
- PathItem::create('/ping')->operations(
- Operation::get()->responses(Response::ok()),
- ),
- );
-
- $openApi->validate();
-})->throwsNoExceptions();
-
-it('accepts a document with components, tags, and schemas', function (): void {
- $openApi = OpenApi::create()
- ->info(Info::create('Example', '1.0.0'))
- ->tags(Tag::create('Users'))
- ->components(Components::create()->schema('User', Schema::object()->properties(Schema::string('id'))))
- ->paths(
- PathItem::create('/users')->operations(
- Operation::get()->tags('Users')->responses(
- Response::ok()->content(MediaType::json(Schema::string())),
- ),
- ),
- );
-
- $openApi->validate();
-})->throwsNoExceptions();
-
-it('rejects a document missing info', function (): void {
- $openApi = OpenApi::create();
-
- expect(fn(): mixed => $openApi->validate())->toThrow(ValidationException::class);
-});
-
-it('rejects a document with a bad response status key', function (): void {
- // 99 is not a valid HTTP status code, nor 'default'
- $openApi = OpenApi::create()
- ->info(Info::create('x', '1.0.0'))
- ->paths(
- PathItem::create('/ping')->operations(
- Operation::get()->responses(Response::status(99)),
- ),
- );
-
- expect(fn(): mixed => $openApi->validate())->toThrow(ValidationException::class);
-});
-
-it('validation exception message contains schema validation prefix', function (): void {
- $openApi = OpenApi::create();
-
- try {
- $openApi->validate();
- $this->fail('Expected ValidationException');
- } catch (ValidationException $validationException) {
- expect($validationException->getMessage())->toContain('OpenAPI document failed meta-schema validation:');
- }
-});
-
-it('validation exception message contains json-encoded errors', function (): void {
- $openApi = OpenApi::create();
-
- try {
- $openApi->validate();
- } catch (ValidationException $validationException) {
- // The message should contain both the prefix and JSON-encoded error details
- expect($validationException->getMessage())->toStartWith('OpenAPI document failed meta-schema validation: ');
- $jsonPart = substr(
- $validationException->getMessage(),
- strlen('OpenAPI document failed meta-schema validation: '),
- );
- expect(json_decode($jsonPart, true))->not->toBeNull();
- }
-});
-
-it('validation exception carries a structured errors array', function (): void {
- $openApi = OpenApi::create(); // missing info — fails validation
-
- try {
- $openApi->validate();
- expect(true)->toBeFalse('Expected ValidationException');
- } catch (ValidationException $validationException) {
- expect($validationException->errors())->toBeArray();
- expect($validationException->errors())->not->toBeEmpty();
- }
-});
-
-it('ValidationException constructed without errors returns empty array from errors()', function (): void {
- $e = new ValidationException('Something went wrong');
- expect($e->errors())->toBe([]);
-});
diff --git a/tests/phpstan/pest-expectations.stub b/tests/phpstan/pest-expectations.stub
new file mode 100644
index 0000000..cf8d9aa
--- /dev/null
+++ b/tests/phpstan/pest-expectations.stub
@@ -0,0 +1,18 @@
+ toFailOpenApiValidation(array> $expectedErrors)
+ * @method self toFailOpenApiValidationAt(string $pointer, string $message)
+ * @method self toPassOpenApiValidation()
+ */
+class Expectation
+{
+}