diff --git a/README.md b/README.md index d1651e4..ec493ca 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,35 @@ [![GitHub release](https://img.shields.io/github/release/byjg/php-swagger-test.svg)](https://github.com/byjg/php-swagger-test/releases/) A set of tools for testing your REST calls based on the OpenApi specification using PHPUnit. -Currently, this library supports the OpenApi specifications `2.0` (formerly swagger) and `3.0`. +Currently, this library supports the OpenApi specifications `2.0` (formerly swagger), `3.0.x`, and **`3.1.x`**. + +## OpenAPI Version Support + +- **Swagger 2.0** - Full support +- **OpenAPI 3.0.x** - Full support (3.0.0, 3.0.1, 3.0.2, 3.0.3) +- **OpenAPI 3.1.x** - Full support with JSON Schema 2020-12 features ✨ + +### OpenAPI 3.1 Features + +OpenAPI 3.1 brings full JSON Schema 2020-12 compatibility. Key features supported: + +- **Nullable Types**: Use JSON Schema union types `["string", "null"]` instead of the deprecated `nullable` keyword +- **Webhooks**: Describe and validate incoming HTTP requests your API will receive +- **`const` Keyword**: Validate constant values +- **Conditional Schemas**: Use `if/then/else` for conditional validation +- **Tuple Validation**: Validate arrays with specific types at specific positions using `prefixItems` +- **`$ref` with Sibling Keywords**: References can have additional properties alongside them + +For detailed documentation on OpenAPI 3.1 features, see [OpenAPI 3.1 Features Guide](docs/openapi-3.1-features.md). + +### Limitations Some features of the OpenAPI specification are not fully implemented: -- Callbacks (OpenAPI 3.0) -- Links (OpenAPI 3.0) +- Callbacks (OpenAPI 3.0/3.1) +- Links (OpenAPI 3.0/3.1) - References to external documents/objects -- Complex schema validations +- Some advanced JSON Schema 2020-12 keywords (e.g., `unevaluatedProperties`, `dependentSchemas`) For details on the schema classes and their specific features, see [Schema Classes](docs/schema-classes.md). @@ -34,12 +55,14 @@ The ApiTestCase's assertion process is based on throwing exceptions if some vali - [Contract test cases](docs/contract-tests.md) - Testing without HTTP using custom requesters - [Runtime parameters validator](docs/runtime-parameters-validator.md) - Validating requests in production - [Mocking Requests](docs/mock-requests.md) - Testing with mocked responses -- [Schema classes](docs/schema-classes.md) - Working with OpenAPI 2.0 and 3.0 schemas +- [Schema classes](docs/schema-classes.md) - Working with OpenAPI 2.0, 3.0, and 3.1 schemas +- [OpenAPI 3.1 features](docs/openapi-3.1-features.md) - Webhooks, const, if/then/else, tuple validation, and more - [Using the OpenApiValidation trait](docs/trait-usage.md) - Flexible validation without extending ApiTestCase - [Advanced usage](docs/advanced-usage.md) - File uploads, custom clients, authentication, and more - [Exception handling](docs/exceptions.md) - Understanding and handling validation exceptions - [Migration guide](docs/migration-guide.md) - Upgrading from older versions - [Troubleshooting](docs/troubleshooting.md) - Common issues and solutions +- [Version comparison](docs/version-comparison.md) - Feature support matrix across OpenAPI versions ## Who is using this library? diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 29bf47c..c984a37 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 --- # Advanced Usage diff --git a/docs/exceptions.md b/docs/exceptions.md index 1a2d84e..0f22bce 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 --- # Exception Handling @@ -543,17 +543,17 @@ class ApiController ## Exception Quick Reference -| Exception | When Thrown | HTTP Status Hint | -|-----------|-------------|------------------| -| `PathNotFoundException` | Path not in spec | 404 (likely) | -| `HttpMethodNotFoundException` | Method not allowed for path | 405 (likely) | -| `DefinitionNotFoundException` | Schema reference not found | 500 (spec error) | -| `InvalidDefinitionException` | Spec structure invalid | 500 (spec error) | -| `NotMatchedException` | Data doesn't match schema | 400 (validation) | -| `RequiredArgumentNotFound` | Missing required field | 400 (validation) | -| `StatusCodeNotMatchedException` | Wrong status code | N/A | -| `InvalidRequestException` | Malformed request | 400 (client error) | -| `GenericApiException` | General API error | Varies | +| Exception | When Thrown | HTTP Status Hint | +|---------------------------------|-----------------------------|--------------------| +| `PathNotFoundException` | Path not in spec | 404 (likely) | +| `HttpMethodNotFoundException` | Method not allowed for path | 405 (likely) | +| `DefinitionNotFoundException` | Schema reference not found | 500 (spec error) | +| `InvalidDefinitionException` | Spec structure invalid | 500 (spec error) | +| `NotMatchedException` | Data doesn't match schema | 400 (validation) | +| `RequiredArgumentNotFound` | Missing required field | 400 (validation) | +| `StatusCodeNotMatchedException` | Wrong status code | N/A | +| `InvalidRequestException` | Malformed request | 400 (client error) | +| `GenericApiException` | General API error | Varies | --- diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 7dc8a7b..92c8b23 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -1,17 +1,114 @@ --- -sidebar_position: 9 +sidebar_position: 10 --- # Migration Guide +## OpenAPI 3.1 Support (Added in 6.1) + +:::info Non-Breaking Addition +Version 6.1 adds full support for OpenAPI 3.1 with JSON Schema 2020-12 compatibility. All existing code continues to +work without changes. +::: + +### What's New in OpenAPI 3.1 + +- **Automatic Version Detection**: The library now automatically detects and handles OpenAPI 3.1 schemas +- **Type Array Nullable**: Support for JSON Schema union types like `["string", "null"]` +- **Webhooks**: Test incoming HTTP requests your API will receive +- **Modern JSON Schema Keywords**: `const`, `if/then/else`, `prefixItems` for tuples +- **Enhanced $ref**: References can have sibling keywords + +### Migration from OpenAPI 3.0 to 3.1 + +:::tip No Code Changes Required +The library automatically detects the version - your existing code works with both 3.0 and 3.1 schemas! +::: + +```php title="Example: Automatic version detection" +// This works for both 3.0 and 3.1 +$schema = Schema::fromFile('/path/to/openapi.json'); + +// Check which version you're using +echo $schema->getSpecificationVersion(); // "3.0" or "3.1" +``` + +### Updating Your OpenAPI Schemas + +If you want to upgrade your OpenAPI schemas from 3.0 to 3.1: + +**1. Update the version number:** + +```json title="Update OpenAPI version" +{ + "openapi": "3.1.0" +} +``` + +**2. Replace `nullable` with type arrays (optional but recommended):** + +**Before (OpenAPI 3.0):** + +```json +{ + "type": "string", + "nullable": true +} +``` + +**After (OpenAPI 3.1):** + +```json +{ + "type": [ + "string", + "null" + ] +} +``` + +**3. Use webhooks for incoming requests (new feature):** + +```json title="Example: Webhook definition" +{ + "openapi": "3.1.0", + "webhooks": { + "newUser": { + "post": { + "requestBody": {}, + "responses": {} + } + } + } +} +``` + +For detailed information on OpenAPI 3.1 features, see [OpenAPI 3.1 Features Guide](openapi-3.1-features.md). + +### Backward Compatibility + +:::success 100% Backward Compatible +- ✅ All OpenAPI 3.0 and Swagger 2.0 schemas work without changes +- ✅ All existing test code continues to work +- ✅ No breaking changes +- ✅ Mixed 3.0 and 3.1 schemas can be used in the same project + +::: + +--- + ## Migrating from Schema::getInstance() (Deprecated in 6.0) +:::warning Deprecated The `Schema::getInstance()` method has been deprecated in version 6.0 and will be removed in version 7.0. +::: ### Why the Change? +:::info Reason for Deprecation The method name `getInstance()` suggests a singleton pattern, but it actually creates new instances each time (factory pattern). This is confusing for developers. +::: ### New Factory Methods @@ -27,7 +124,7 @@ $schema = Schema::getInstance(file_get_contents('/path/to/spec.json')); $schema = Schema::getInstance($arrayData); ``` -**New Way:** +**New Way (Recommended):** ```php // From file (recommended - simplest) @@ -48,25 +145,32 @@ $schema = Schema::fromArray($arrayData, allowNullValues: true); ### Benefits +:::tip Why Use New Methods 1. **Clearer intent**: Method name matches what it does (factory, not singleton) 2. **Better error messages**: Each method validates its specific input type 3. **More convenient**: `fromFile()` handles file reading for you 4. **Consistent naming**: Follows common factory method patterns +::: + --- ## Migrating from assertRequest() (Deprecated in 6.0) +:::warning Deprecated The `assertRequest()` method has been renamed to `sendRequest()` for clarity. +::: ### Why the Change? +:::info Reason for Deprecation The method name `assertRequest()` is misleading because: - - It returns a value (assertions typically don't return) - The actual validation happens inside via exceptions - Developers expect assertion methods to be void +::: + ### Migration **Old Way (Deprecated):** @@ -87,8 +191,10 @@ That's it! The functionality is identical, just the name is clearer. ## Migrating to expect* Methods (Version 6.0) +:::info Renamed for Clarity The assertion-style methods (`assertStatus()`, `assertResponseCode()`, `assertBodyContains()`, etc.) have been renamed to expectation-style methods in version 6.0 for better semantic clarity. +::: ### Why the Change? @@ -140,21 +246,23 @@ $request ## Migrating from makeRequest() (Deprecated in 6.0) +:::warning Deprecated The `makeRequest()` method with 6 parameters has been deprecated in version 6.0 and will be removed in version 7.0. +::: ### Why the Change? -The old `makeRequest()` method had several issues: +**Old `makeRequest()` Issues:** -- Required passing 6 parameters (even empty ones) making it verbose and error-prone -- Parameters had to be in a specific order -- Not easily extensible for new features +- Required passing 6 parameters (even empty ones) +- Parameters in specific order +- Not easily extensible - Less readable code -The new fluent interface with `ApiRequester` provides: +**New `ApiRequester` Benefits:** -- More readable and self-documenting code -- Only specify the parameters you need +- More readable and self-documenting +- Only specify parameters you need - Easy to extend with new features - Better IDE autocomplete support @@ -187,7 +295,7 @@ public function testGetPet() $request ->withMethod('GET') ->withPath('/pet/1'); - + $this->sendRequest($request); } ``` @@ -221,7 +329,7 @@ public function testCreatePet() ->withPath('/pet') ->withRequestBody(['name' => 'Fluffy', 'status' => 'available']) ->expectStatus(201); - + $this->sendRequest($request); } ``` @@ -254,7 +362,7 @@ public function testFindPets() ->withMethod('GET') ->withPath('/pet/findByStatus') ->withQuery(['status' => 'available']); - + $this->sendRequest($request); } ``` @@ -287,7 +395,7 @@ public function testAuthenticatedRequest() ->withMethod('GET') ->withPath('/pet/1') ->withRequestHeader(['Authorization' => 'Bearer token123']); - + $this->sendRequest($request); } ``` @@ -322,8 +430,8 @@ public function testComplexRequest() ->withQuery(['detailed' => 'true']) ->withRequestBody(['name' => 'Updated Name']) ->withRequestHeader(['Authorization' => 'Bearer token123']) - ->assertResponseCode(200); - + ->expectStatus(200); + $response = $this->sendRequest($request); } ``` @@ -332,25 +440,29 @@ public function testComplexRequest() #### 1. Better Assertions +:::tip Multiple Expectations You can add multiple assertions to your request: +::: -```php +```php title="Example: Multiple expectations" $request = new \ByJG\ApiTools\ApiRequester(); $request ->withMethod('GET') ->withPath('/pet/1') - ->assertResponseCode(200) - ->assertHeaderContains('Content-Type', 'application/json') - ->assertBodyContains('Fluffy'); + ->expectStatus(200) + ->expectHeaderContains('Content-Type', 'application/json') + ->expectBodyContains('Fluffy'); $this->sendRequest($request); ``` #### 2. Reusable Request Builders +:::tip Helper Methods You can create helper methods that return configured requesters: +::: -```php +```php title="Example: Reusable authenticated request" protected function createAuthenticatedRequest(string $method, string $path): \ByJG\ApiTools\ApiRequester { $request = new \ByJG\ApiTools\ApiRequester(); @@ -369,9 +481,11 @@ public function testWithHelper() #### 3. Response Inspection +:::tip Response Analysis Both methods return the response, allowing you to inspect it further: +::: -```php +```php title="Example: Inspecting response data" $request = new \ByJG\ApiTools\ApiRequester(); $request ->withMethod('GET') @@ -386,16 +500,22 @@ $this->assertEquals('Fluffy', $body['name']); ### Timeline +:::caution Deprecation Timeline - **Version 6.0**: - `Schema::getInstance()` deprecated (use `fromJson()`, `fromArray()`, or `fromFile()`) - `assertRequest()` deprecated (use `sendRequest()`) - `makeRequest()` deprecated (use `ApiRequester` fluent interface) - **Version 7.0**: All deprecated methods will be removed +::: + ### Need Help? -If you encounter issues during migration, please: +:::info Getting Support +If you encounter issues during migration: 1. Check the [Troubleshooting Guide](troubleshooting.md) 2. Review the [API Reference](functional-tests.md) 3. Open an issue on [GitHub](https://github.com/byjg/php-swagger-test/issues) + +::: diff --git a/docs/openapi-3.1-features.md b/docs/openapi-3.1-features.md new file mode 100644 index 0000000..ffbb683 --- /dev/null +++ b/docs/openapi-3.1-features.md @@ -0,0 +1,461 @@ +--- +sidebar_position: 6 +--- + +# OpenAPI 3.1 Features + +This document describes the OpenAPI 3.1 specific features supported by php-swagger-test. + +## Table of Contents + +- [Version Detection](#version-detection) +- [Nullable Types](#nullable-types) +- [Webhooks](#webhooks) +- [JSON Schema 2020-12 Features](#json-schema-2020-12-features) +- [Migration from 3.0 to 3.1](#migration-from-30-to-31) + +## Version Detection + +The library automatically detects OpenAPI 3.1 schemas based on the `openapi` field: + +```php +use ByJG\ApiTools\Base\Schema; + +// OpenAPI 3.0 - returns OpenApiSchema +$schema30 = Schema::fromJson('{"openapi": "3.0.3", ...}'); + +// OpenAPI 3.1 - returns OpenApi31Schema +$schema31 = Schema::fromJson('{"openapi": "3.1.0", ...}'); + +// Check version +echo $schema31->getSpecificationVersion(); // "3.1" +``` + +## Nullable Types + +### OpenAPI 3.0 Approach + +OpenAPI 3.0 uses the `nullable` keyword: + +```json +{ + "type": "string", + "nullable": true +} +``` + +### OpenAPI 3.1 Approach + +OpenAPI 3.1 uses JSON Schema union types: + +```json +{ + "type": [ + "string", + "null" + ] +} +``` + +### Testing Nullable Fields + +```php +$schema = Schema::fromFile('openapi31.json'); +$responseBody = $schema->getResponseParameters('/users', 'get', 200); + +// Both null and string values are valid +$responseBody->match(['email' => null]); // Valid +$responseBody->match(['email' => 'test@example.com']); // Valid +``` + +### Multiple Nullable Types + +You can have multiple types including null: + +```json +{ + "type": [ + "string", + "number", + "null" + ] +} +``` + +### Nullable Objects with Required Fields + +OpenAPI 3.1 supports nullable objects that have required fields and nested properties using `$ref`: + +```json +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "manager": { + "type": ["object", "null"], + "required": ["phone"], + "properties": { + "email": { + "$ref": "#/components/schemas/emailProperty" + }, + "phone": { + "$ref": "#/components/schemas/phoneNumberProperty" + }, + "firstName": { + "$ref": "#/components/schemas/firstNameProperty" + } + } + } + }, + "required": ["name"] +} +``` + +When validating nullable objects with required fields: + +```php +// Valid: manager is null +$requestBody->match([ + 'name' => 'ACME Corp', + 'manager' => null +]); + +// Valid: manager is omitted (not required) +$requestBody->match([ + 'name' => 'ACME Corp' +]); + +// Valid: manager has required phone field +$requestBody->match([ + 'name' => 'ACME Corp', + 'manager' => [ + 'phone' => '+1234567890' + ] +]); + +// Invalid: manager is present but missing required phone +$requestBody->match([ + 'name' => 'ACME Corp', + 'manager' => [ + 'email' => 'test@example.com' + ] +]); // Throws NotMatchedException +``` + +This feature is particularly useful when modeling optional complex objects that, when present, must satisfy specific +requirements. + +## Webhooks + +Webhooks allow you to describe incoming HTTP requests that your API will receive. + +### Schema Definition + +```json +{ + "openapi": "3.1.0", + "webhooks": { + "newUser": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "integer" + }, + "event": { + "type": "string" + } + } + } + } + } + } + } + } + } +} +``` + +### Testing Webhooks + +```php +$schema = Schema::fromFile('openapi31.json'); + +// Check if webhooks exist +if ($schema->hasWebhooks()) { + // Get all webhook names + $webhooks = $schema->getWebhookNames(); // ['newUser', 'orderUpdated'] + + // Validate webhook request + $requestBody = $schema->getWebhookRequestParameters('newUser', 'post'); + $requestBody->match([ + 'userId' => 123, + 'event' => 'user.created' + ]); + + // Validate webhook response + $responseBody = $schema->getWebhookResponseParameters('newUser', 'post', 200); + $responseBody->match($responseData); +} +``` + +## JSON Schema 2020-12 Features + +### const Keyword + +Validate that a value is exactly a constant: + +```json +{ + "type": "object", + "properties": { + "status": { + "const": "active" + } + } +} +``` + +```php +$requestBody->match(['status' => 'active']); // Valid +$requestBody->match(['status' => 'inactive']); // Throws NotMatchedException +``` + +### Conditional Schemas (if/then/else) + +Apply different validation rules based on conditions: + +```json +{ + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "if": { + "properties": { + "country": { + "const": "US" + } + } + }, + "then": { + "properties": { + "postalCode": { + "pattern": "^[0-9]{5}(-[0-9]{4})?$" + } + } + }, + "else": { + "properties": { + "postalCode": { + "pattern": "^[A-Z0-9 -]+$" + } + } + } +} +``` + +```php +// US postal code +$requestBody->match([ + 'country' => 'US', + 'postalCode' => '12345' +]); // Valid + +// Non-US postal code +$requestBody->match([ + 'country' => 'CA', + 'postalCode' => 'K1A 0B1' +]); // Valid +``` + +### Tuple Validation (prefixItems) + +Validate arrays with specific types at specific positions: + +```json +{ + "type": "array", + "prefixItems": [ + { + "type": "number", + "description": "latitude" + }, + { + "type": "number", + "description": "longitude" + } + ], + "minItems": 2, + "maxItems": 2 +} +``` + +```php +$requestBody->match([40.7128, -74.0060]); // Valid (lat, lng) +$requestBody->match([40.7128]); // Invalid - too few items +$requestBody->match(['40.7128', -74.0060]); // Invalid - first item is string +``` + +### $ref with Sibling Keywords + +In OpenAPI 3.1, you can have sibling keywords alongside `$ref`: + +```json +{ + "$ref": "#/components/schemas/User", + "description": "The authenticated user", + "example": { + "id": 1, + "name": "John" + } +} +``` + +In OpenAPI 3.0, you would need to wrap this in `allOf`. + +## Migration from 3.0 to 3.1 + +### 1. Update openapi Version + +```json +{ + "openapi": "3.1.0", + // Changed from "3.0.3" + ... +} +``` + +### 2. Replace nullable with Type Arrays + +Before (3.0): + +```json +{ + "type": "string", + "nullable": true +} +``` + +After (3.1): + +```json +{ + "type": [ + "string", + "null" + ] +} +``` + +### 3. Simplify $ref Usage + +Before (3.0): + +```json +{ + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "description": "Additional info" + } + ] +} +``` + +After (3.1): + +```json +{ + "$ref": "#/components/schemas/User", + "description": "Additional info" +} +``` + +### 4. Optional Server Variable Defaults + +In 3.1, server variable defaults are optional: + +Before (3.0 - required): + +```json +{ + "servers": [ + { + "url": "http://{host}", + "variables": { + "host": { + "default": "localhost" + // Required + } + } + } + ] +} +``` + +After (3.1 - optional): + +```json +{ + "servers": [ + { + "url": "http://{host}", + "variables": { + "host": { + "enum": [ + "localhost", + "example.com" + ] + // default is optional + } + } + } + ] +} +``` + +### 5. Use Modern JSON Schema Keywords + +Take advantage of new keywords: + +- Use `const` instead of single-value `enum` +- Use `prefixItems` for tuple validation +- Use `if/then/else` for conditional validation + +## Compatibility Notes + +- **Backward Compatibility**: OpenAPI 3.0 schemas continue to work without changes +- **Mixed Usage**: You can use both 3.0 and 3.1 schemas in the same project +- **Automatic Detection**: The library automatically detects the version and applies appropriate validation rules +- **No Breaking Changes**: Existing tests and code continue to work + +## Examples + +See the `/tests/example/` directory for complete working examples: + +- `openapi31.json` - Basic 3.1 schema +- `openapi31-nullable.json` - Nullable type examples +- `openapi31-nested-ref-required.json` - Nullable objects with required fields and nested $ref +- `openapi31-webhooks.json` - Webhook definitions +- `openapi31-conditional.json` - Conditional schemas +- `openapi31-tuples.json` - Tuple validation + +## Further Reading + +- [OpenAPI 3.1 Specification](https://spec.openapis.org/oas/v3.1.0) +- [JSON Schema 2020-12](https://json-schema.org/draft/2020-12/json-schema-core.html) +- [What's New in OpenAPI 3.1](https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0) \ No newline at end of file diff --git a/docs/schema-classes.md b/docs/schema-classes.md index 863e1c3..f67908d 100644 --- a/docs/schema-classes.md +++ b/docs/schema-classes.md @@ -4,12 +4,13 @@ sidebar_position: 5 # Schema Classes -PHP Swagger Test provides two main schema classes for working with different versions of the OpenAPI specification: +PHP Swagger Test provides three main schema classes for working with different versions of the OpenAPI specification: - `SwaggerSchema` - For OpenAPI 2.0 (formerly known as Swagger) -- `OpenApiSchema` - For OpenAPI 3.0 +- `OpenApiSchema` - For OpenAPI 3.0.x +- `OpenApi31Schema` - For OpenAPI 3.1.x with JSON Schema 2020-12 support -Both classes extend the abstract `Schema` class and provide specific implementations for their respective specification +All classes extend the abstract `Schema` class and provide specific implementations for their respective specification versions. ## Creating Schema Instances @@ -88,6 +89,77 @@ $schema->setServerVariable('environment', 'api.dev'); echo $schema->getServerUrl(); ``` +## OpenApi31Schema Specific Features + +The `OpenApi31Schema` class handles OpenAPI 3.1.x specifications with full JSON Schema 2020-12 support. + +### Key Differences from OpenAPI 3.0 + +1. **Nullable Handling**: Uses type arrays `["string", "null"]` instead of the `nullable` keyword +2. **Webhooks**: First-class support for describing incoming HTTP requests +3. **JSON Schema Keywords**: Full support for `const`, `if/then/else`, `prefixItems`, and more +4. **$ref Behavior**: Can have sibling keywords alongside `$ref` +5. **Server Variables**: Default values are optional (required in 3.0) + +### Webhooks Support + +OpenAPI 3.1 introduces webhooks for describing incoming HTTP requests: + +```php +hasWebhooks()) { + // Get all webhook names + $webhooks = $schema->getWebhookNames(); // ['newUser', 'orderUpdated', ...] + + // Validate webhook request + $requestBody = $schema->getWebhookRequestParameters('newUser', 'post'); + $requestBody->match([ + 'userId' => 123, + 'event' => 'user.created' + ]); + + // Validate webhook response + $responseBody = $schema->getWebhookResponseParameters('newUser', 'post', 200); + $responseBody->match($responseData); +} +``` + +### Schema Dialect Detection + +OpenAPI 3.1 can declare its JSON Schema dialect: + +```php +getSchemaDialect(); +// Returns: "https://spec.openapis.org/oas/3.1/dialect/base" or null + +// Check if using JSON Schema 2020-12 +if ($schema->isJsonSchema202012()) { + // Can use advanced JSON Schema 2020-12 features +} +``` + +### Webhook Methods + +- `hasWebhooks(): bool` - Check if the schema defines any webhooks +- `getWebhookNames(): array` - Get list of all webhook names +- `getWebhookDefinition(string $name, string $method): mixed` - Get webhook definition +- `getWebhookRequestParameters(string $name, string $method): Body` - Get request body validator +- `getWebhookResponseParameters(string $name, string $method, int $status): Body` - Get response validator + +### Schema Dialect Methods + +- `getSchemaDialect(): ?string` - Get the JSON Schema dialect declaration +- `isJsonSchema202012(): bool` - Check if using JSON Schema 2020-12 + +For comprehensive documentation on OpenAPI 3.1 features, see the [OpenAPI 3.1 Features Guide](openapi-3.1-features.md). + ## Common Methods Both schema classes provide the following common methods: diff --git a/docs/trait-usage.md b/docs/trait-usage.md index 03ef9cc..01cbf51 100644 --- a/docs/trait-usage.md +++ b/docs/trait-usage.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # Using the OpenApiValidation Trait diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a2bd9f3..0535f9b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 --- # Troubleshooting Guide @@ -28,47 +28,51 @@ You're sending a request body for an operation that doesn't define one in the Op **Solutions:** 1. **If the endpoint should NOT accept a body** (e.g., GET, DELETE requests): - Remove the `withRequestBody()` call from your test: - - ```php - // WRONG - GET requests typically don't have bodies - $request->withMethod('GET') - ->withPath('/pet/1') - ->withRequestBody(['data' => 'value']); // ← Remove this - - // CORRECT - $request->withMethod('GET') - ->withPath('/pet/1'); - ``` + +Remove the `withRequestBody()` call from your test: + +```php +// WRONG - GET requests typically don't have bodies +$request->withMethod('GET') + ->withPath('/pet/1') + ->withRequestBody(['data' => 'value']); // ← Remove this + +// CORRECT +$request->withMethod('GET') + ->withPath('/pet/1'); +``` 2. **If the endpoint SHOULD accept a body** (e.g., POST, PUT, PATCH): - Add the request body definition to your OpenAPI specification: - - **For OpenAPI 3.0:** - ```yaml - paths: - /pet: - post: - requestBody: # ← Add this - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - ``` - - **For Swagger 2.0:** - ```yaml - paths: - /pet: - post: - parameters: - - in: body # ← Add this parameter - name: body - required: true - schema: - $ref: '#/definitions/Pet' - ``` + +Add the request body definition to your OpenAPI specification: + +**For OpenAPI 3.0:** + +```yaml +paths: + /pet: + post: + requestBody: # ← Add this + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +``` + +**For Swagger 2.0:** + +```yaml +paths: + /pet: + post: + parameters: + - in: body # ← Add this parameter + name: body + required: true + schema: + $ref: '#/definitions/Pet' +``` --- @@ -139,47 +143,49 @@ The path you're testing doesn't exist in your OpenAPI/Swagger specification. **Common Issues:** 1. **Path parameter mismatch:** - ```php - // Your spec has: /pet/{petId} - // But you're testing: /pet/1 - - // This should work - parameters are replaced automatically - $request->withPath('/pet/1'); // ✓ Matches /pet/{petId} - ``` + +```php +// Your spec has: /pet/{petId} +// But you're testing: /pet/1 + +// This should work - parameters are replaced automatically +$request->withPath('/pet/1'); // ✓ Matches /pet/{petId} +``` 2. **Missing leading slash:** - ```php - $request->withPath('pet/1'); // ✗ WRONG - $request->withPath('/pet/1'); // ✓ CORRECT - ``` + +```php +$request->withPath('pet/1'); // ✗ WRONG +$request->withPath('/pet/1'); // ✓ CORRECT +``` 3. **Base path confusion:** If your spec defines a base path (Swagger 2.0) or server URL (OpenAPI 3.0), don't include it in your test path: - ```yaml - # OpenAPI 3.0 - servers: - - url: https://api.example.com/v1 - - paths: - /pet/{petId}: # ← Use this in your test - ``` +```yaml +# OpenAPI 3.0 +servers: + - url: https://api.example.com/v1 + +paths: + /pet/{petId}: # ← Use this in your test +``` - ```php - $request->withPath('/pet/1'); // ✓ CORRECT - $request->withPath('/v1/pet/1'); // ✗ WRONG - ``` +```php +$request->withPath('/pet/1'); // ✓ CORRECT +$request->withPath('/v1/pet/1'); // ✗ WRONG +``` 4. **Path doesn't exist in spec:** Add the path to your OpenAPI specification: - ```yaml - paths: - /pet/{petId}: # ← Add this path - get: - # ... operation definition - ``` +```yaml +paths: + /pet/{petId}: # ← Add this path + get: + # ... operation definition +``` --- @@ -192,19 +198,21 @@ The HTTP method you're testing doesn't exist for that path in your specification Either: 1. **Fix your test** to use the correct HTTP method: - ```php - $request->withMethod('POST'); // Change to match your spec - ``` + +```php +$request->withMethod('POST'); // Change to match your spec +``` 2. **Add the method** to your OpenAPI specification: - ```yaml - paths: - /pet/{petId}: - get: # ← Already exists - # ... - put: # ← Add this method - # ... operation definition - ``` + +```yaml +paths: + /pet/{petId}: + get: # ← Already exists + # ... + put: # ← Add this method + # ... operation definition +``` --- @@ -216,28 +224,30 @@ The API returned a different status code than expected. **Solution:** 1. **Adjust your expectation** if the new status code is correct: - ```php - $request - ->withMethod('POST') - ->withPath('/pet') - ->expectStatus(201); // ← Adjust this - ``` + +```php +$request + ->withMethod('POST') + ->withPath('/pet') + ->expectStatus(201); // ← Adjust this +``` 2. **Fix your API** if it's returning the wrong status code. 3. **Add the status code** to your OpenAPI specification: - ```yaml - paths: - /pet: - post: - responses: - '201': # ← Add this response - description: Pet created - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - ``` + +```yaml +paths: + /pet: + post: + responses: + '201': # ← Add this response + description: Pet created + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +``` --- @@ -257,20 +267,22 @@ schema: **Solution:** 1. **Fix your test data** to match the pattern: - ```php - $request->withRequestBody([ - 'status' => 'AVAILABLE' // ✗ WRONG - uppercase - ]); - - $request->withRequestBody([ - 'status' => 'available' // ✓ CORRECT - lowercase - ]); - ``` + +```php +$request->withRequestBody([ + 'status' => 'AVAILABLE' // ✗ WRONG - uppercase +]); + +$request->withRequestBody([ + 'status' => 'available' // ✓ CORRECT - lowercase +]); +``` 2. **Fix the pattern** in your spec if it's too restrictive: - ```yaml - pattern: '^[a-zA-Z]+$' # Allow both upper and lowercase - ``` + +```yaml +pattern: '^[a-zA-Z]+$' # Allow both upper and lowercase +``` --- @@ -282,26 +294,28 @@ Your request/response is missing a required field defined in the specification. **Solution:** 1. **Add the missing field** to your test: - ```php - $request->withRequestBody([ - 'name' => 'Fluffy', - 'status' => 'available', // ← Don't forget required fields - ]); - ``` + +```php +$request->withRequestBody([ + 'name' => 'Fluffy', + 'status' => 'available', // ← Don't forget required fields +]); +``` 2. **Make the field optional** in your spec if it shouldn't be required: - ```yaml - schema: - type: object - required: - - name - # Remove 'status' from required if optional - properties: - name: - type: string - status: - type: string - ``` + +```yaml +schema: + type: object + required: + - name + # Remove 'status' from required if optional + properties: + name: + type: string + status: + type: string +``` --- @@ -314,33 +328,36 @@ is set to `false` (or not set, as false is the default). **Solution:** 1. **Remove extra fields** from your test: - ```php - $request->withRequestBody([ - 'name' => 'Fluffy', - 'extraField' => 'value', // ← Remove this if not in spec - ]); - ``` + +```php +$request->withRequestBody([ + 'name' => 'Fluffy', + 'extraField' => 'value', // ← Remove this if not in spec +]); +``` 2. **Add the field** to your OpenAPI specification: - ```yaml - schema: - type: object - properties: - name: - type: string - extraField: # ← Add this - type: string - ``` + +```yaml +schema: + type: object + properties: + name: + type: string + extraField: # ← Add this + type: string +``` 3. **Allow additional properties** in your spec (not recommended for strict validation): - ```yaml - schema: - type: object - additionalProperties: true # ← Allow any extra properties - properties: - name: - type: string - ``` + +```yaml +schema: + type: object + additionalProperties: true # ← Allow any extra properties + properties: + name: + type: string +``` --- @@ -351,7 +368,6 @@ is set to `false` (or not set, as false is the default). Ensure PHPUnit is configured to show detailed errors in your `phpunit.xml.dist`: ```xml - OpenAPI 3.0 ──> OpenAPI 3.1 + │ + └──> OpenAPI 3.1 (direct jump possible) +``` + +### Recommended Migration Strategy + +1. **Swagger 2.0 → OpenAPI 3.0**: Good first step if you have complex tooling dependencies + - Easier migration path + - Better tool support historically + - Can then migrate to 3.1 when ready + +2. **Swagger 2.0 → OpenAPI 3.1**: Direct migration for new projects + - Skip the intermediate step + - Get all the latest features immediately + - Requires more comprehensive schema updates + +3. **OpenAPI 3.0 → OpenAPI 3.1**: Simple upgrade + - Mostly compatible + - Main change: nullable keyword → type arrays + - Can take advantage of new features gradually + +## Class Names in php-swagger-test + +| Version | Class Name | +|---------------|-------------------------------------------| +| Swagger 2.0 | `ByJG\ApiTools\Swagger\SwaggerSchema` | +| OpenAPI 3.0.x | `ByJG\ApiTools\OpenApi\OpenApiSchema` | +| OpenAPI 3.1.x | `ByJG\ApiTools\OpenApi31\OpenApi31Schema` | + +All classes extend `ByJG\ApiTools\Base\Schema` and are automatically instantiated based on the specification version +when using factory methods: + +```php +// Automatic version detection +$schema = \ByJG\ApiTools\Base\Schema::fromFile('/path/to/spec.json'); + +// Returns: +// - SwaggerSchema for "swagger": "2.0" +// - OpenApiSchema for "openapi": "3.0.x" +// - OpenApi31Schema for "openapi": "3.1.x" +``` + +## Version-Specific Code Examples + +### Nullable Fields + +```php +// Swagger 2.0 +$schema = Schema::fromFile('swagger.json', allowNullValues: true); + +// OpenAPI 3.0 +{ + "type": "string", + "nullable": true +} + +// OpenAPI 3.1 +{ + "type": ["string", "null"] +} +``` + +### Server URLs + +```php +// Swagger 2.0 +{ + "host": "api.example.com", + "basePath": "/v1" +} +// URL: https://api.example.com/v1 + +// OpenAPI 3.0 & 3.1 +{ + "servers": [ + { + "url": "https://{environment}.example.com/v1", + "variables": { + "environment": { + "default": "api", // Required in 3.0, optional in 3.1 + "enum": ["api", "dev", "staging"] + } + } + } + ] +} +``` + +### Webhooks (OpenAPI 3.1 only) + +```php +// OpenAPI 3.1 - testing incoming webhook requests +$schema = Schema::fromFile('openapi31.json'); + +if ($schema->hasWebhooks()) { + $requestBody = $schema->getWebhookRequestParameters('newUser', 'post'); + $requestBody->match($incomingWebhookData); +} +``` + +## Further Reading + +- [Migration Guide](migration-guide.md) - Detailed migration instructions +- [OpenAPI 3.1 Features](openapi-3.1-features.md) - Complete guide to 3.1 features +- [Schema Classes](schema-classes.md) - API reference for schema classes +- [OpenAPI Specification](https://spec.openapis.org/) - Official specifications \ No newline at end of file diff --git a/src/Base/Body.php b/src/Base/Body.php index bb816ea..9bfe358 100644 --- a/src/Base/Body.php +++ b/src/Base/Body.php @@ -8,6 +8,7 @@ use ByJG\ApiTools\Exception\InvalidRequestException; use ByJG\ApiTools\Exception\NotMatchedException; use ByJG\ApiTools\Exception\RequiredArgumentNotFound; +use SimpleXMLElement; abstract class Body { @@ -191,6 +192,45 @@ protected function matchArray(string $name, array $schemaArray, mixed $body, mix return null; } + // NEW: Handle prefixItems (JSON Schema 2020-12 tuple validation) + if (isset($schemaArray['prefixItems'])) { + $bodyArray = (array)$body; + + // Validate each item against its corresponding schema + foreach ($schemaArray['prefixItems'] as $index => $itemSchema) { + if (!isset($bodyArray[$index])) { + // Check minItems if needed + if (isset($schemaArray['minItems']) && $index < $schemaArray['minItems']) { + throw new NotMatchedException( + "Array '$name' requires at least " . $schemaArray['minItems'] . " items", + $this->structure + ); + } + break; + } + $this->matchSchema($name . "[$index]", $itemSchema, $bodyArray[$index]); + } + + // Check maxItems + if (isset($schemaArray['maxItems']) && count($bodyArray) > $schemaArray['maxItems']) { + throw new NotMatchedException( + "Array '$name' has more than " . $schemaArray['maxItems'] . " items", + $this->structure + ); + } + + // Check minItems + if (isset($schemaArray['minItems']) && count($bodyArray) < $schemaArray['minItems']) { + throw new NotMatchedException( + "Array '$name' requires at least " . $schemaArray['minItems'] . " items", + $this->structure + ); + } + + return true; + } + + // Original items handling for non-tuple arrays foreach ((array)$body as $item) { if (!isset($schemaArray['items'])) { // If there is no type , there is no test. continue; @@ -208,6 +248,29 @@ protected function matchArray(string $name, array $schemaArray, mixed $body, mix */ protected function matchTypes(string $name, mixed $schemaArray, mixed $body): ?bool { + // NEW: Support 'const' keyword (JSON Schema 2020-12) + if (isset($schemaArray['const'])) { + if ($body !== $schemaArray['const']) { + throw new NotMatchedException( + "Value '" . var_export($body, true) . "' in '$name' does not match const value '" . var_export($schemaArray['const'], true) . "'", + $this->structure + ); + } + return true; + } + + // NEW: Support 'pattern' keyword without type (JSON Schema allows this) + if (isset($schemaArray['pattern']) && !isset($schemaArray['type'])) { + if (!is_string($body)) { + throw new NotMatchedException("Value '" . var_export($body, true) . "' in '$name' must be a string to match pattern. ", $this->structure); + } + $pattern = '/' . rtrim(ltrim($schemaArray['pattern'], '/'), '/') . '/'; + if (!preg_match($pattern, $body)) { + throw new NotMatchedException("Value '$body' in '$name' not matched in pattern. ", $this->structure); + } + return true; + } + if (!isset($schemaArray['type'])) { return null; } @@ -257,6 +320,138 @@ function () use ($name, $schemaArray, $body, $type): bool|null return null; } + /** + * Handle conditional schemas (if/then/else) - JSON Schema 2020-12 + * + * @param string $name + * @param array $schemaArray + * @param mixed $body + * @return ?bool + * @throws DefinitionNotFoundException + * @throws GenericApiException + * @throws InvalidDefinitionException + * @throws InvalidRequestException + * @throws NotMatchedException + */ + protected function matchConditional(string $name, array $schemaArray, mixed $body): ?bool + { + if (!isset($schemaArray['if'])) { + return null; + } + + // Test if condition - for "if", we need to allow additional properties + // because we're only testing if certain properties match, not the whole schema + $ifMatches = false; + try { + $ifSchema = $schemaArray['if']; + // Make sure additional properties are allowed for the "if" test + if (isset($ifSchema['properties']) && !isset($ifSchema['additionalProperties'])) { + $ifSchema['additionalProperties'] = true; + } + $ifMatches = $this->matchSchema($name . '[if]', $ifSchema, $body) ?? false; + } catch (NotMatchedException $e) { + // If doesn't match, that's okay + $ifMatches = false; + } + + // Apply then or else - these add constraints ON TOP of base schema + if ($ifMatches && isset($schemaArray['then'])) { + // For "then", also allow additional properties since we're adding constraints + $thenSchema = $schemaArray['then']; + if (isset($thenSchema['properties']) && !isset($thenSchema['additionalProperties'])) { + $thenSchema['additionalProperties'] = true; + } + // Validate against the then branch (this adds constraints) + $this->matchSchema($name . '[then]', $thenSchema, $body); + // Return true to indicate we processed conditional (but base validation continues) + return true; + } elseif (!$ifMatches && isset($schemaArray['else'])) { + // For "else", also allow additional properties + $elseSchema = $schemaArray['else']; + if (isset($elseSchema['properties']) && !isset($elseSchema['additionalProperties'])) { + $elseSchema['additionalProperties'] = true; + } + // Validate against the else branch (this adds constraints) + $this->matchSchema($name . '[else]', $elseSchema, $body); + // Return true to indicate we processed conditional (but base validation continues) + return true; + } + + return null; + } + + /** + * Check if type is an array (for OpenAPI 3.1 nullable support) + * Handles type: ["string", "null"] syntax + * + * @param string $name + * @param array $schemaArray + * @param mixed $body + * @return ?bool + * @throws DefinitionNotFoundException + * @throws GenericApiException + * @throws InvalidDefinitionException + * @throws InvalidRequestException + * @throws NotMatchedException + */ + protected function matchTypeArray(string $name, array $schemaArray, mixed $body): ?bool + { + if (!isset($schemaArray['type']) || !is_array($schemaArray['type'])) { + return null; + } + + // OpenAPI 3.1: type can be an array like ["string", "null"] + $types = $schemaArray['type']; + + // Check if null is allowed + $isNullable = in_array('null', $types); + + // If body is null + if (is_null($body)) { + if ($isNullable) { + return true; + } + throw new NotMatchedException( + "Value of property '$name' is null, but null is not in allowed types: " . implode(', ', $types), + $this->structure + ); + } + + // Try to match against each type (excluding 'null') + $nonNullTypes = array_filter($types, fn($t) => $t !== 'null'); + $matched = false; + $lastException = null; + + foreach ($nonNullTypes as $type) { + $tempSchema = $schemaArray; + $tempSchema['type'] = $type; + + try { + // Try to match with this type + $typeMatched = $this->matchTypes($name, $tempSchema, $body); + + // For object types, matchTypes returns null, so we need to also check object properties + if ($typeMatched === null && $type === self::SWAGGER_OBJECT) { + $typeMatched = $this->matchObjectProperties($name, $tempSchema, $body); + } + + if ($typeMatched) { + $matched = true; + break; + } + } catch (NotMatchedException $e) { + $lastException = $e; + continue; + } + } + + if (!$matched && $lastException !== null) { + throw $lastException; + } + + return $matched; + } + /** * @param string $name * @param array $schemaArray @@ -275,7 +470,8 @@ public function matchObjectProperties(string $name, mixed $schemaArray, mixed $b // } if (!isset($schemaArray[self::SWAGGER_PROPERTIES])) { - if (in_array($schemaArray["type"] ?? '', [self::SWAGGER_OBJECT, self::SWAGGER_ARRAY])) { + // If type is object/array OR if there's a required constraint, treat as object + if (in_array($schemaArray["type"] ?? '', [self::SWAGGER_OBJECT, self::SWAGGER_ARRAY]) || isset($schemaArray[self::SWAGGER_REQUIRED])) { $schemaArray[self::SWAGGER_PROPERTIES] = []; } else { return null; @@ -286,21 +482,34 @@ public function matchObjectProperties(string $name, mixed $schemaArray, mixed $b $schemaArray[self::SWAGGER_ADDITIONAL_PROPERTIES] = true; } - if ($body instanceof \SimpleXMLElement) { + if ($body instanceof SimpleXMLElement) { $encoded = json_encode($body); $body = json_decode($encoded !== false ? $encoded : '{}', true); } + // Handle null values when nullable is allowed + if (is_null($body)) { + $nullable = isset($schemaArray['nullable']) ? (bool)$schemaArray['nullable'] : $this->schema->isAllowNullValues(); + if ($nullable) { + return true; + } + throw new NotMatchedException( + "Value of property '$name' is null, but should be an object", + $this->structure + ); + } + if (!is_array($body)) { - throw new InvalidRequestException( + throw new NotMatchedException( "The body '" . $body . "' cannot be compared with the expected type " . $name, - $body + $this->structure ); } if (!isset($schemaArray[self::SWAGGER_REQUIRED])) { $schemaArray[self::SWAGGER_REQUIRED] = []; } + foreach ($schemaArray[self::SWAGGER_PROPERTIES] as $prop => $def) { $required = array_search($prop, $schemaArray[self::SWAGGER_REQUIRED]); @@ -320,6 +529,16 @@ public function matchObjectProperties(string $name, mixed $schemaArray, mixed $b unset($body[$prop]); } + // NEW: If there are required fields but no properties were defined (e.g., in conditional then/else), + // check if the required fields exist in the body without validating them + if (empty($schemaArray[self::SWAGGER_PROPERTIES]) && !empty($schemaArray[self::SWAGGER_REQUIRED])) { + foreach ($schemaArray[self::SWAGGER_REQUIRED] as $index => $reqProp) { + if (array_key_exists($reqProp, $body)) { + unset($schemaArray[self::SWAGGER_REQUIRED][$index]); + } + } + } + if (count($schemaArray[self::SWAGGER_REQUIRED]) > 0) { throw new NotMatchedException( "The required property(ies) '" @@ -366,6 +585,12 @@ public function matchObjectProperties(string $name, mixed $schemaArray, mixed $b */ protected function matchSchema(string $name, mixed $schemaArray, mixed $body): ?bool { + // NEW: Check for array types first (OpenAPI 3.1 nullable support) + $arrayTypeResult = $this->matchTypeArray($name, $schemaArray, $body); + if ($arrayTypeResult !== null) { + return $arrayTypeResult; + } + // Match Single Types if ($this->matchTypes($name, $schemaArray, $body)) { return true; @@ -381,14 +606,42 @@ protected function matchSchema(string $name, mixed $schemaArray, mixed $body): ? // Get References and try to match it again if (isset($schemaArray['$ref']) && !is_array($schemaArray['$ref'])) { $definition = $this->schema->getDefinition($schemaArray['$ref']); + + // NEW: OpenAPI 3.1 - $ref can have sibling keywords + if (is_array($schemaArray) && ($this->schema->getSpecificationVersion() === '3.1' || str_starts_with($this->schema->getSpecificationVersion(), '3.1.'))) { + // Merge sibling keywords (but definition takes precedence for conflicting keys) + $siblingKeywords = array_diff_key($schemaArray, ['$ref' => true]); + if (!empty($siblingKeywords)) { + // Merge: sibling keywords first, then definition takes precedence + $definition = array_merge($siblingKeywords, $definition); + } + } + return $this->matchSchema($schemaArray['$ref'], $definition, $body); } + // NEW: Handle conditional schemas (if/then/else) BEFORE object properties + // This ensures conditional constraints are applied + $conditionalResult = $this->matchConditional($name, $schemaArray, $body); + // Match object properties - if ($this->matchObjectProperties($name, $schemaArray, $body)) { + $objectResult = $this->matchObjectProperties($name, $schemaArray, $body); + + // If we processed a conditional OR matched object properties, continue + // Both must pass if both are present + if ($conditionalResult !== null || $objectResult) { return true; } + // Check nullable at the outer schema level for combined schemas (oneOf/allOf/anyOf + nullable: true) + // This handles the OpenAPI 3.0 pattern: { "oneOf": [...], "nullable": true } + if (is_null($body) && (isset($schemaArray['oneOf']) || isset($schemaArray['allOf']) || isset($schemaArray['anyOf']))) { + $nullable = isset($schemaArray['nullable']) ? (bool)$schemaArray['nullable'] : $this->schema->isAllowNullValues(); + if ($nullable) { + return true; + } + } + if (isset($schemaArray['allOf'])) { $allOfSchemas = $schemaArray['allOf']; foreach ($allOfSchemas as &$schema) { @@ -418,6 +671,24 @@ protected function matchSchema(string $name, mixed $schemaArray, mixed $body): ? return $matched; } + if (isset($schemaArray['anyOf'])) { + $catchException = null; + foreach ($schemaArray['anyOf'] as $schema) { + try { + $result = $this->matchSchema($name, $schema, $body); + if ($result) { + return true; + } + } catch (NotMatchedException $exception) { + $catchException = $exception; + } + } + if ($catchException !== null) { + throw $catchException; + } + return false; + } + /** * OpenApi 2.0 does not describe ANY object value * But there is hack that makes ANY object possible, described in link below @@ -433,6 +704,12 @@ protected function matchSchema(string $name, mixed $schemaArray, mixed $body): ? return true; } + // NEW: Handle schemas with only "required" (used in conditional then/else) + if (isset($schemaArray['required']) && count(array_diff(array_keys($schemaArray), ['required', 'additionalProperties'])) === 0) { + // This is handled by matchObjectProperties, just return true + return true; + } + throw new GenericApiException("Not all cases are defined. Please open an issue about this. Schema: $name"); } @@ -446,6 +723,17 @@ protected function matchSchema(string $name, mixed $schemaArray, mixed $body): ? */ protected function matchNull(string $name, mixed $body, mixed $type, bool $nullable): ?bool { + // OpenAPI 3.1: `type: null` means the only valid value is null + if ($type === 'null') { + if (is_null($body)) { + return true; + } + throw new NotMatchedException( + "Value of property '$name' is not null, but type is 'null'", + $this->structure + ); + } + if (!is_null($body)) { return null; } diff --git a/src/Base/Schema.php b/src/Base/Schema.php index 69c116b..7125352 100644 --- a/src/Base/Schema.php +++ b/src/Base/Schema.php @@ -9,6 +9,7 @@ use ByJG\ApiTools\Exception\NotMatchedException; use ByJG\ApiTools\Exception\PathNotFoundException; use ByJG\ApiTools\OpenApi\OpenApiSchema; +use ByJG\ApiTools\OpenApi31\OpenApi31Schema; use ByJG\ApiTools\Swagger\SwaggerSchema; use ByJG\Util\Uri; use InvalidArgumentException; @@ -37,10 +38,10 @@ public function getSpecificationVersion(): string * * @param string $jsonString JSON-encoded OpenAPI/Swagger specification * @param bool $allowNullValues Whether to allow null values (Swagger 2.0 only) - * @return SwaggerSchema|OpenApiSchema + * @return SwaggerSchema|OpenApiSchema|OpenApi31Schema * @throws InvalidArgumentException */ - public static function fromJson(string $jsonString, bool $allowNullValues = false): SwaggerSchema|OpenApiSchema + public static function fromJson(string $jsonString, bool $allowNullValues = false): SwaggerSchema|OpenApiSchema|OpenApi31Schema { $data = json_decode($jsonString, true); if ($data === null) { @@ -54,10 +55,10 @@ public static function fromJson(string $jsonString, bool $allowNullValues = fals * * @param string $filePath Path to JSON file containing OpenAPI/Swagger specification * @param bool $allowNullValues Whether to allow null values (Swagger 2.0 only) - * @return SwaggerSchema|OpenApiSchema + * @return SwaggerSchema|OpenApiSchema|OpenApi31Schema * @throws InvalidArgumentException */ - public static function fromFile(string $filePath, bool $allowNullValues = false): SwaggerSchema|OpenApiSchema + public static function fromFile(string $filePath, bool $allowNullValues = false): SwaggerSchema|OpenApiSchema|OpenApi31Schema { if (!file_exists($filePath)) { throw new InvalidArgumentException("File not found: $filePath"); @@ -74,16 +75,22 @@ public static function fromFile(string $filePath, bool $allowNullValues = false) * * @param array $data PHP array containing OpenAPI/Swagger specification * @param bool $allowNullValues Whether to allow null values (Swagger 2.0 only) - * @return SwaggerSchema|OpenApiSchema + * @return SwaggerSchema|OpenApiSchema|OpenApi31Schema * @throws InvalidArgumentException */ - public static function fromArray(array $data, bool $allowNullValues = false): SwaggerSchema|OpenApiSchema + public static function fromArray(array $data, bool $allowNullValues = false): SwaggerSchema|OpenApiSchema|OpenApi31Schema { // check which type of schema we have and dispatch to derived class constructor if (isset($data['swagger'])) { return new SwaggerSchema($data, $allowNullValues); } if (isset($data['openapi'])) { + $version = $data['openapi']; + // Check if it's 3.1.x or higher + if (version_compare($version, '3.1.0', '>=')) { + return new OpenApi31Schema($data); + } + // Default to 3.0.x return new OpenApiSchema($data); } @@ -98,10 +105,10 @@ public static function fromArray(array $data, bool $allowNullValues = false): Sw * * @param array|string $data * @param bool $extraArgs - * @return SwaggerSchema|OpenApiSchema + * @return SwaggerSchema|OpenApiSchema|OpenApi31Schema * @deprecated Since version 6.0, use fromJson(), fromArray(), or fromFile() instead. Will be removed in version 7.0 */ - public static function getInstance(array|string $data, bool $extraArgs = false): SwaggerSchema|OpenApiSchema + public static function getInstance(array|string $data, bool $extraArgs = false): SwaggerSchema|OpenApiSchema|OpenApi31Schema { // when given a string, decode from JSON if (is_string($data)) { diff --git a/src/OpenApi/OpenApiBase.php b/src/OpenApi/OpenApiBase.php new file mode 100644 index 0000000..259e6b1 --- /dev/null +++ b/src/OpenApi/OpenApiBase.php @@ -0,0 +1,166 @@ +jsonFile = $data; + $this->specificationVersion = $this->getDefaultVersion($data); + } + + /** + * Get the default version for this schema type + * + * @param array $data + * @return string + */ + abstract protected function getDefaultVersion(array $data): string; + + /** + * Get server variable default value + * Override in subclasses for version-specific behavior + * + * @param array $variableDefinition + * @return string + */ + abstract protected function getServerVariableDefault(array $variableDefinition): string; + + /** + * Get server URL with variable substitution + * + * @return string + */ + #[\Override] + public function getServerUrl(): string + { + if (!isset($this->jsonFile['servers'])) { + return ''; + } + $serverUrl = $this->jsonFile['servers'][0]['url']; + + if (isset($this->jsonFile['servers'][0]['variables'])) { + foreach ($this->jsonFile['servers'][0]['variables'] as $var => $value) { + if (!isset($this->serverVariables[$var])) { + $this->serverVariables[$var] = $this->getServerVariableDefault($value); + } + } + } + + foreach ($this->serverVariables as $var => $value) { + $replaced = preg_replace("/\{$var}/", $value, $serverUrl); + $serverUrl = is_string($replaced) ? $replaced : $serverUrl; + } + + return $serverUrl; + } + + /** + * Get the base path from the server URL + * + * @return string + */ + #[\Override] + public function getBasePath(): string + { + $uriServer = new Uri($this->getServerUrl()); + return $uriServer->getPath(); + } + + /** + * Set a server variable value + * + * @param string $var + * @param string $value + * @return void + */ + public function setServerVariable(string $var, string $value): void + { + $this->serverVariables[$var] = $value; + } + + /** + * Validate arguments for parameters + * + * @param string $parameterIn + * @param array $parameters + * @param array $arguments + * @return void + * @throws DefinitionNotFoundException + * @throws InvalidDefinitionException + * @throws NotMatchedException + */ + #[\Override] + protected function validateArguments(string $parameterIn, array $parameters, array $arguments): void + { + foreach ($parameters as $parameter) { + if (isset($parameter['$ref'])) { + $paramParts = explode("/", $parameter['$ref']); + if (count($paramParts) != 4 || $paramParts[0] != "#" || $paramParts[1] != self::SWAGGER_COMPONENTS || $paramParts[2] != self::SWAGGER_PARAMETERS) { + throw new InvalidDefinitionException( + "Not get the reference in the expected format #/components/parameters/" + ); + } + if (!isset($this->jsonFile[self::SWAGGER_COMPONENTS][self::SWAGGER_PARAMETERS][$paramParts[3]])) { + throw new DefinitionNotFoundException( + "Not find reference #/components/parameters/$paramParts[3]" + ); + } + $parameter = $this->jsonFile[self::SWAGGER_COMPONENTS][self::SWAGGER_PARAMETERS][$paramParts[3]]; + } + if ($parameter['in'] === $parameterIn && + $parameter['schema']['type'] === "integer" + && filter_var($arguments[$parameter['name']], FILTER_VALIDATE_INT) === false) { + throw new NotMatchedException('Path expected an integer value'); + } + } + } + + /** + * Get a component definition by reference path + * + * @param string $name + * @return mixed + * @throws DefinitionNotFoundException + * @throws InvalidDefinitionException + */ + #[\Override] + public function getDefinition(string $name): mixed + { + $nameParts = explode('/', $name); + + if (count($nameParts) < 4 || $nameParts[0] !== '#') { + throw new InvalidDefinitionException('Invalid Component'); + } + + if (!isset($this->jsonFile[$nameParts[1]][$nameParts[2]][$nameParts[3]])) { + throw new DefinitionNotFoundException("Component'$name' not found"); + } + + return $this->jsonFile[$nameParts[1]][$nameParts[2]][$nameParts[3]]; + } +} \ No newline at end of file diff --git a/src/OpenApi/OpenApiResponseBody.php b/src/OpenApi/OpenApiResponseBody.php index 8e93e7f..304ec11 100644 --- a/src/OpenApi/OpenApiResponseBody.php +++ b/src/OpenApi/OpenApiResponseBody.php @@ -4,6 +4,7 @@ use ByJG\ApiTools\Base\Body; use ByJG\ApiTools\Exception\NotMatchedException; +use SimpleXMLElement; class OpenApiResponseBody extends Body { @@ -28,7 +29,7 @@ public function match(mixed $body, ?string $contentType = null): bool } if (empty($contentType)) { - if ($body instanceof \SimpleXMLElement) { + if ($body instanceof SimpleXMLElement) { if (isset($this->structure['content']["application/xml"])) { $contentType = "application/xml"; $encoded = json_encode($body); diff --git a/src/OpenApi/OpenApiSchema.php b/src/OpenApi/OpenApiSchema.php index 2895b30..06d8086 100644 --- a/src/OpenApi/OpenApiSchema.php +++ b/src/OpenApi/OpenApiSchema.php @@ -4,111 +4,32 @@ use ByJG\ApiTools\Base\Body; use ByJG\ApiTools\Base\Schema; -use ByJG\ApiTools\Exception\DefinitionNotFoundException; -use ByJG\ApiTools\Exception\InvalidDefinitionException; use ByJG\ApiTools\Exception\InvalidRequestException; -use ByJG\ApiTools\Exception\NotMatchedException; -use ByJG\Util\Uri; -class OpenApiSchema extends Schema +/** + * OpenAPI 3.0.x Schema implementation + * + * Handles OpenAPI 3.0.0, 3.0.1, 3.0.2, and 3.0.3 specifications. + */ +class OpenApiSchema extends OpenApiBase { - - protected array $serverVariables = []; - - /** - * Initialize with schema data, which can be a PHP array or encoded as JSON. - * - * @param array|string $data - */ - public function __construct(array|string $data) - { - // when given a string, decode from JSON - if (is_string($data)) { - $data = json_decode($data, true); - } - $this->jsonFile = $data; - } - - #[\Override] - public function getServerUrl(): string - { - if (!isset($this->jsonFile['servers'])) { - return ''; - } - $serverUrl = $this->jsonFile['servers'][0]['url']; - - if (isset($this->jsonFile['servers'][0]['variables'])) { - foreach ($this->jsonFile['servers'][0]['variables'] as $var => $value) { - if (!isset($this->serverVariables[$var])) { - $this->serverVariables[$var] = $value['default']; - } - } - } - - foreach ($this->serverVariables as $var => $value) { - $replaced = preg_replace("/\{$var}/", $value, $serverUrl); - $serverUrl = is_string($replaced) ? $replaced : $serverUrl; - } - - return $serverUrl; - } - - #[\Override] - public function getBasePath(): string - { - $uriServer = new Uri($this->getServerUrl()); - return $uriServer->getPath(); - } - /** * @inheritDoc */ #[\Override] - protected function validateArguments(string $parameterIn, array $parameters, array $arguments): void + protected function getDefaultVersion(array $data): string { - foreach ($parameters as $parameter) { - if (isset($parameter['$ref'])) { - $paramParts = explode("/", $parameter['$ref']); - if (count($paramParts) != 4 || $paramParts[0] != "#" || $paramParts[1] != self::SWAGGER_COMPONENTS || $paramParts[2] != self::SWAGGER_PARAMETERS) { - throw new InvalidDefinitionException( - "Not get the reference in the expected format #/components/parameters/" - ); - } - if (!isset($this->jsonFile[self::SWAGGER_COMPONENTS][self::SWAGGER_PARAMETERS][$paramParts[3]])) { - throw new DefinitionNotFoundException( - "Not find reference #/components/parameters/$paramParts[3]" - ); - } - $parameter = $this->jsonFile[self::SWAGGER_COMPONENTS][self::SWAGGER_PARAMETERS][$paramParts[3]]; - } - if ($parameter['in'] === $parameterIn && - $parameter['schema']['type'] === "integer" - && filter_var($arguments[$parameter['name']], FILTER_VALIDATE_INT) === false) { - throw new NotMatchedException('Path expected an integer value'); - } - } + return $data['openapi'] ?? '3.0'; } /** - * @param string $name - * @return mixed - * @throws DefinitionNotFoundException - * @throws InvalidDefinitionException + * @inheritDoc */ #[\Override] - public function getDefinition(string $name): mixed + protected function getServerVariableDefault(array $variableDefinition): string { - $nameParts = explode('/', $name); - - if (count($nameParts) < 4 || $nameParts[0] !== '#') { - throw new InvalidDefinitionException('Invalid Component'); - } - - if (!isset($this->jsonFile[$nameParts[1]][$nameParts[2]][$nameParts[3]])) { - throw new DefinitionNotFoundException("Component'$name' not found"); - } - - return $this->jsonFile[$nameParts[1]][$nameParts[2]][$nameParts[3]]; + // OpenAPI 3.0 requires default + return $variableDefinition['default']; } /** @@ -126,11 +47,6 @@ public function getRequestParameters(string $path, string $method): Body return new OpenApiRequestBody($this, "$method $path", $structure['requestBody']); } - public function setServerVariable(string $var, string $value): void - { - $this->serverVariables[$var] = $value; - } - /** * @inheritDoc */ diff --git a/src/OpenApi31/OpenApi31RequestBody.php b/src/OpenApi31/OpenApi31RequestBody.php new file mode 100644 index 0000000..392db06 --- /dev/null +++ b/src/OpenApi31/OpenApi31RequestBody.php @@ -0,0 +1,43 @@ +structure)) { + return true; + } + + if (!isset($this->structure['content'])) { + return true; + } + + $content = null; + if (isset($this->structure['content']['application/json'])) { + $content = $this->structure['content']['application/json']; + } elseif (isset($this->structure['content']['application/xml'])) { + $content = $this->structure['content']['application/xml']; + } elseif (isset($this->structure['content']['text/xml'])) { + $content = $this->structure['content']['text/xml']; + } else { + $contentKey = key($this->structure['content']); + if ($contentKey !== null) { + $content = $this->structure['content'][$contentKey]; + } + } + + if ($content === null || !isset($content['schema'])) { + return true; + } + + return $this->matchSchema($this->name, $content['schema'], $body) ?? false; + } +} diff --git a/src/OpenApi31/OpenApi31ResponseBody.php b/src/OpenApi31/OpenApi31ResponseBody.php new file mode 100644 index 0000000..e83507e --- /dev/null +++ b/src/OpenApi31/OpenApi31ResponseBody.php @@ -0,0 +1,16 @@ +getPathDefinition($path, $method); + + if (!isset($structure['requestBody'])) { + return new OpenApi31RequestBody($this, "$method $path", []); + } + return new OpenApi31RequestBody($this, "$method $path", $structure['requestBody']); + } + + /** + * @inheritDoc + */ + #[\Override] + public function getResponseBody(Schema $schema, string $name, array $structure, bool $allowNullValues = false): Body + { + return new OpenApi31ResponseBody($schema, $name, $structure, $allowNullValues); + } + + // ==================== OpenAPI 3.1 Specific Methods ==================== + + /** + * Check if schema has webhooks + * + * @return bool + */ + public function hasWebhooks(): bool + { + return isset($this->jsonFile['webhooks']) && !empty($this->jsonFile['webhooks']); + } + + /** + * Get all webhook names + * + * @return array + */ + public function getWebhookNames(): array + { + if (!$this->hasWebhooks()) { + return []; + } + return array_keys($this->jsonFile['webhooks']); + } + + /** + * Get webhook definition for OpenAPI 3.1 + * + * @param string $webhookName + * @param string $method + * @return mixed + * @throws DefinitionNotFoundException + */ + public function getWebhookDefinition(string $webhookName, string $method): mixed + { + if (!isset($this->jsonFile['webhooks'][$webhookName][$method])) { + throw new DefinitionNotFoundException("Webhook '$webhookName' with method '$method' not found"); + } + return $this->jsonFile['webhooks'][$webhookName][$method]; + } + + /** + * Get webhook request parameters + * + * @param string $webhookName + * @param string $method + * @return Body + * @throws DefinitionNotFoundException + */ + public function getWebhookRequestParameters(string $webhookName, string $method): Body + { + $structure = $this->getWebhookDefinition($webhookName, $method); + + if (!isset($structure['requestBody'])) { + return new OpenApi31RequestBody($this, "webhook $webhookName $method", []); + } + return new OpenApi31RequestBody($this, "webhook $webhookName $method", $structure['requestBody']); + } + + /** + * Get webhook response parameters + * + * @param string $webhookName + * @param string $method + * @param int $status + * @return Body + * @throws DefinitionNotFoundException + */ + public function getWebhookResponseParameters(string $webhookName, string $method, int $status): Body + { + $structure = $this->getWebhookDefinition($webhookName, $method); + + if (!isset($structure['responses'][$status])) { + throw new DefinitionNotFoundException( + "Response status '$status' not found for webhook '$webhookName' method '$method'" + ); + } + + return $this->getResponseBody( + $this, + "webhook $webhookName $method $status", + $structure['responses'][$status] + ); + } + + /** + * Get the JSON Schema dialect used + * + * @return string|null + */ + public function getSchemaDialect(): ?string + { + return $this->jsonFile['$schema'] ?? null; + } + + /** + * Check if schema uses JSON Schema 2020-12 + * + * @return bool + */ + public function isJsonSchema202012(): bool + { + $dialect = $this->getSchemaDialect(); + return $dialect === null || str_contains($dialect, '2020-12'); + } +} diff --git a/src/Swagger/SwaggerSchema.php b/src/Swagger/SwaggerSchema.php index 30de915..721decf 100644 --- a/src/Swagger/SwaggerSchema.php +++ b/src/Swagger/SwaggerSchema.php @@ -25,6 +25,7 @@ public function __construct(array|string $data, bool $allowNullValues = false) } $this->jsonFile = $data; $this->allowNullValues = $allowNullValues; + $this->specificationVersion = $data['swagger'] ?? '2.0'; } public function getHttpSchema(): string diff --git a/tests/ExpectJsonTest.php b/tests/ExpectJsonTest.php index 35f3385..a65ed1c 100644 --- a/tests/ExpectJsonTest.php +++ b/tests/ExpectJsonTest.php @@ -3,10 +3,13 @@ namespace Tests; use ByJG\ApiTools\Base\Schema; +use ByJG\ApiTools\Exception\NotMatchedException; use ByJG\ApiTools\MockRequester; use ByJG\ApiTools\OpenApiValidation; use ByJG\WebRequest\Psr7\MemoryStream; use ByJG\WebRequest\Psr7\Response; +use Override; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\TestCase; /** @@ -16,7 +19,7 @@ class ExpectJsonTest extends TestCase { use OpenApiValidation; - #[\Override] + #[Override] public function setUp(): void { $schema = Schema::fromFile(__DIR__ . '/rest/openapi.json'); @@ -82,7 +85,7 @@ public function testExpectJsonContainsNestedArraySuccess(): void */ public function testExpectJsonContainsMissingKey(): void { - $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage("Expected JSON response to contain key 'nonexistent'"); $expectedResponse = Response::getInstance(200) @@ -109,7 +112,7 @@ public function testExpectJsonContainsMissingKey(): void */ public function testExpectJsonContainsWrongValue(): void { - $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage("Expected JSON key 'name' to be equal \"Fluffy\""); $expectedResponse = Response::getInstance(200) @@ -210,7 +213,7 @@ public function testExpectJsonPathArrayIndexSuccess(): void */ public function testExpectJsonPathMissing(): void { - $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage("JSONPath 'category.id' not found in response"); $expectedResponse = Response::getInstance(200) @@ -235,7 +238,7 @@ public function testExpectJsonPathMissing(): void */ public function testExpectJsonPathWrongValue(): void { - $this->expectException(\PHPUnit\Framework\AssertionFailedError::class); + $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage("Expected value at JSONPath 'name' to be equal \"Fluffy\""); $expectedResponse = Response::getInstance(200) @@ -267,7 +270,7 @@ public function testExpectJsonPathWrongValue(): void */ public function testExpectJsonContainsInvalidJson(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Body is expected for GET 200 /v2/pet/1"); $expectedResponse = Response::getInstance(200) @@ -290,7 +293,7 @@ public function testExpectJsonContainsInvalidJson(): void */ public function testExpectJsonPathInvalidJson(): void { - $this->expectException(\ByJG\ApiTools\Exception\InvalidRequestException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("The body 'test' cannot be compared with the expected type #/components/schemas/Category"); $expectedResponse = Response::getInstance(200) diff --git a/tests/OpenApi30OneOfNullableTest.php b/tests/OpenApi30OneOfNullableTest.php new file mode 100644 index 0000000..bb51d46 --- /dev/null +++ b/tests/OpenApi30OneOfNullableTest.php @@ -0,0 +1,97 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi30-oneof-nullable.json'); + } + + // --- oneOf + nullable --- + + public function testOneOfNullableAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $this->assertTrue($responseBody->match(['category' => null])); + } + + public function testOneOfNullableAcceptsValidObject(): void + { + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $this->assertTrue($responseBody->match(['category' => ['id' => 1, 'name' => 'Dogs']])); + } + + public function testOneOfNullableAcceptsObjectWithMissingOptionalFields(): void + { + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $this->assertTrue($responseBody->match(['category' => ['id' => 5]])); + } + + public function testOneOfNullableRejectsWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $responseBody->match(['category' => 'not-an-object']); + } + + // --- anyOf + nullable --- + + public function testAnyOfNullableAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $this->assertTrue($responseBody->match(['tag' => null])); + } + + public function testAnyOfNullableAcceptsValidTagObject(): void + { + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $this->assertTrue($responseBody->match(['tag' => ['label' => 'sale']])); + } + + public function testAnyOfNullableAcceptsString(): void + { + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $this->assertTrue($responseBody->match(['tag' => 'plain-string'])); + } + + // --- allOf + nullable --- + + public function testAllOfNullableAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-allof', 'get', 200); + + $this->assertTrue($responseBody->match(['product' => null])); + } + + public function testAllOfNullableAcceptsValidObject(): void + { + $responseBody = $this->schema->getResponseParameters('/test-allof', 'get', 200); + + $this->assertTrue($responseBody->match(['product' => ['sku' => 'ABC-123']])); + } + + public function testAllOfNullableRejectsWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $responseBody = $this->schema->getResponseParameters('/test-allof', 'get', 200); + + $responseBody->match(['product' => 'not-an-object']); + } +} \ No newline at end of file diff --git a/tests/OpenApi31AnyOfNullTest.php b/tests/OpenApi31AnyOfNullTest.php new file mode 100644 index 0000000..1518698 --- /dev/null +++ b/tests/OpenApi31AnyOfNullTest.php @@ -0,0 +1,85 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31-anyof-null.json'); + } + + // --- anyOf with { type: null } --- + + public function testAnyOfWithNullTypeAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $this->assertTrue($responseBody->match(['category' => null])); + } + + public function testAnyOfWithNullTypeAcceptsValidObject(): void + { + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $this->assertTrue($responseBody->match(['category' => ['id' => 1, 'name' => 'Dogs']])); + } + + public function testAnyOfWithNullTypeAcceptsObjectWithMissingOptionalFields(): void + { + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $this->assertTrue($responseBody->match(['category' => ['id' => 42]])); + } + + public function testAnyOfWithNullTypeRejectsWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $responseBody = $this->schema->getResponseParameters('/test-anyof', 'get', 200); + + $responseBody->match(['category' => 'not-an-object']); + } + + // --- oneOf with { type: null } --- + + public function testOneOfWithNullTypeAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $this->assertTrue($responseBody->match(['tag' => null])); + } + + public function testOneOfWithNullTypeAcceptsValidObject(): void + { + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $this->assertTrue($responseBody->match(['tag' => ['label' => 'sale']])); + } + + public function testOneOfWithNullTypeAcceptsObjectWithMissingOptionalFields(): void + { + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $this->assertTrue($responseBody->match(['tag' => []])); + } + + public function testOneOfWithNullTypeRejectsWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $responseBody = $this->schema->getResponseParameters('/test-oneof', 'get', 200); + + $responseBody->match(['tag' => 12345]); + } +} \ No newline at end of file diff --git a/tests/OpenApi31ConditionalTest.php b/tests/OpenApi31ConditionalTest.php new file mode 100644 index 0000000..8377b85 --- /dev/null +++ b/tests/OpenApi31ConditionalTest.php @@ -0,0 +1,122 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31-conditional.json'); + } + + public function testUSPostalCodePattern(): void + { + $requestBody = $this->schema->getRequestParameters('/shipping', 'post'); + + $validBody = [ + 'country' => 'US', + 'postalCode' => '12345' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testUSPostalCodeWithExtension(): void + { + $requestBody = $this->schema->getRequestParameters('/shipping', 'post'); + + $validBody = [ + 'country' => 'US', + 'postalCode' => '12345-6789' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testUSPostalCodeInvalidFormat(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/shipping', 'post'); + + $invalidBody = [ + 'country' => 'US', + 'postalCode' => 'ABC123' // Invalid for US + ]; + + $requestBody->match($invalidBody); + } + + public function testNonUSPostalCode(): void + { + $requestBody = $this->schema->getRequestParameters('/shipping', 'post'); + + $validBody = [ + 'country' => 'CA', + 'postalCode' => 'K1A 0B1' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testNonUSPostalCodeWithDifferentFormat(): void + { + $requestBody = $this->schema->getRequestParameters('/shipping', 'post'); + + $validBody = [ + 'country' => 'UK', + 'postalCode' => 'SW1A 1AA' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testConditionalWithRequiredField(): void + { + $requestBody = $this->schema->getRequestParameters('/product', 'post'); + + $validBody = [ + 'productType' => 'subscription', + 'price' => 9.99, + 'subscription' => [ + 'interval' => 'monthly' + ] + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testConditionalWithRequiredFieldMissing(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/product', 'post'); + + $invalidBody = [ + 'productType' => 'subscription', + 'price' => 9.99 + // Missing required 'subscription' field when productType is 'subscription' + ]; + + $requestBody->match($invalidBody); + } + + public function testConditionalNotMetNoRequirement(): void + { + $requestBody = $this->schema->getRequestParameters('/product', 'post'); + + $validBody = [ + 'productType' => 'one-time', + 'price' => 49.99 + // subscription field not required when productType is not 'subscription' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } +} diff --git a/tests/OpenApi31ConstTest.php b/tests/OpenApi31ConstTest.php new file mode 100644 index 0000000..13752e1 --- /dev/null +++ b/tests/OpenApi31ConstTest.php @@ -0,0 +1,191 @@ + '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'const' => 'success' + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $responseBody = $schema->getResponseParameters('/test', 'get', 200); + + $validBody = ['status' => 'success']; + $this->assertTrue($responseBody->match($validBody)); + } + + public function testConstValueRejectsWrongValue(): void + { + $this->expectException(NotMatchedException::class); + $this->expectExceptionMessage('does not match const value'); + + $data = [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'const' => 'success' + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $responseBody = $schema->getResponseParameters('/test', 'get', 200); + + $invalidBody = ['status' => 'failure']; // Should fail + $responseBody->match($invalidBody); + } + + public function testConstNumberValue(): void + { + $data = [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'version' => [ + 'const' => 1 + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $responseBody = $schema->getResponseParameters('/test', 'get', 200); + + $validBody = ['version' => 1]; + $this->assertTrue($responseBody->match($validBody)); + } + + public function testConstBooleanValue(): void + { + $data = [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'active' => [ + 'const' => true + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $responseBody = $schema->getResponseParameters('/test', 'get', 200); + + $validBody = ['active' => true]; + $this->assertTrue($responseBody->match($validBody)); + } + + public function testConstInWebhook(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + $requestBody = $schema->getWebhookRequestParameters('newUser', 'post'); + + $validBody = [ + 'userId' => 123, + 'event' => 'user.created', // const value + 'timestamp' => '2024-01-01T00:00:00Z' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testConstInWebhookRejectsWrongValue(): void + { + $this->expectException(NotMatchedException::class); + + $schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + $requestBody = $schema->getWebhookRequestParameters('newUser', 'post'); + + $invalidBody = [ + 'userId' => 123, + 'event' => 'user.updated', // Wrong const value + 'timestamp' => '2024-01-01T00:00:00Z' + ]; + + $requestBody->match($invalidBody); + } +} diff --git a/tests/OpenApi31NestedRefRequiredTest.php b/tests/OpenApi31NestedRefRequiredTest.php new file mode 100644 index 0000000..149f5bb --- /dev/null +++ b/tests/OpenApi31NestedRefRequiredTest.php @@ -0,0 +1,136 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31-nested-ref-required.json'); + } + + public function testNestedRefPropertiesWithAllFields(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => [ + 'email' => 'john.doe@example.com', + 'phone' => '+1234567890', + 'firstName' => 'John', + 'middleName' => 'Q', + 'lastName' => 'Doe' + ] + ]; + + $this->assertTrue($requestBody->match($body)); + } + + public function testNestedRefPropertiesWithRequiredFieldOnly(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => [ + 'phone' => '+1234567890' + ] + ]; + + $this->assertTrue($requestBody->match($body)); + } + + public function testNestedRefPropertiesMissingRequiredField(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => [ + 'email' => 'john.doe@example.com', + 'firstName' => 'John', + 'lastName' => 'Doe' + // Missing required 'phone' field + ] + ]; + + $requestBody->match($body); + } + + public function testNullableManagerAcceptsNull(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => null + ]; + + $this->assertTrue($requestBody->match($body)); + } + + public function testNullableManagerCanBeOmitted(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp' + ]; + + $this->assertTrue($requestBody->match($body)); + } + + public function testNestedRefEmailValidation(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => [ + 'phone' => '+1234567890', + 'email' => 'valid@example.com' + ] + ]; + + $this->assertTrue($requestBody->match($body)); + } + + public function testNestedRefPhoneValidation(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => [ + 'phone' => '+12345678901234' + ] + ]; + + $this->assertTrue($requestBody->match($body)); + } + + public function testNestedRefWithPartialFields(): void + { + $requestBody = $this->schema->getRequestParameters('/organization', 'post'); + + $body = [ + 'name' => 'ACME Corp', + 'manager' => [ + 'phone' => '+1234567890', + 'firstName' => 'John', + 'lastName' => 'Doe' + ] + ]; + + $this->assertTrue($requestBody->match($body)); + } +} \ No newline at end of file diff --git a/tests/OpenApi31NullableTest.php b/tests/OpenApi31NullableTest.php new file mode 100644 index 0000000..7f78288 --- /dev/null +++ b/tests/OpenApi31NullableTest.php @@ -0,0 +1,146 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31-nullable.json'); + } + + public function testNullableStringAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableString' => null, + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testNullableStringAcceptsString(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableString' => 'value', + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testRegularStringRejectsNull(): void + { + $this->expectException(NotMatchedException::class); + // Error message may vary based on nullable handling, just check exception is thrown + + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableString' => 'value', + 'regularString' => null // Should fail + ]; + + $responseBody->match($body); + } + + public function testNullableNumberAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableNumber' => null, + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testNullableNumberAcceptsNumber(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableNumber' => 42, + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testNullableObjectAcceptsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableObject' => null, + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testNullableObjectAcceptsObject(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableObject' => ['prop' => 'value'], + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testMixedNullableFields(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableString' => null, + 'nullableNumber' => 42, + 'nullableObject' => null, + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testAllFieldsNull(): void + { + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableString' => null, + 'nullableNumber' => null, + 'nullableObject' => null, + 'regularString' => 'test' + ]; + + $this->assertTrue($responseBody->match($body)); + } + + public function testNullableStringWithWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $responseBody = $this->schema->getResponseParameters('/test-nullable', 'get', 200); + + $body = [ + 'nullableString' => 123, // Should be string or null, not number + 'regularString' => 'test' + ]; + + $responseBody->match($body); + } +} diff --git a/tests/OpenApi31RefSiblingTest.php b/tests/OpenApi31RefSiblingTest.php new file mode 100644 index 0000000..424d549 --- /dev/null +++ b/tests/OpenApi31RefSiblingTest.php @@ -0,0 +1,147 @@ + '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/User', + 'description' => 'User object with extra description' + ] + ] + ] + ] + ] + ] + ] + ], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'] + ] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $responseBody = $schema->getResponseParameters('/test', 'get', 200); + + $validBody = ['id' => 1, 'name' => 'John']; + + $this->assertTrue($responseBody->match($validBody)); + } + + public function testRefWithSiblingProperties(): void + { + $data = [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/BaseUser', + 'title' => 'Extended User' + ] + ] + ] + ], + 'responses' => [ + '201' => [ + 'description' => 'Created' + ] + ] + ] + ] + ], + 'components' => [ + 'schemas' => [ + 'BaseUser' => [ + 'type' => 'object', + 'properties' => [ + 'username' => ['type' => 'string'], + 'email' => ['type' => 'string'] + ], + 'required' => ['username'] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $requestBody = $schema->getRequestParameters('/test', 'post'); + + $validBody = [ + 'username' => 'johndoe', + 'email' => 'john@example.com' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testOpenApi30DoesNotSupportSiblingKeywords(): void + { + // OpenAPI 3.0 should still work with traditional $ref (no siblings) + $data = [ + 'openapi' => '3.0.3', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/User' + ] + ] + ] + ] + ] + ] + ] + ], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'] + ] + ] + ] + ] + ]; + + $schema = Schema::fromArray($data); + $responseBody = $schema->getResponseParameters('/test', 'get', 200); + + $this->assertTrue($responseBody->match(['id' => 1])); + } +} diff --git a/tests/OpenApi31RequestBodyTest.php b/tests/OpenApi31RequestBodyTest.php new file mode 100644 index 0000000..f748f41 --- /dev/null +++ b/tests/OpenApi31RequestBodyTest.php @@ -0,0 +1,75 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + } + + public function testRequestBodyMatchValid(): void + { + $requestBody = $this->schema->getRequestParameters('/users', 'post'); + + $validBody = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + // Note: Nullable type array tests will be added in Phase 2 + // public function testRequestBodyMatchNullableEmail(): void + + public function testRequestBodyMatchMissingOptionalField(): void + { + $requestBody = $this->schema->getRequestParameters('/users', 'post'); + + $validBody = [ + 'id' => 1, + 'name' => 'John Doe' + // email is optional, so it can be omitted + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testRequestBodyMatchMissingRequiredField(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/users', 'post'); + + $invalidBody = [ + 'id' => 1 + // Missing required 'name' field + ]; + + $requestBody->match($invalidBody); + } + + public function testRequestBodyMatchInvalidType(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/users', 'post'); + + $invalidBody = [ + 'id' => 'not-a-number', // Should be integer + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]; + + $requestBody->match($invalidBody); + } +} diff --git a/tests/OpenApi31ResponseBodyTest.php b/tests/OpenApi31ResponseBodyTest.php new file mode 100644 index 0000000..00385fc --- /dev/null +++ b/tests/OpenApi31ResponseBodyTest.php @@ -0,0 +1,74 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + } + + public function testResponseBodyMatchValid(): void + { + $responseBody = $this->schema->getResponseParameters('/users', 'get', 200); + + $validBody = [ + 'users' => [ + ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'] + ] + ]; + + $this->assertTrue($responseBody->match($validBody)); + } + + // Note: Nullable type array tests will be added in Phase 2 + // public function testResponseBodyMatchWithNullEmail(): void + + public function testResponseBodyMatchEmptyArray(): void + { + $responseBody = $this->schema->getResponseParameters('/users', 'get', 200); + + $validBody = [ + 'users' => [] // Empty array is valid + ]; + + $this->assertTrue($responseBody->match($validBody)); + } + + public function testResponseBodyMatchInvalidStructure(): void + { + $this->expectException(NotMatchedException::class); + + $responseBody = $this->schema->getResponseParameters('/users', 'get', 200); + + $invalidBody = [ + 'users' => [ + ['id' => 1, 'name' => 'John'], // Missing required fields is OK if not in required array + ['id' => 'invalid', 'name' => 'Jane'] // Invalid type for id + ] + ]; + + $responseBody->match($invalidBody); + } + + public function testCreatedResponse(): void + { + $responseBody = $this->schema->getResponseParameters('/users', 'post', 201); + + $validBody = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]; + + $this->assertTrue($responseBody->match($validBody)); + } +} diff --git a/tests/OpenApi31SchemaTest.php b/tests/OpenApi31SchemaTest.php new file mode 100644 index 0000000..94a4d14 --- /dev/null +++ b/tests/OpenApi31SchemaTest.php @@ -0,0 +1,152 @@ +assertInstanceOf(OpenApi31Schema::class, $schema); + $this->assertEquals('3.1.0', $schema->getSpecificationVersion()); + } + + public function testFromFileCreatesOpenApi31Schema(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + + $this->assertInstanceOf(OpenApi31Schema::class, $schema); + } + + public function testFromArrayCreatesOpenApi31Schema(): void + { + $data = [ + 'openapi' => '3.1.0', + 'info' => [ + 'title' => 'Test', + 'version' => '1.0.0' + ], + 'paths' => [] + ]; + + $schema = Schema::fromArray($data); + + $this->assertInstanceOf(OpenApi31Schema::class, $schema); + } + + public function testVersionDetection30vs31(): void + { + $data30 = [ + 'openapi' => '3.0.3', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [] + ]; + + $data31 = [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [] + ]; + + $schema30 = Schema::fromArray($data30); + $schema31 = Schema::fromArray($data31); + + $this->assertInstanceOf(\ByJG\ApiTools\OpenApi\OpenApiSchema::class, $schema30); + $this->assertInstanceOf(OpenApi31Schema::class, $schema31); + } + + public function testServerVariablesWithOptionalDefault(): void + { + $data = [ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'servers' => [ + [ + 'url' => 'http://{host}/api', + 'variables' => [ + 'host' => [ + 'enum' => ['localhost', 'example.com'] + // Note: no 'default' key - this is valid in 3.1 + ] + ] + ] + ], + 'paths' => [] + ]; + + $schema = Schema::fromArray($data); + + // Should not throw exception even without default + $url = $schema->getServerUrl(); + $this->assertIsString($url); + } + + public function testHasWebhooks(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + + $this->assertInstanceOf(OpenApi31Schema::class, $schema); + $this->assertTrue($schema->hasWebhooks()); + } + + public function testGetWebhookNames(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + + $webhookNames = $schema->getWebhookNames(); + + $this->assertCount(2, $webhookNames); + $this->assertContains('newUser', $webhookNames); + $this->assertContains('orderUpdated', $webhookNames); + } + + public function testGetWebhookDefinition(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + + $webhookDef = $schema->getWebhookDefinition('newUser', 'post'); + + $this->assertIsArray($webhookDef); + $this->assertEquals('New user webhook', $webhookDef['summary']); + } + + public function testGetWebhookDefinitionThrowsExceptionForNonExistent(): void + { + $this->expectException(\ByJG\ApiTools\Exception\DefinitionNotFoundException::class); + + $schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + $schema->getWebhookDefinition('nonExistent', 'post'); + } + + public function testSchemaDialect(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + + $this->assertInstanceOf(OpenApi31Schema::class, $schema); + $this->assertTrue($schema->isJsonSchema202012()); + } + + public function testGetServerUrl(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + + $serverUrl = $schema->getServerUrl(); + + $this->assertEquals('http://localhost:8080/api', $serverUrl); + } + + public function testGetBasePath(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + + $basePath = $schema->getBasePath(); + + $this->assertEquals('/api', $basePath); + } +} diff --git a/tests/OpenApi31TupleTest.php b/tests/OpenApi31TupleTest.php new file mode 100644 index 0000000..e931f9f --- /dev/null +++ b/tests/OpenApi31TupleTest.php @@ -0,0 +1,139 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31-tuples.json'); + } + + public function testValidCoordinateTuple(): void + { + $requestBody = $this->schema->getRequestParameters('/coordinates', 'post'); + + $validBody = [ + 'location' => [40.7128, -74.0060] // Latitude, Longitude + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testCoordinateTupleWithWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/coordinates', 'post'); + + $invalidBody = [ + 'location' => ['invalid', -74.0060] // First element is non-numeric string + ]; + + $requestBody->match($invalidBody); + } + + public function testCoordinateTupleTooFewItems(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/coordinates', 'post'); + + $invalidBody = [ + 'location' => [40.7128] // Missing longitude + ]; + + $requestBody->match($invalidBody); + } + + public function testCoordinateTupleTooManyItems(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/coordinates', 'post'); + + $invalidBody = [ + 'location' => [40.7128, -74.0060, 100] // Extra element + ]; + + $requestBody->match($invalidBody); + } + + public function testValidNameTuple(): void + { + $requestBody = $this->schema->getRequestParameters('/person', 'post'); + + $validBody = [ + 'name' => ['John', 'Doe'] + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testNameTupleWithMiddleName(): void + { + $requestBody = $this->schema->getRequestParameters('/person', 'post'); + + $validBody = [ + 'name' => ['John', 'Michael', 'Doe'] // maxItems is 3 + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testNameTupleTooManyItems(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/person', 'post'); + + $invalidBody = [ + 'name' => ['John', 'Michael', 'James', 'Doe'] // Too many names + ]; + + $requestBody->match($invalidBody); + } + + public function testValidRGBTuple(): void + { + $requestBody = $this->schema->getRequestParameters('/rgb', 'post'); + + $validBody = [ + 'color' => [255, 128, 64] // RGB values + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testRGBTupleWithWrongType(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/rgb', 'post'); + + $invalidBody = [ + 'color' => [255, 'invalid', 64] // Second value is non-numeric string + ]; + + $requestBody->match($invalidBody); + } + + public function testRGBTupleTooFewItems(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getRequestParameters('/rgb', 'post'); + + $invalidBody = [ + 'color' => [255, 128] // Missing blue + ]; + + $requestBody->match($invalidBody); + } +} diff --git a/tests/OpenApi31WebhooksTest.php b/tests/OpenApi31WebhooksTest.php new file mode 100644 index 0000000..b0fe968 --- /dev/null +++ b/tests/OpenApi31WebhooksTest.php @@ -0,0 +1,146 @@ +schema = Schema::fromFile(__DIR__ . '/example/openapi31-webhooks.json'); + } + + public function testHasWebhooks(): void + { + $this->assertTrue($this->schema->hasWebhooks()); + } + + public function testGetWebhookNames(): void + { + $names = $this->schema->getWebhookNames(); + + $this->assertCount(2, $names); + $this->assertContains('newUser', $names); + $this->assertContains('orderUpdated', $names); + } + + public function testGetWebhookDefinition(): void + { + $definition = $this->schema->getWebhookDefinition('newUser', 'post'); + + $this->assertIsArray($definition); + $this->assertEquals('New user webhook', $definition['summary']); + } + + public function testWebhookRequestBodyValidation(): void + { + $requestBody = $this->schema->getWebhookRequestParameters('newUser', 'post'); + + $validBody = [ + 'userId' => 123, + 'event' => 'user.created', + 'timestamp' => '2024-01-01T00:00:00Z' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testWebhookRequestBodyConstValidation(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getWebhookRequestParameters('newUser', 'post'); + + $invalidBody = [ + 'userId' => 123, + 'event' => 'wrong.event', // Should be 'user.created' + 'timestamp' => '2024-01-01T00:00:00Z' + ]; + + $requestBody->match($invalidBody); + } + + public function testWebhookRequestBodyMissingRequired(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getWebhookRequestParameters('newUser', 'post'); + + $invalidBody = [ + 'userId' => 123 + // Missing required 'event' field + ]; + + $requestBody->match($invalidBody); + } + + public function testWebhookWithReference(): void + { + $requestBody = $this->schema->getWebhookRequestParameters('orderUpdated', 'post'); + + $validBody = [ + 'orderId' => 456, + 'status' => 'completed' + ]; + + $this->assertTrue($requestBody->match($validBody)); + } + + public function testWebhookWithInvalidEnumValue(): void + { + $this->expectException(NotMatchedException::class); + + $requestBody = $this->schema->getWebhookRequestParameters('orderUpdated', 'post'); + + $invalidBody = [ + 'orderId' => 456, + 'status' => 'invalid-status' // Not in enum + ]; + + $requestBody->match($invalidBody); + } + + public function testWebhookResponseValidation(): void + { + $responseBody = $this->schema->getWebhookResponseParameters('newUser', 'post', 200); + + // 200 response has no body defined, so empty/null body should be valid + $this->assertTrue($responseBody->match(null)); + } + + public function testNonExistentWebhookThrowsException(): void + { + $this->expectException(DefinitionNotFoundException::class); + + $this->schema->getWebhookDefinition('nonExistent', 'post'); + } + + public function testSchemaWithoutWebhooks(): void + { + $schema = Schema::fromFile(__DIR__ . '/example/openapi31.json'); + + $this->assertFalse($schema->hasWebhooks()); + $this->assertEmpty($schema->getWebhookNames()); + } + + public function testWebhookWithAllValidStatuses(): void + { + $requestBody = $this->schema->getWebhookRequestParameters('orderUpdated', 'post'); + + $statuses = ['pending', 'processing', 'completed', 'cancelled']; + + foreach ($statuses as $status) { + $validBody = [ + 'orderId' => 123, + 'status' => $status + ]; + $this->assertTrue($requestBody->match($validBody)); + } + } +} diff --git a/tests/OpenApiResponseBodyTest.php b/tests/OpenApiResponseBodyTest.php index fed53bf..28cf60f 100644 --- a/tests/OpenApiResponseBodyTest.php +++ b/tests/OpenApiResponseBodyTest.php @@ -100,7 +100,7 @@ public function testMatchResponseBodyWithRefInsteadOfContent(): void */ public function testMatchResponseBodyEnumError(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Value 'notfound' in 'status' not matched in ENUM"); $body = [ @@ -128,7 +128,7 @@ public function testMatchResponseBodyEnumError(): void */ public function testMatchResponseBodyWrongNumber(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Expected 'id' to be numeric, but found 'ABC'"); $body = [ @@ -156,7 +156,7 @@ public function testMatchResponseBodyWrongNumber(): void */ public function testMatchResponseBodyMoreThanExpected(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("The property(ies) 'more' has not defined in '#/components/schemas/Order'"); $body = [ @@ -234,7 +234,7 @@ public function testMatchResponseBodyAllowNullValues(): void */ public function testMatchResponseBodyNotAllowNullValues(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Value of property 'complete' is null, but should be of type 'boolean'"); $body = [ @@ -277,7 +277,7 @@ public function testMatchResponseBodyEmpty(): void */ public function testMatchResponseBodyNotEmpty(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Expected empty body for"); $body = ['suppose'=>'not here']; @@ -391,7 +391,7 @@ public function testAdditionalPropertiesInObjectInResponseBody(): void public function testAdditionalPropertiesInObjectInResponseBodyDoNotMatch(): void { $this->expectExceptionMessage("Expected 'value2' to be numeric, but found 'string'"); - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $body = ['value1' => 1, 'value2' => 'string']; $responseParameter = self::openApiSchema5()->getResponseParameters('/tests/additional_properties', 'get', 200); $this->assertTrue($responseParameter->match($body)); @@ -445,7 +445,7 @@ public function testIssue9(): void */ public function testIssue9Error(): void { - $this->expectException(InvalidRequestException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("The body 'fr' cannot be compared with the expected type #/components/schemas/LanguageData_inner"); $body = [ @@ -582,7 +582,7 @@ public function testResponseDefault(): void */ public function testResponseWithNoDefault(): void { - $this->expectException(\ByJG\ApiTools\Exception\InvalidDefinitionException::class); + $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage("Could not found status code '503'"); $body = []; diff --git a/tests/SwaggerResponseBodyTest.php b/tests/SwaggerResponseBodyTest.php index d6520c0..1acdb98 100644 --- a/tests/SwaggerResponseBodyTest.php +++ b/tests/SwaggerResponseBodyTest.php @@ -74,7 +74,7 @@ public function testMatchResponseBody(): void */ public function testMatchResponseBodyEnumError(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Value 'notfound' in 'status' not matched in ENUM"); $body = [ @@ -101,7 +101,7 @@ public function testMatchResponseBodyEnumError(): void */ public function testMatchResponseBodyWrongNumber(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Expected 'id' to be numeric, but found 'ABC'"); $body = [ @@ -128,7 +128,7 @@ public function testMatchResponseBodyWrongNumber(): void */ public function testMatchResponseBodyMoreThanExpected(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("The property(ies) 'more' has not defined in '#/definitions/Order'"); $body = [ @@ -203,7 +203,7 @@ public function testMatchResponseBodyAllowNullValues(): void */ public function testMatchResponseBodyNotAllowNullValues(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Value of property 'complete' is null, but should be of type 'boolean'"); $body = [ @@ -244,7 +244,7 @@ public function testMatchResponseBodyEmpty(): void */ public function testMatchResponseBodyNotEmpty(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("Expected empty body for"); $body = ['suppose'=>'not here']; @@ -338,7 +338,7 @@ public function testMatchResponseBodyWhenValueWithNestedPropertiesIsNullAndNulls */ public function testNotMatchResponseBodyWhenValueWithPatterns(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage(<<<'EOL' Value '18' in 'age' is not string. -> { @@ -391,7 +391,7 @@ public function testMatchResponseBodyWhenValueWithPatterns(): void */ public function testMatchResponseBodyWhenValueWithStringPatternError(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage(<<<'EOL' Value '20100-05-11' in 'date' not matched in pattern. -> { @@ -424,7 +424,7 @@ public function testMatchResponseBodyWhenValueWithStringPatternError(): void */ public function testMatchResponseBodyWhenValueWithNumberPatternError(): void { - $this->expectException(\ByJG\ApiTools\Exception\NotMatchedException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage(<<<'EOL' Value '9999' in 'age' not matched in pattern. -> { @@ -492,7 +492,7 @@ public function testIssue9(): void */ public function testIssue9Error(): void { - $this->expectException(InvalidRequestException::class); + $this->expectException(NotMatchedException::class); $this->expectExceptionMessage("The body 'fr' cannot be compared with the expected type #/definitions/LanguageData_inner"); $body = @@ -569,7 +569,7 @@ public function testResponseDefault(): void */ public function testResponseWithNoDefault(): void { - $this->expectException(\ByJG\ApiTools\Exception\InvalidDefinitionException::class); + $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage("Could not found status code '503'"); $body = []; diff --git a/tests/example/openapi30-oneof-nullable.json b/tests/example/openapi30-oneof-nullable.json new file mode 100644 index 0000000..c8efeae --- /dev/null +++ b/tests/example/openapi30-oneof-nullable.json @@ -0,0 +1,121 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "OpenAPI 3.0 oneOf/anyOf with nullable Test", + "version": "1.0.0" + }, + "paths": { + "/test-oneof": { + "get": { + "responses": { + "200": { + "description": "Test oneOf with nullable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "oneOf": [ + { + "$ref": "#/components/schemas/Category" + } + ], + "nullable": true + } + } + } + } + } + } + } + } + }, + "/test-anyof": { + "get": { + "responses": { + "200": { + "description": "Test anyOf with nullable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tag" + }, + { + "type": "string" + } + ], + "nullable": true + } + } + } + } + } + } + } + } + }, + "/test-allof": { + "get": { + "responses": { + "200": { + "description": "Test allOf with nullable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "product": { + "allOf": [ + { + "$ref": "#/components/schemas/Product" + } + ], + "nullable": true + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + } + }, + "Product": { + "type": "object", + "properties": { + "sku": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/example/openapi31-anyof-null.json b/tests/example/openapi31-anyof-null.json new file mode 100644 index 0000000..b1179b0 --- /dev/null +++ b/tests/example/openapi31-anyof-null.json @@ -0,0 +1,88 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1 anyOf with null type Test", + "version": "1.0.0" + }, + "paths": { + "/test-anyof": { + "get": { + "responses": { + "200": { + "description": "Test anyOf with null type", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category" + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + } + } + }, + "/test-oneof": { + "get": { + "responses": { + "200": { + "description": "Test oneOf with null type", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag": { + "oneOf": [ + { + "$ref": "#/components/schemas/Tag" + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/example/openapi31-conditional.json b/tests/example/openapi31-conditional.json new file mode 100644 index 0000000..c04b93e --- /dev/null +++ b/tests/example/openapi31-conditional.json @@ -0,0 +1,102 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Conditional Schema Test", + "version": "1.0.0" + }, + "paths": { + "/shipping": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "if": { + "properties": { + "country": { + "const": "US" + } + } + }, + "then": { + "properties": { + "postalCode": { + "pattern": "^[0-9]{5}(-[0-9]{4})?$" + } + } + }, + "else": { + "properties": { + "postalCode": { + "pattern": "^[A-Z0-9 -]+$" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/product": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "productType": { + "type": "string" + }, + "price": { + "type": "number" + }, + "subscription": { + "type": "object", + "properties": { + "interval": { + "type": "string" + } + } + } + }, + "if": { + "properties": { + "productType": { + "const": "subscription" + } + } + }, + "then": { + "required": [ + "subscription" + ] + } + } + } + } + }, + "responses": { + "201": { + "description": "Created" + } + } + } + } + } +} diff --git a/tests/example/openapi31-nested-ref-required.json b/tests/example/openapi31-nested-ref-required.json new file mode 100644 index 0000000..d6c1fa2 --- /dev/null +++ b/tests/example/openapi31-nested-ref-required.json @@ -0,0 +1,94 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1 Nested Ref with Required Test", + "version": "1.0.0" + }, + "paths": { + "/organization": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "manager": { + "type": [ + "object", + "null" + ], + "description": "Organization Profile Manager", + "required": [ + "phone" + ], + "properties": { + "email": { + "$ref": "#/components/schemas/emailProperty" + }, + "phone": { + "$ref": "#/components/schemas/phoneNumberProperty" + }, + "lastName": { + "$ref": "#/components/schemas/lastNameProperty" + }, + "firstName": { + "$ref": "#/components/schemas/firstNameProperty" + }, + "middleName": { + "$ref": "#/components/schemas/middleNameProperty" + } + } + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Organization created" + } + } + } + } + }, + "components": { + "schemas": { + "emailProperty": { + "type": "string", + "format": "email", + "description": "Email address" + }, + "phoneNumberProperty": { + "type": "string", + "pattern": "^\\+?[1-9]\\d{1,14}$", + "description": "Phone number in E.164 format" + }, + "firstNameProperty": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "First name" + }, + "middleNameProperty": { + "type": "string", + "maxLength": 50, + "description": "Middle name" + }, + "lastNameProperty": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "Last name" + } + } + } +} \ No newline at end of file diff --git a/tests/example/openapi31-nullable.json b/tests/example/openapi31-nullable.json new file mode 100644 index 0000000..63827e3 --- /dev/null +++ b/tests/example/openapi31-nullable.json @@ -0,0 +1,53 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1 Nullable Test", + "version": "1.0.0" + }, + "paths": { + "/test-nullable": { + "get": { + "responses": { + "200": { + "description": "Test nullable types", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nullableString": { + "type": [ + "string", + "null" + ] + }, + "nullableNumber": { + "type": [ + "number", + "null" + ] + }, + "nullableObject": { + "type": [ + "object", + "null" + ], + "properties": { + "prop": { + "type": "string" + } + } + }, + "regularString": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} diff --git a/tests/example/openapi31-tuples.json b/tests/example/openapi31-tuples.json new file mode 100644 index 0000000..69d98e5 --- /dev/null +++ b/tests/example/openapi31-tuples.json @@ -0,0 +1,118 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Tuple Validation Test", + "version": "1.0.0" + }, + "paths": { + "/coordinates": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "description": "latitude" + }, + { + "type": "number", + "description": "longitude" + } + ], + "minItems": 2, + "maxItems": 2 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/person": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "description": "first name" + }, + { + "type": "string", + "description": "last name" + } + ], + "minItems": 2, + "maxItems": 3 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/rgb": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "color": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "description": "red" + }, + { + "type": "integer", + "description": "green" + }, + { + "type": "integer", + "description": "blue" + } + ], + "minItems": 3, + "maxItems": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/tests/example/openapi31-webhooks.json b/tests/example/openapi31-webhooks.json new file mode 100644 index 0000000..abcc35e --- /dev/null +++ b/tests/example/openapi31-webhooks.json @@ -0,0 +1,99 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1 Webhooks Test", + "version": "1.0.0" + }, + "paths": { + "/subscribe": { + "post": { + "summary": "Subscribe to webhooks", + "responses": { + "201": { + "description": "Subscription created" + } + } + } + } + }, + "webhooks": { + "newUser": { + "post": { + "summary": "New user webhook", + "description": "Called when a new user is created", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "integer" + }, + "event": { + "type": "string", + "const": "user.created" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "userId", + "event" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Webhook received successfully" + } + } + } + }, + "orderUpdated": { + "post": { + "summary": "Order updated webhook", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderEvent" + } + } + } + }, + "responses": { + "200": { + "description": "Webhook received" + } + } + } + } + }, + "components": { + "schemas": { + "OrderEvent": { + "type": "object", + "properties": { + "orderId": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "processing", + "completed", + "cancelled" + ] + } + } + } + } + } +} diff --git a/tests/example/openapi31.json b/tests/example/openapi31.json new file mode 100644 index 0000000..74390cf --- /dev/null +++ b/tests/example/openapi31.json @@ -0,0 +1,99 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1 Test Schema", + "description": "Test schema for OpenAPI 3.1 features", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:8080/api", + "variables": { + "environment": { + "enum": [ + "dev", + "staging", + "prod" + ] + } + } + } + ], + "paths": { + "/users": { + "get": { + "summary": "Get users", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name" + ] + } + } + } +}