diff --git a/README.md b/README.md index 7e92f9a..c6ee2b9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A modern, fluent builder for [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) - Schemas via `cortexphp/json-schema` — no parallel schema DSL to learn - Vendor extensions (`x-*`) and `$ref` on every object - JSON and YAML output (YAML via optional `symfony/yaml`) -- Meta-schema validation proxied through the existing `cortex/json-schema` pipeline +- Meta-schema validation against the official OpenAPI 3.1 schemas (bundled, offline) ## Requirements @@ -44,7 +44,7 @@ $userSchema = Schema::object('User')->properties( ); $openApi = OpenApi::create() - ->info(Info::create()->title('Example API')->version('1.0.0')) + ->info(Info::create('Example API', '1.0.0')) ->tags(Tag::create('Users')->description('User endpoints')) ->components(Components::create()->schema('User', $userSchema)) ->paths( diff --git a/docs/docs.json b/docs/docs.json index d356e6d..a83100c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -43,7 +43,8 @@ "openapi/responses", "openapi/components", "openapi/security", - "openapi/webhooks-and-callbacks" + "openapi/webhooks-and-callbacks", + "openapi/validation-and-output" ] } ] diff --git a/docs/openapi/components.mdx b/docs/openapi/components.mdx index 7359b5a..317a8c5 100644 --- a/docs/openapi/components.mdx +++ b/docs/openapi/components.mdx @@ -37,7 +37,7 @@ Components supports ten registries, each addressable by its own `$ref` path: ## Schemas -Register any `cortexphp/json-schema` schema or raw array by name: +Register any `cortexphp/json-schema` schema or raw array by name. The component key (`'User'`) is what `$ref` pointers use, so there is no need to name the schema itself — see [how schemas are embedded](/openapi/introduction). ```php use Cortex\JsonSchema\Schema; @@ -72,9 +72,9 @@ MediaType::json(Reference::schema('User')) // In a Parameter schema slot Parameter::query('filter', Reference::schema('Filter')) -// In a composed schema (allOf/oneOf/anyOf) +// Composing inside a schema — use the JSON Schema builder's own ref() Schema::object()->allOf( - Reference::schema('BaseEntity'), + Schema::typeless()->ref('#/components/schemas/BaseEntity'), Schema::object()->properties(Schema::string('title')), ) ``` @@ -153,14 +153,14 @@ $components = Components::create() ); ``` -Reference them from operations using `Response::ref()`: +Reference them from operations with `->ref()`, which keeps the status code from the named constructor: ```php Operation::get() ->responses( - Response::ok()->content(MediaType::json(Reference::schema('User'))), - Response::ref('NotFound'), - Response::ref('Unauthorized'), + Response::ok()->json(Reference::schema('User')), + Response::notFound()->ref('NotFound'), + Response::unauthorized()->ref('Unauthorized'), ); ``` @@ -184,8 +184,8 @@ $components = Components::create() ); // Reference -Operation::post()->requestBody(RequestBody::ref('CreateUser')); -Operation::patch()->requestBody(RequestBody::ref('UpdateUser')); +Operation::post()->requestBody(Reference::requestBody('CreateUser')); +Operation::patch()->requestBody(Reference::requestBody('UpdateUser')); ``` ## Security Schemes diff --git a/docs/openapi/info-and-metadata.mdx b/docs/openapi/info-and-metadata.mdx index 6caad14..2e5bc5b 100644 --- a/docs/openapi/info-and-metadata.mdx +++ b/docs/openapi/info-and-metadata.mdx @@ -137,7 +137,7 @@ PathItem::create('/uploads') ## Tags -Tags group operations together in documentation renderers. Declare them at the document level with `Tag`, then reference them by name string inside each operation. +Tags group operations together in documentation renderers. Declare them at the document level with `Tag`, then attach them on each operation with `Operation::tags()`. ```php use Cortex\OpenApi\Objects\Tag; @@ -156,10 +156,14 @@ OpenApi::create() ); ``` -Reference a tag in an operation by its name string: +Pass the `Tag` objects you already declared, plain name strings, or a mix — the name is taken from the tag for you: ```php -Operation::get()->tags('users', 'payments') +$users = Tag::create('users')->description('User account management'); + +Operation::get()->tags($users); +Operation::get()->tags('users', 'payments'); +Operation::get()->tags($users, 'payments'); ``` diff --git a/docs/openapi/installation.mdx b/docs/openapi/installation.mdx index fbf0d92..671bb15 100644 --- a/docs/openapi/installation.mdx +++ b/docs/openapi/installation.mdx @@ -14,6 +14,16 @@ icon: 'terminal' composer require cortexphp/openapi ``` +## YAML Output + +JSON serialization (`toJson()`, `toArray()`) is built-in. YAML output requires [symfony/yaml](https://symfony.com/doc/current/components/yaml.html): + +```bash +composer require symfony/yaml +``` + +`toYaml()` throws a `RuntimeException` if the package is not installed. + ## Development Setup ### For Package Development diff --git a/docs/openapi/introduction.mdx b/docs/openapi/introduction.mdx index 97b562f..8ae0881 100644 --- a/docs/openapi/introduction.mdx +++ b/docs/openapi/introduction.mdx @@ -154,17 +154,90 @@ MediaType::json($addressSchema); Parameter::query('filter', Schema::string()->enum(['active', 'archived'])); ``` -When serializing, `$schema` URI and `title` are automatically stripped from inline schemas per the OpenAPI spec — the sibling schema object is never mutated. +Embedding copies the schema into the slot and never mutates the object you passed, so the same schema can be reused across a document. Two things are cleaned up on the way in, at every depth: + +- The JSON Schema `$schema` URI is dropped. An OpenAPI document declares its dialect once, at the top level. +- The name you gave a builder — `Schema::object('Pet')`, `Schema::string('id')` — is dropped. It labels the variable, not the API. + +A `title` you set explicitly is kept, because that is documentation you meant to publish: + +```php +// The constructor name is a builder label +MediaType::json(Schema::object('Consult')); +// schema: { "type": "object" } + +// ->title() is published +MediaType::json(Schema::object('Consult')->title('Consultation')); +// schema: { "type": "object", "title": "Consultation" } +``` + + +`properties()` takes its key from each child's constructor name (`Schema::string('email')`), so property schemas do need one — and it is never duplicated into the output as a `title`. + + +### OpenAPI-only Schema Fields + +`discriminator` and `xml` come from OpenAPI rather than JSON Schema, so they have no builder method. Every schema slot also accepts a plain array, which lets you spread a built schema and add them: + +```php +use Cortex\JsonSchema\Schema; +use Cortex\OpenApi\Objects\Discriminator; +use Cortex\OpenApi\Objects\MediaType; + +$pet = Schema::object()->properties( + Schema::string('petType')->required(), +); + +MediaType::json([ + ...$pet->toArray(includeSchemaRef: false), + 'discriminator' => Discriminator::create('petType') + ->mapping(['dog' => '#/components/schemas/Dog']) + ->toArray(), +]); +``` + +### References Inside Schemas + +`Reference` is an OpenAPI object for schema *slots* — `MediaType`, `Parameter`, `Header`, `Components::schema()`. To point at a component from inside a schema you are composing, use the JSON Schema builder's own `ref()`: + +```php +use Cortex\JsonSchema\Schema; +use Cortex\OpenApi\Objects\MediaType; +use Cortex\OpenApi\Objects\Reference; + +// A slot takes a Reference +MediaType::json(Reference::schema('User')); + +// Composition takes a schema — typeless() emits a bare $ref with no stray "type" +Schema::object()->allOf( + Schema::typeless()->ref('#/components/schemas/BaseEntity'), + Schema::object()->properties(Schema::string('title')), +); +``` ## OpenAPI Version Support -The default version is **3.1.0**. Use `OpenApi::v311()` to target the 3.1.1 revision, which includes clarifications and minor fixes. +The default version is **3.1.0**. Pass `OpenApiVersion::V3_1_1` to target the 3.1.1 revision, which includes clarifications and minor fixes. -| Version | Status | Named Constructor | -|---------|--------|-------------------| -| 3.1.0 | Supported | `OpenApi::create()` or `OpenApi::v310()` | -| 3.1.1 | Supported | `OpenApi::v311()` | +| Version | Status | Constructor | +|---------|--------|-------------| +| 3.1.0 | Supported | `OpenApi::create()` or `OpenApi::create(OpenApiVersion::V3_1_0)` | +| 3.1.1 | Supported | `OpenApi::create(OpenApiVersion::V3_1_1)` | -Both versions ship with their official meta-schema bundled — validation works fully offline. +```php +use Cortex\OpenApi\OpenApi; +use Cortex\OpenApi\Enums\OpenApiVersion; + +OpenApi::create(); // 3.1.0 +OpenApi::create(OpenApiVersion::V3_1_1); // 3.1.1 +``` + +Both versions ship with their official meta-schema bundled, so [validation](/openapi/validation-and-output) works fully offline. + +Schema Objects are read as JSON Schema 2020-12 unless you say otherwise. If your schemas use a different dialect, announce it once on the document: + +```php +OpenApi::create()->jsonSchemaDialect('https://json-schema.org/draft/2020-12/schema'); +``` diff --git a/docs/openapi/paths-and-operations.mdx b/docs/openapi/paths-and-operations.mdx index 7b93602..519151b 100644 --- a/docs/openapi/paths-and-operations.mdx +++ b/docs/openapi/paths-and-operations.mdx @@ -94,13 +94,27 @@ Operation::trace() ```php Common fields Operation::get() ->operationId('articles.show') // unique ID used by tooling and links - ->tags('articles', 'public') // groups the operation in docs + ->tags('articles', 'public') // Tag objects or name strings ->summary('Get an article') // short label ->description('Returns a single article by its slug.') ->deprecated() // marks operation as deprecated ``` +### Tags + +`Operation::tags()` accepts name strings, `Tag` objects, or both. Document-level `Tag` objects are serialized as `{ name, description, ... }`; on an operation they become the name string only: + +```php +use Cortex\OpenApi\Objects\Tag; + +$articles = Tag::create('articles')->description('Publishing'); + +Operation::get()->tags('articles', 'public'); +Operation::get()->tags($articles); +Operation::get()->tags($articles, 'public'); +``` + ### operationId `operationId` must be unique across the entire document. Tools use it for client SDK method naming and inter-operation links. @@ -122,6 +136,31 @@ Operation::get() ->description('Deprecated. Use /v2/items instead.') ``` +### Attaching Responses + +`responses()` takes `Response` objects and keys them from their own status code, so you rarely repeat yourself. Point a status code at a reusable response with `->ref()`: + +```php +use Cortex\OpenApi\Objects\Response; +use Cortex\OpenApi\Objects\Reference; + +Operation::get() + ->responses( + Response::ok()->json(Reference::schema('Article')), + Response::notFound()->ref('NotFound'), + ); +``` + +`response()` sets a single status key, which is useful for a code with no named constructor or when adding to an existing map — `responses()` replaces the map wholesale: + +```php +Operation::get() + ->responses(Response::ok()->json(Reference::schema('Article'))) + ->response(418, Reference::response('Teapot')); +``` + +See [Responses](/openapi/responses) for headers, links, and reusable response components. + ## Parameters Parameters live in four locations: `path`, `query`, `header`, and `cookie`. Named constructors set the location automatically. diff --git a/docs/openapi/quickstart.mdx b/docs/openapi/quickstart.mdx index 65202c8..fa852ba 100644 --- a/docs/openapi/quickstart.mdx +++ b/docs/openapi/quickstart.mdx @@ -273,16 +273,18 @@ Reference::requestBody('CreateUser') // #/components/requestBodies/CreateUser Reference::header('RateLimit') // #/components/headers/RateLimit Reference::securityScheme('OAuth2') // #/components/securitySchemes/OAuth2 Reference::link('GetPet') // #/components/links/GetPet +Reference::example('AdminUser') // #/components/examples/AdminUser Reference::callback('EventWebhook') // #/components/callbacks/EventWebhook Reference::pathItem('LegacyPets') // #/components/pathItems/LegacyPets -// Generic reference — use when the bucket isn't covered above -Reference::to('#/components/schemas/Pet') +// Generic reference — for a pointer outside the component buckets +Reference::to('./common.yaml#/components/schemas/Pet') +``` + +Every reference is built here, so there is one place to look and one spelling to remember. The exception is a response inside `Operation::responses()`, which needs a status code to key on — see [Responses](/openapi/responses): -// Shortcut on the target class (equivalent, and communicates intent) -Response::ref('NotFound') -Parameter::ref('PetId') -RequestBody::ref('CreateUser') +```php +Response::notFound()->ref('NotFound') ``` ## Vendor Extensions diff --git a/docs/openapi/request-bodies.mdx b/docs/openapi/request-bodies.mdx index 38ad28b..2d47de2 100644 --- a/docs/openapi/request-bodies.mdx +++ b/docs/openapi/request-bodies.mdx @@ -162,7 +162,7 @@ MediaType::multipart($uploadSchema)->encoding([ |--------|-------------| | `->contentType(string)` | Override MIME type for the part (default: field's schema type) | | `->headers(array)` | Custom HTTP headers on this part | -| `->style(string)` | Serialization style for complex types | +| `->style(...)` | Serialization style for complex types (`Style` enum or a spec string such as `'form'`) | | `->explode(bool)` | Explode arrays into separate parts | | `->allowReserved(bool)` | Allow RFC3986 reserved characters unencoded | @@ -223,7 +223,7 @@ $components = Components::create() // Reference from an operation Operation::post() ->operationId('articles.create') - ->requestBody(RequestBody::ref('CreateArticle')); + ->requestBody(Reference::requestBody('CreateArticle')); ``` ## Complete Example diff --git a/docs/openapi/responses.mdx b/docs/openapi/responses.mdx index 5e314d0..8bdbb5d 100644 --- a/docs/openapi/responses.mdx +++ b/docs/openapi/responses.mdx @@ -6,7 +6,7 @@ icon: 'file-down' ## Response -`Response` describes a single HTTP status code outcome. Pass one or more responses to `Operation::responses()`. +`Response` describes a single HTTP status code outcome. Pass one or more of them to `Operation::responses()` and each is keyed from its own status code: ```php use Cortex\OpenApi\Objects\Response; @@ -20,6 +20,21 @@ Operation::get() ); ``` +To answer a status code with a reusable response from `Components`, use `->ref()`. The status still comes from the named constructor, so it belongs in the same `responses()` call: + +```php +Operation::get() + ->responses( + Response::ok()->json(Reference::schema('User')), + Response::notFound()->ref('NotFound'), + Response::unauthorized()->ref('Unauthorized'), + ); +``` + + +Elsewhere, references are built with `Reference::response('NotFound')` — see [Working with References](/openapi/quickstart). `->ref()` exists on `Response` because `responses()` keys each entry by status code, and a bare reference has none. Slots that supply the status themselves, such as `Components::response()` and `Operation::response(404, ...)`, take `Reference::response()`. + + ### Named Constructors Every common HTTP status code has a named constructor that pre-populates the standard description. Override it with `->description()`. @@ -199,8 +214,8 @@ $components = Components::create() Operation::get() ->responses( Response::ok()->json($schema), - Response::ref('NotFound'), - Response::ref('Unauthorized'), + Response::notFound()->ref('NotFound'), + Response::unauthorized()->ref('Unauthorized'), ); ``` diff --git a/docs/openapi/security.mdx b/docs/openapi/security.mdx index c6ac670..06e5c5e 100644 --- a/docs/openapi/security.mdx +++ b/docs/openapi/security.mdx @@ -278,6 +278,7 @@ use Cortex\OpenApi\Objects\SecurityRequirement; use Cortex\OpenApi\Objects\OAuthFlows; use Cortex\OpenApi\Objects\OAuthFlow; use Cortex\OpenApi\Objects\Operation; +use Cortex\OpenApi\Objects\PathItem; use Cortex\OpenApi\Enums\In; $doc = OpenApi::create() diff --git a/docs/openapi/validation-and-output.mdx b/docs/openapi/validation-and-output.mdx new file mode 100644 index 0000000..e2df5a2 --- /dev/null +++ b/docs/openapi/validation-and-output.mdx @@ -0,0 +1,163 @@ +--- +title: Validation & Output +description: 'Validate against the official OpenAPI 3.1 meta-schema and serialize to array, JSON, or YAML' +icon: 'shield-check' +--- + +## Overview + +A document is just an object graph until you serialize it. Two methods matter at the end of a build: + +1. **Validate** with `validate()` — checks the document against the official OpenAPI 3.1 meta-schema. +2. **Serialize** with `toArray()`, `toJson()`, or `toYaml()`. + +```php +use Cortex\OpenApi\Exceptions\ValidationException; + +try { + $doc->validate(); +} catch (ValidationException $e) { + echo $e->getMessage(); + + exit(1); +} + +file_put_contents('openapi.json', $doc->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +``` + +## Validation + +`validate()` returns nothing on success and throws `ValidationException` on failure. The meta-schemas for both 3.1.0 and 3.1.1 ship with the package, so validation never touches the network and works in CI without extra setup. + +```php +$doc->validate(); // void on success +``` + +### Reading Errors + +`getMessage()` gives a one-line summary suitable for logs. `errors()` gives the structured version: a map of JSON pointers to the problems found at that location. + +```php +try { + $doc->validate(); +} catch (ValidationException $e) { + print_r($e->errors()); +} +``` + +``` +Array +( + [/] => Array + ( + [0] => The required properties (info) are missing + ) +) +``` + +The pointer tells you where to look. A path that is missing its leading slash, for example, is reported against `/paths`: + +``` +Array +( + [/paths] => Array + ( + [0] => Unevaluated object properties not allowed: users + ) +) +``` + +Some spec rules are "at least one of these", and the validator reports every branch it tried. A document with no operations fails like this: + +``` +Array +( + [/] => Array + ( + [0] => The required properties (paths) are missing + [1] => The required properties (components) are missing + [2] => The required properties (webhooks) are missing + ) +) +``` + +That is one rule, not three: OpenAPI 3.1 requires a document to have at least one of `paths`, `components`, or `webhooks`. Supplying any one of them clears all three lines. + + +`ValidationException` extends `OpenApiException`, so you can catch every exception this package throws with a single `catch (OpenApiException $e)`. + + +### What Validation Covers + +Validation is structural: it checks the document against the spec's own meta-schema, the same rules an OpenAPI tool would apply to your published file. + +| Caught | Not caught | +|--------|------------| +| Missing required fields such as `info` | A `$ref` pointing at a component that does not exist | +| Malformed path keys and status codes | Duplicate `operationId` values | +| Fields in the wrong place or of the wrong type | Servers that are unreachable | + +Because `$ref` pointers are not resolved, a typo in `Reference::schema('Usr')` serializes and validates happily — it only surfaces in the renderer. Registering shared shapes in [Components](/openapi/components) and referencing them through the typed `Reference` helpers keeps those pointers in one place. + +## Output Formats + + +```php Array +$array = $doc->toArray(); +``` + +```php JSON +$json = $doc->toJson(); // compact +$json = $doc->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +``` + +```php YAML +$yaml = $doc->toYaml(); // inline: 10, indent: 2 +$yaml = $doc->toYaml(4, 4); // expand nesting sooner, indent by 4 +``` + + +`toJson()` passes its `$flags` straight to `json_encode()`. Adding `JSON_UNESCAPED_SLASHES` is worth it for readability, since paths and `$ref` pointers are full of slashes: + +```json +"paths": { "\/users": { ... } } // default +"paths": { "/users": { ... } } // with JSON_UNESCAPED_SLASHES +``` + +Both forms are valid JSON and parse identically. + + +`toYaml()` requires [symfony/yaml](https://symfony.com/doc/current/components/yaml.html) and throws a `RuntimeException` if it is not installed. Add it with `composer require symfony/yaml`. + + +`toYaml()`'s first argument is the depth at which YAML switches from block style to inline `{ }` style. The default of `10` keeps most documents fully expanded; lower it for a more compact file. + +## Writing the Document to Disk + +A small build script is usually all a project needs, and it doubles as a CI check: + +```php +#!/usr/bin/env php +validate(); +} catch (ValidationException $e) { + fwrite(STDERR, $e->getMessage() . PHP_EOL); + + exit(1); +} + +file_put_contents(__DIR__ . '/public/openapi.json', $doc->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +file_put_contents(__DIR__ . '/public/openapi.yaml', $doc->toYaml()); +``` + + +Commit the generated file and run the script in CI. A failing build then means the spec drifted from the code that generates it. + diff --git a/docs/openapi/webhooks-and-callbacks.mdx b/docs/openapi/webhooks-and-callbacks.mdx index b33431f..edeb4ca 100644 --- a/docs/openapi/webhooks-and-callbacks.mdx +++ b/docs/openapi/webhooks-and-callbacks.mdx @@ -10,6 +10,7 @@ Webhooks are HTTP requests that **your server sends** to a subscriber-provided U ```php use Cortex\OpenApi\OpenApi; +use Cortex\OpenApi\Objects\Info; use Cortex\OpenApi\Objects\PathItem; use Cortex\OpenApi\Objects\Operation; use Cortex\OpenApi\Objects\Response; @@ -128,7 +129,7 @@ $components = Components::create() ->schema('EventEnvelope', $eventEnvelope) ->schema('UserCreatedPayload', Schema::object() ->allOf( - Reference::schema('EventEnvelope'), + Schema::typeless()->ref('#/components/schemas/EventEnvelope'), Schema::object()->properties( Schema::object('data')->properties( Schema::integer('id')->required(), @@ -138,7 +139,7 @@ $components = Components::create() )) ->schema('OrderPlacedPayload', Schema::object() ->allOf( - Reference::schema('EventEnvelope'), + Schema::typeless()->ref('#/components/schemas/EventEnvelope'), Schema::object()->properties( Schema::object('data')->properties( Schema::integer('orderId')->required(), @@ -206,6 +207,19 @@ Operation::post() ]); ``` +`Operation::callback()` adds entries one at a time, the same way `OpenApi::webhook()` does: + +```php +Operation::post() + ->callback('onEvent', Reference::callback('EventWebhook')) + ->callback('onError', Callback::create()->expression( + '{$request.body#/errorUrl}', + PathItem::create('')->operations( + Operation::post()->responses(Response::ok()), + ), + )); +``` + ### Runtime Expressions The key passed to `->expression()` is a runtime expression evaluated against the original API request: @@ -243,7 +257,7 @@ $components = Components::create() Operation::post() ->operationId('hooks.subscribe') ->callbacks([ - 'onEvent' => Callback::ref('EventWebhook'), + 'onEvent' => Reference::callback('EventWebhook'), ]); ``` @@ -325,7 +339,7 @@ $doc = OpenApi::create() ) ->responses(Response::created()) ->callbacks([ - 'onEvent' => Callback::ref('GenericWebhook'), + 'onEvent' => Reference::callback('GenericWebhook'), ]), ), ); diff --git a/src/Concerns/BuildsArray.php b/src/Concerns/BuildsArray.php index 381230b..92ed3c0 100644 --- a/src/Concerns/BuildsArray.php +++ b/src/Concerns/BuildsArray.php @@ -68,7 +68,9 @@ private function unwrapValue(mixed $value): mixed $title = $value->getTitle(); $includeTitle = $title !== null && $title !== $value->getInitialTitle(); - return $value->toArray(includeSchemaRef: false, includeTitle: $includeTitle); + return $this->stripSchemaRef( + $value->toArray(includeSchemaRef: false, includeTitle: $includeTitle), + ); } if (is_array($value)) { @@ -90,4 +92,59 @@ private function unwrapValue(mixed $value): mixed return $value; } + + /** + * $schema must not appear outside the root of a schema resource (JSON Schema + * 2020-12 core, 8.1.1), and the builder cannot produce a nested resource root, so + * every nested occurrence is invalid. cortexphp/json-schema emits one anyway for + * contains as of 2.0. Recursion walks nested schemas, not instance values under + * default, enum, or examples. A raw array schema is left alone, which is + * the way to declare a dialect deliberately. + * + * @param array $schema + * + * @return array + */ + private function stripSchemaRef(array $schema): array + { + // Maps whose keys are user-chosen names (a property may legitimately be named "$schema"). + $namedSubschemaKeys = ['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']; + $instanceValueKeys = ['default', 'enum', 'examples']; + + unset($schema['$schema']); + + foreach ($schema as $key => $value) { + if (! is_array($value) || in_array($key, $instanceValueKeys, true)) { + continue; + } + + if (in_array($key, $namedSubschemaKeys, true)) { + foreach ($value as $name => $subschema) { + if (is_array($subschema)) { + $value[$name] = $this->stripSchemaRef($subschema); + } + } + + $schema[$key] = $value; + + continue; + } + + if (array_is_list($value)) { + foreach ($value as $index => $subschema) { + if (is_array($subschema)) { + $value[$index] = $this->stripSchemaRef($subschema); + } + } + + $schema[$key] = $value; + + continue; + } + + $schema[$key] = $this->stripSchemaRef($value); + } + + return $schema; + } } diff --git a/src/Objects/Callback.php b/src/Objects/Callback.php index daa296c..a5814cb 100644 --- a/src/Objects/Callback.php +++ b/src/Objects/Callback.php @@ -22,11 +22,6 @@ public static function create(): self return new self(); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::callback($name, $summary, $description); - } - public function expression(string $runtimeExpression, PathItem $pathItem): self { $this->expressions[$runtimeExpression] = $pathItem; diff --git a/src/Objects/Example.php b/src/Objects/Example.php index e4f3ee3..f6ea206 100644 --- a/src/Objects/Example.php +++ b/src/Objects/Example.php @@ -29,11 +29,6 @@ public static function create(): self return new self(); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::example($name, $summary, $description); - } - public function summary(?string $summary): self { $this->summary = $summary; diff --git a/src/Objects/Header.php b/src/Objects/Header.php index ff1b60a..8ac0e1d 100644 --- a/src/Objects/Header.php +++ b/src/Objects/Header.php @@ -54,11 +54,6 @@ public static function create(): self return new self(); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::header($name, $summary, $description); - } - public function description(?string $description): self { $this->description = $description; diff --git a/src/Objects/Link.php b/src/Objects/Link.php index aa30412..e195e3d 100644 --- a/src/Objects/Link.php +++ b/src/Objects/Link.php @@ -36,11 +36,6 @@ public static function create(): self return new self(); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::link($name, $summary, $description); - } - public function operationRef(?string $operationRef): self { $this->operationRef = $operationRef; diff --git a/src/Objects/Parameter.php b/src/Objects/Parameter.php index c8310be..7bc1134 100644 --- a/src/Objects/Parameter.php +++ b/src/Objects/Parameter.php @@ -90,11 +90,6 @@ public static function cookie(string $name, JsonSchema|array|Reference|null $sch return new self($name, In::Cookie, $schema); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::parameter($name, $summary, $description); - } - public function getName(): string { return $this->name; diff --git a/src/Objects/PathItem.php b/src/Objects/PathItem.php index 95bc558..43c6e42 100644 --- a/src/Objects/PathItem.php +++ b/src/Objects/PathItem.php @@ -42,11 +42,6 @@ public static function create(string $path): self return new self($path); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::pathItem($name, $summary, $description); - } - public function getPath(): string { return $this->path; diff --git a/src/Objects/RequestBody.php b/src/Objects/RequestBody.php index 9cc8c5c..4615bf2 100644 --- a/src/Objects/RequestBody.php +++ b/src/Objects/RequestBody.php @@ -29,11 +29,6 @@ public static function create(): self return new self(); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::requestBody($name, $summary, $description); - } - public function description(?string $description): self { $this->description = $description; diff --git a/src/Objects/Response.php b/src/Objects/Response.php index 8180b04..0983b03 100644 --- a/src/Objects/Response.php +++ b/src/Objects/Response.php @@ -35,6 +35,8 @@ final class Response implements Serializable, HasExtensionsInterface private ?string $description; + private ?Reference $reference = null; + /** * @var array */ @@ -126,9 +128,18 @@ public static function internalServerError(): self return new self('500'); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference + /** + * Answer this status code with a reusable response from Components. + * + * The status code is kept, so the result can go straight into Operation::responses(). + * The document carries the $ref alone — a Reference Object has no room for the other + * fields set on this response. + */ + public function ref(string $name, ?string $summary = null, ?string $description = null): self { - return Reference::response($name, $summary, $description); + $this->reference = Reference::response($name, $summary, $description); + + return $this; } public function getStatusCode(): string @@ -203,6 +214,10 @@ public function link(string $name, Link|Reference $link): self */ public function toArray(): array { + if ($this->reference instanceof Reference) { + return $this->reference->toArray(); + } + return $this->buildArray([ 'description' => $this->description, 'headers' => $this->headers, diff --git a/src/Objects/SecurityScheme.php b/src/Objects/SecurityScheme.php index 640768f..ffef819 100644 --- a/src/Objects/SecurityScheme.php +++ b/src/Objects/SecurityScheme.php @@ -72,11 +72,6 @@ public static function mutualTls(): self return new self(SecuritySchemeType::MutualTls); } - public static function ref(string $name, ?string $summary = null, ?string $description = null): Reference - { - return Reference::securityScheme($name, $summary, $description); - } - public function getType(): SecuritySchemeType { return $this->securitySchemeType; diff --git a/tests/Unit/Concerns/BuildsArrayTest.php b/tests/Unit/Concerns/BuildsArrayTest.php index 13732f4..cf49d24 100644 --- a/tests/Unit/Concerns/BuildsArrayTest.php +++ b/tests/Unit/Concerns/BuildsArrayTest.php @@ -246,3 +246,151 @@ public function assemble(array $fields): array ], ]); }); + +it('emits no $schema in a nested item schema', function (): void { + $arraySchema = Schema::array()->items(Schema::object()->properties(Schema::string('name'))); + + $out = new BuildsArrayFixture()->assemble([ + 'schema' => $arraySchema, + ]); + + expect($out)->toBe([ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + ], + ], + ], + ]); +}); + +it('emits no $schema in a nested contains schema', function (): void { + $arraySchema = Schema::array()->contains(Schema::string()); + + $out = new BuildsArrayFixture()->assemble([ + 'schema' => $arraySchema, + ]); + + expect($out)->toBe([ + 'schema' => [ + 'type' => 'array', + 'contains' => [ + 'type' => 'string', + ], + ], + ]); +}); + +it('emits no $schema at any depth', function (): void { + $out = new BuildsArrayFixture()->assemble([ + 'schema' => Schema::array()->items( + Schema::object()->properties( + Schema::array('tags')->items(Schema::string()), + ), + ), + ]); + + $json = json_encode($out, JSON_THROW_ON_ERROR); + + expect($json)->not->toContain('$schema'); +}); + +it('keeps a property that is itself named $schema', function (): void { + $out = new BuildsArrayFixture()->assemble([ + 'schema' => Schema::object()->properties(Schema::string('$schema'), Schema::string('id')), + ]); + + expect($out)->toBe([ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + '$schema' => [ + 'type' => 'string', + ], + 'id' => [ + 'type' => 'string', + ], + ], + ], + ]); +}); + +it('leaves $schema in a raw array schema untouched', function (): void { + $out = new BuildsArrayFixture()->assemble([ + 'schema' => [ + 'type' => 'object', + '$schema' => 'https://example.test/dialect', + ], + ]); + + expect($out)->toBe([ + 'schema' => [ + 'type' => 'object', + '$schema' => 'https://example.test/dialect', + ], + ]); +}); + +it('leaves $schema inside instance values', function (): void { + $out = new BuildsArrayFixture()->assemble([ + 'schema' => Schema::object() + ->default([ + '$schema' => 'literal', + 'keep' => true, + ]) + ->enum([ + [ + '$schema' => 'e', + ], + [ + 'ok' => true, + ], + ]) + ->examples([[ + '$schema' => 'x', + ]]), + ]); + + expect($out['schema'])->toMatchArray([ + 'type' => 'object', + 'default' => [ + '$schema' => 'literal', + 'keep' => true, + ], + 'enum' => [ + [ + '$schema' => 'e', + ], + [ + 'ok' => true, + ], + ], + 'examples' => [ + [ + '$schema' => 'x', + ], + ], + ]); +}); + +it('strips $schema from schemas inside allOf', function (): void { + $out = new BuildsArrayFixture()->assemble([ + 'schema' => Schema::object()->allOf(Schema::string()), + ]); + + expect($out)->toBe([ + 'schema' => [ + 'type' => 'object', + 'allOf' => [ + [ + 'type' => 'string', + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Objects/CallbackTest.php b/tests/Unit/Objects/CallbackTest.php index a2da579..259daa1 100644 --- a/tests/Unit/Objects/CallbackTest.php +++ b/tests/Unit/Objects/CallbackTest.php @@ -37,9 +37,3 @@ ], ]); }); - -it('supports ref() shortcut', function (): void { - expect(Callback::ref('Webhook')->toArray())->toBe([ - '$ref' => '#/components/callbacks/Webhook', - ]); -}); diff --git a/tests/Unit/Objects/ExampleTest.php b/tests/Unit/Objects/ExampleTest.php index 952fb30..0a26afe 100644 --- a/tests/Unit/Objects/ExampleTest.php +++ b/tests/Unit/Objects/ExampleTest.php @@ -38,12 +38,6 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(Example::ref('SampleUser')->toArray())->toBe([ - '$ref' => '#/components/examples/SampleUser', - ]); -}); - it('clears a value when explicitly cleared', function (): void { $example = Example::create()->value('a'); diff --git a/tests/Unit/Objects/HeaderTest.php b/tests/Unit/Objects/HeaderTest.php index 4794770..7f371f5 100644 --- a/tests/Unit/Objects/HeaderTest.php +++ b/tests/Unit/Objects/HeaderTest.php @@ -83,12 +83,6 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(Header::ref('RateLimitRemaining')->toArray())->toBe([ - '$ref' => '#/components/headers/RateLimitRemaining', - ]); -}); - it('accepts a Style enum for style()', function (): void { $header = Header::create()->style(Style::Simple); diff --git a/tests/Unit/Objects/LinkTest.php b/tests/Unit/Objects/LinkTest.php index 3c94e2f..9dbcff5 100644 --- a/tests/Unit/Objects/LinkTest.php +++ b/tests/Unit/Objects/LinkTest.php @@ -48,12 +48,6 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(Link::ref('Foo')->toArray())->toBe([ - '$ref' => '#/components/links/Foo', - ]); -}); - it('inserts requestBody after parameters when parameters are present', function (): void { $link = Link::create() ->operationId('users.create') diff --git a/tests/Unit/Objects/OperationTest.php b/tests/Unit/Objects/OperationTest.php index eea1775..a878bc1 100644 --- a/tests/Unit/Objects/OperationTest.php +++ b/tests/Unit/Objects/OperationTest.php @@ -168,6 +168,22 @@ expect($operation->toArray()['responses'])->toHaveKeys(['200', '404']); }); +it('responses() accepts a referenced response and keys it by status', function (): void { + $operation = Operation::get()->responses( + Response::ok(), + Response::notFound()->ref('NotFound'), + ); + + expect($operation->toArray()['responses'])->toBe([ + '200' => [ + 'description' => 'OK', + ], + '404' => [ + '$ref' => '#/components/responses/NotFound', + ], + ]); +}); + it('adds a response by explicit status key, accepting Response or Reference', function (): void { $operation = Operation::get() ->response('200', Response::ok()) @@ -194,7 +210,7 @@ it('adds callbacks one at a time with callback()', function (): void { $operation = Operation::post() ->callback('onData', Callback::create()->expression('{$url}', PathItem::create('/hook'))) - ->callback('onError', Callback::ref('OnError')); + ->callback('onError', Reference::callback('OnError')); $arr = $operation->toArray(); expect($arr['callbacks'])->toHaveKey('onData') diff --git a/tests/Unit/Objects/ParameterTest.php b/tests/Unit/Objects/ParameterTest.php index 933b2b5..1fea150 100644 --- a/tests/Unit/Objects/ParameterTest.php +++ b/tests/Unit/Objects/ParameterTest.php @@ -109,12 +109,6 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(Parameter::ref('PageSize')->toArray())->toBe([ - '$ref' => '#/components/parameters/PageSize', - ]); -}); - it('required() defaults to true', function (): void { expect(Parameter::query('test', Schema::string())->required()->toArray())->toMatchArray([ 'required' => true, diff --git a/tests/Unit/Objects/PathItemTest.php b/tests/Unit/Objects/PathItemTest.php index 9eec251..2c7e743 100644 --- a/tests/Unit/Objects/PathItemTest.php +++ b/tests/Unit/Objects/PathItemTest.php @@ -66,12 +66,6 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(PathItem::ref('UserById')->toArray())->toBe([ - '$ref' => '#/components/pathItems/UserById', - ]); -}); - it('emits vendor extensions', function (): void { $pathItem = PathItem::create('/users') ->x('internal', true); diff --git a/tests/Unit/Objects/RequestBodyTest.php b/tests/Unit/Objects/RequestBodyTest.php index 3474e1b..e5e1544 100644 --- a/tests/Unit/Objects/RequestBodyTest.php +++ b/tests/Unit/Objects/RequestBodyTest.php @@ -56,12 +56,6 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(RequestBody::ref('Create')->toArray())->toBe([ - '$ref' => '#/components/requestBodies/Create', - ]); -}); - it('required() defaults to true', function (): void { $requestBody = RequestBody::create() ->required() diff --git a/tests/Unit/Objects/ResponseTest.php b/tests/Unit/Objects/ResponseTest.php index 11ff408..ca394d2 100644 --- a/tests/Unit/Objects/ResponseTest.php +++ b/tests/Unit/Objects/ResponseTest.php @@ -85,9 +85,33 @@ ]); }); -it('supports ref() shortcut', function (): void { - expect(Response::ref('Unauthorized')->toArray())->toBe([ - '$ref' => '#/components/responses/Unauthorized', +it('ref() serializes to the reference alone', function (): void { + expect(Response::notFound()->ref('NotFound')->toArray())->toBe([ + '$ref' => '#/components/responses/NotFound', + ]); +}); + +it('ref() keeps the status code so responses() can key it', function (): void { + expect(Response::notFound()->ref('NotFound')->getStatusCode())->toBe('404'); +}); + +it('ref() carries summary and description onto the reference', function (): void { + expect(Response::unauthorized()->ref('Unauthorized', 'Auth failed', 'Token missing or expired')->toArray()) + ->toBe([ + '$ref' => '#/components/responses/Unauthorized', + 'summary' => 'Auth failed', + 'description' => 'Token missing or expired', + ]); +}); + +it('ref() replaces any fields set on the response itself', function (): void { + $response = Response::notFound() + ->description('Locally described') + ->json(Schema::object()) + ->ref('NotFound'); + + expect($response->toArray())->toBe([ + '$ref' => '#/components/responses/NotFound', ]); }); @@ -102,7 +126,7 @@ it('adds links one at a time with link()', function (): void { $response = Response::ok() ->link('self', Link::create()->operationId('users.show')) - ->link('next', Link::ref('NextUser')); + ->link('next', Reference::link('NextUser')); expect($response->toArray()['links'])->toHaveKeys(['self', 'next']); }); diff --git a/tests/Unit/Objects/SecuritySchemeTest.php b/tests/Unit/Objects/SecuritySchemeTest.php index 4b99a25..833c602 100644 --- a/tests/Unit/Objects/SecuritySchemeTest.php +++ b/tests/Unit/Objects/SecuritySchemeTest.php @@ -71,9 +71,3 @@ 'type' => 'mutualTLS', ]); }); - -it('supports ref() shortcut', function (): void { - expect(SecurityScheme::ref('OAuth2')->toArray())->toBe([ - '$ref' => '#/components/securitySchemes/OAuth2', - ]); -});