diff --git a/ROADMAP.md b/ROADMAP.md index 0b2f0f656..772dc9d65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,10 @@ it and getting people onto it: * a rector rule set for `OpenApi\Attributes` -> `OpenApi\Spec` * a seam to contribute pre-built attributes to a `Specification` before resolution, for framework integrations that do not scan +* one `component:` field on every reusable `Spec` attribute, naming its key in `components`; + the per-type spellings it replaces (`schema`, `parameter`, `request`, `securityScheme`, and + `response`/`header`/`link`/`example` used as a component key) are marked `@deprecated`, + removed in `8.0`, and trigger a runtime deprecation when used * docblock annotation support marked `@deprecated`, removed in `8.0` - the README has said so since 4.8, the code now agrees, and parsing a docblock annotation triggers a runtime deprecation once per run diff --git a/docs/dev/pipeline.md b/docs/dev/pipeline.md index 35ab1214f..2652c4229 100644 --- a/docs/dev/pipeline.md +++ b/docs/dev/pipeline.md @@ -65,13 +65,19 @@ attribute is one that can stand alone — it owns a bucket and needs no parent. - **Always root**: `Schema`, `Operation`, `PathItem`, `OpenApi`, `Info`, `Tag`, `Server`, `ExternalDocumentation`, `Security\Scheme`, `Components`, `Attachable` -- **Conditionally root**, when their own key is set and `ref` is not: `Response` - (`response`), `Parameter` (`parameter`), `Link` (`link`); `RequestBody` needs `request` - set but has no `ref` check -- **Never root**: `Header`, `Example`, `MediaType`, `Property` — these must nest inside a - parent or sit in a `Components` container - -Each attribute decides for itself, in `isRoot()`. +- **Conditionally root**, when `component` is set — the key the attribute is filed under in + `components` — and `ref` is not: `Response`, `Parameter`, `Link`, `RequestBody`, `Header`, + `Example`, `MediaType`. The request body has no `ref` check, since its key can never double + as a nesting key. Until 8.0 the historic spellings still count for the response and the + link (`response`, `link`); the other historic spellings alias onto `component` in the + constructor and need no clause +- **Never root**: `Property` — it must nest inside a parent + +Each attribute decides for itself, in `isRoot()`. The one field behind the conditional rule, +`component`, is read through `Specification\ComponentName::of()` everywhere a key is needed — +the compiler, `ComponentIndex`, `Augmenter\Cleanup` — and filled in from the historic +spellings once per build by `ComponentName::normalise()`, after the contribution hooks and +before the resolver builds the first index. Root does not mean un-nested. `isRoot()` says what an attribute may be when nothing consumes it; `merge()` and `contained()` run first, and often do. `Schema` is always root, and is diff --git a/docs/examples/specs/api/hybrid/NameTrait.php b/docs/examples/specs/api/hybrid/NameTrait.php index bb6b1b235..bf9465144 100644 --- a/docs/examples/specs/api/hybrid/NameTrait.php +++ b/docs/examples/specs/api/hybrid/NameTrait.php @@ -11,7 +11,7 @@ /** * A Name. */ -#[OA\Schema(schema: 'NameTrait')] +#[OA\Schema(component: 'NameTrait')] trait NameTrait { #[OA\Property(property: 'name')] diff --git a/docs/examples/specs/misc/spec/ResultSchema.php b/docs/examples/specs/misc/spec/ResultSchema.php index cdfca4645..c39f704df 100644 --- a/docs/examples/specs/misc/spec/ResultSchema.php +++ b/docs/examples/specs/misc/spec/ResultSchema.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'Result', title: 'Sample schema for using references')] +#[OA\Schema(title: 'Sample schema for using references', component: 'Result')] class ResultSchema { #[OA\Property] diff --git a/docs/examples/specs/petstore/spec/Models/PetRequestBody.php b/docs/examples/specs/petstore/spec/Models/PetRequestBody.php index 165fbdce3..4390fbdd2 100644 --- a/docs/examples/specs/petstore/spec/Models/PetRequestBody.php +++ b/docs/examples/specs/petstore/spec/Models/PetRequestBody.php @@ -8,21 +8,16 @@ use OpenApi\Spec as OA; -#[OA\RequestBody( - request: 'Pet', - description: 'Pet object that needs to be added to the store', - required: true, - content: [ - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: Pet::class), - ), - new OA\MediaType( - mediaType: 'application/xml', - schema: new OA\Schema(ref: Pet::class), - ), - ], -)] +#[OA\RequestBody(description: 'Pet object that needs to be added to the store', required: true, content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: Pet::class), + ), + new OA\MediaType( + mediaType: 'application/xml', + schema: new OA\Schema(ref: Pet::class), + ), +], component: 'Pet')] class PetRequestBody { } diff --git a/docs/examples/specs/petstore/spec/Models/UserArrayRequestBody.php b/docs/examples/specs/petstore/spec/Models/UserArrayRequestBody.php index d2896a94c..c26b0f497 100644 --- a/docs/examples/specs/petstore/spec/Models/UserArrayRequestBody.php +++ b/docs/examples/specs/petstore/spec/Models/UserArrayRequestBody.php @@ -8,17 +8,12 @@ use OpenApi\Spec as OA; -#[OA\RequestBody( - request: 'UserArray', - description: 'List of user object', - required: true, - content: [ - new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(type: 'array', items: new OA\Schema(ref: User::class)), - ), - ], -)] +#[OA\RequestBody(description: 'List of user object', required: true, content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'array', items: new OA\Schema(ref: User::class)), + ), +], component: 'UserArray')] class UserArrayRequestBody { } diff --git a/docs/examples/specs/polymorphism/spec/AbstractResponsible.php b/docs/examples/specs/polymorphism/spec/AbstractResponsible.php index 768ecc853..7bd22331b 100644 --- a/docs/examples/specs/polymorphism/spec/AbstractResponsible.php +++ b/docs/examples/specs/polymorphism/spec/AbstractResponsible.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'Responsible', oneOf: [ +#[OA\Schema(oneOf: [ new OA\Schema(ref: Fl::class), new OA\Schema(ref: Employee::class), ], discriminator: new OA\Discriminator( @@ -17,7 +17,7 @@ 'fl' => Fl::class, 'employee' => Employee::class, ], -))] +), component: 'Responsible')] abstract class AbstractResponsible { protected const TYPE = null; diff --git a/docs/examples/specs/polymorphism/spec/Employee.php b/docs/examples/specs/polymorphism/spec/Employee.php index 24677407d..901aa3c2c 100644 --- a/docs/examples/specs/polymorphism/spec/Employee.php +++ b/docs/examples/specs/polymorphism/spec/Employee.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'EmployeeResponsible')] +#[OA\Schema(component: 'EmployeeResponsible')] final class Employee extends AbstractResponsible { #[OA\Property(property: 'type')] diff --git a/docs/examples/specs/polymorphism/spec/Fl.php b/docs/examples/specs/polymorphism/spec/Fl.php index 506613676..1539f04b9 100644 --- a/docs/examples/specs/polymorphism/spec/Fl.php +++ b/docs/examples/specs/polymorphism/spec/Fl.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'FlResponsible')] +#[OA\Schema(component: 'FlResponsible')] final class Fl extends AbstractResponsible { public const TYPE = 'fl'; diff --git a/docs/examples/specs/using-links/spec/PullRequest.php b/docs/examples/specs/using-links/spec/PullRequest.php index ef771fecb..09beaef93 100644 --- a/docs/examples/specs/using-links/spec/PullRequest.php +++ b/docs/examples/specs/using-links/spec/PullRequest.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'pullrequest')] +#[OA\Schema(component: 'pullrequest')] class PullRequest { #[OA\Property] diff --git a/docs/examples/specs/using-links/spec/Repository.php b/docs/examples/specs/using-links/spec/Repository.php index 10a0dcdc2..3d43edf6c 100644 --- a/docs/examples/specs/using-links/spec/Repository.php +++ b/docs/examples/specs/using-links/spec/Repository.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'repository')] +#[OA\Schema(component: 'repository')] class Repository { #[OA\Property] diff --git a/docs/examples/specs/using-links/spec/User.php b/docs/examples/specs/using-links/spec/User.php index b2f41986a..0c70dbc70 100644 --- a/docs/examples/specs/using-links/spec/User.php +++ b/docs/examples/specs/using-links/spec/User.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'user')] +#[OA\Schema(component: 'user')] class User { #[OA\Property] diff --git a/docs/examples/specs/using-refs/spec/ProductParameter.php b/docs/examples/specs/using-refs/spec/ProductParameter.php index b77d862c0..28be4d690 100644 --- a/docs/examples/specs/using-refs/spec/ProductParameter.php +++ b/docs/examples/specs/using-refs/spec/ProductParameter.php @@ -11,13 +11,6 @@ #[OA\Components] class ProductParameter { - #[OA\Parameter( - parameter: 'product_id_in_path_required', - name: 'product_id', - in: OA\ParameterIn::Path, - description: 'The ID of the product', - required: true, - schema: new OA\Schema(type: 'integer', format: 'int64'), - )] + #[OA\Parameter(name: 'product_id', in: OA\ParameterIn::Path, description: 'The ID of the product', required: true, schema: new OA\Schema(type: 'integer', format: 'int64'), component: 'product_id_in_path_required')] public int $product_id; } diff --git a/docs/examples/specs/using-refs/spec/ProductRequestBody.php b/docs/examples/specs/using-refs/spec/ProductRequestBody.php index e9bcad94f..9f2a60e61 100644 --- a/docs/examples/specs/using-refs/spec/ProductRequestBody.php +++ b/docs/examples/specs/using-refs/spec/ProductRequestBody.php @@ -8,15 +8,10 @@ use OpenApi\Spec as OA; -#[OA\RequestBody( - request: 'product_in_body', - description: 'product_request', - required: true, - content: [new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/Product'), - )], -)] +#[OA\RequestBody(description: 'product_request', required: true, content: [new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/Product'), +)], component: 'product_in_body')] class ProductRequestBody { } diff --git a/docs/examples/specs/using-refs/spec/ProductResponse.php b/docs/examples/specs/using-refs/spec/ProductResponse.php index d837d3f7b..2684df58f 100644 --- a/docs/examples/specs/using-refs/spec/ProductResponse.php +++ b/docs/examples/specs/using-refs/spec/ProductResponse.php @@ -8,14 +8,10 @@ use OpenApi\Spec as OA; -#[OA\Response( - response: 'product', - description: 'All information about a product', - content: [new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/Product'), - )], -)] +#[OA\Response(description: 'All information about a product', content: [new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/Product'), +)], component: 'product')] class ProductResponse { } diff --git a/docs/examples/specs/using-refs/spec/ProductStatus.php b/docs/examples/specs/using-refs/spec/ProductStatus.php index bdd56a753..5a0fa59f2 100644 --- a/docs/examples/specs/using-refs/spec/ProductStatus.php +++ b/docs/examples/specs/using-refs/spec/ProductStatus.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'product_status', description: 'The status of a product', type: 'string', enum: ['available', 'discontinued'], default: 'available')] +#[OA\Schema(description: 'The status of a product', type: 'string', enum: ['available', 'discontinued'], default: 'available', component: 'product_status')] class ProductStatus { } diff --git a/docs/examples/specs/using-refs/spec/TodoResponse.php b/docs/examples/specs/using-refs/spec/TodoResponse.php index fa3fbb310..5fd7c5d8a 100644 --- a/docs/examples/specs/using-refs/spec/TodoResponse.php +++ b/docs/examples/specs/using-refs/spec/TodoResponse.php @@ -8,10 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Response( - response: 'todo', - description: 'This API call has no documentated response (yet)', -)] +#[OA\Response(description: 'This API call has no documentated response (yet)', component: 'todo')] class TodoResponse { } diff --git a/docs/examples/specs/using-traits/spec/Blink.php b/docs/examples/specs/using-traits/spec/Blink.php index 317b6bc65..0c24f291a 100644 --- a/docs/examples/specs/using-traits/spec/Blink.php +++ b/docs/examples/specs/using-traits/spec/Blink.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'CustomName-Blink', title: 'Blink trait')] +#[OA\Schema(title: 'Blink trait', component: 'CustomName-Blink')] trait Blink { /** diff --git a/docs/guide/modes.md b/docs/guide/modes.md index ec4ed6539..c2b8dbd13 100644 --- a/docs/guide/modes.md +++ b/docs/guide/modes.md @@ -117,12 +117,12 @@ The recommended migration path is: 1. **Classic → Hybrid** — change `setMode(Mode::HYBRID)` and verify output is unchanged. No code changes needed. This gives you access to the augmenter pipeline. -2. **Hybrid → Spec** — when starting new code, use `OpenApi\Spec` attributes. Existing `OpenApi\Attributes` code continues to work via hybrid mode. +2. **Hybrid → Spec** — when starting new code, use `OpenApi\Spec` attributes. Existing `OpenApi\Attributes` code continues to work via hybrid mode. The spec attributes are not a one-for-one rename of the classic ones: a reusable attribute takes a single `component:` key where classic spells the key after its own type (`schema:`, `parameter:`, `request:`, `securityScheme:`). See [Components](/guide/spec-attributes#components). 3. **Full Spec** — once all code uses `OpenApi\Spec` attributes, switch to `setMode(Mode::SPEC)`. ::: tip Version timeline - **v6** — spec/hybrid ship as opt-in beta. Classic remains default. - **v7** — hybrid becomes the default mode. Classic still available. `setMode()` and all classic code deprecated. -- **v8** — classic removed. `setMode()` removed. Spec becomes default. Spec attributes move to `OpenApi\Attributes`, one `use` line per file. +- **v8** — classic removed. `setMode()` removed. Spec becomes default. Spec attributes move to `OpenApi\Attributes`. For code already written against `OpenApi\Spec` that move is one `use` line per file; coming from classic, the component keys are renamed as well. ::: diff --git a/docs/guide/spec-attributes.md b/docs/guide/spec-attributes.md index c898f86a6..7c18ed1a5 100644 --- a/docs/guide/spec-attributes.md +++ b/docs/guide/spec-attributes.md @@ -339,40 +339,60 @@ class ValidationErrors {} ## Components -`#[OA\Components]` is a class-level container for reusable definitions that cannot stand alone as root attributes — primarily Parameters, Headers, Links, and Examples. +Any reusable attribute becomes a component by giving it a `component:` key — the name it is +filed under in the document's `components` section, and the name a `$ref` points at. +Schemas, responses, parameters, request bodies, headers, links, examples, security schemes and +path items all take it, and a keyed attribute is a root: it can be declared on a class by +itself, with no wrapper. ```php use OpenApi\Spec as OA; -#[OA\Components] -class SharedComponents -{ - #[OA\Parameter(parameter: 'page', name: 'page', in: 'query')] - #[OA\Schema(type: 'integer', default: 1)] - public int $page; +#[OA\Parameter(component: 'page', name: 'page', in: 'query')] +#[OA\Schema(type: 'integer', default: 1)] +class PageParameter {} - #[OA\Parameter(parameter: 'per_page', name: 'per_page', in: 'query')] - #[OA\Schema(type: 'integer', default: 20)] - public int $perPage; +#[OA\Header(component: 'RateLimit', description: 'Requests remaining')] +#[OA\Schema(type: 'integer')] +class RateLimitHeader {} - #[OA\Header(header: 'X-Rate-Limit', description: 'Requests remaining')] - #[OA\Schema(type: 'integer')] - public string $rateLimit; -} +#[OA\Response(component: 'NotFound', description: 'No such thing')] +class NotFoundResponse {} ``` -These can then be referenced from operations via `$ref`: +These are then referenced from operations via `$ref`: ```php #[OA\Operation\Get(path: '/users', parameters: [ new OA\Parameter(ref: '#/components/parameters/page'), - new OA\Parameter(ref: '#/components/parameters/per_page'), +], responses: [ + new OA\Response(response: 404, ref: '#/components/responses/NotFound'), ])] public function list() {} ``` -::: tip When to use Components -Schemas, PathItems, security schemes (`OA\Security\Scheme`), and named Responses/RequestBodies are root attributes — they can be declared directly on a class without a Components wrapper. Use Components only for types that can't stand alone (Parameter, Header, Link, Example). +Note the two keys on that response: `response: 404` is where it nests in the operation, and +`NotFound` is the component it points at. The same split holds for a header (`header:` is the +HTTP header name, `component:` the reusable definition), a link and an example. A schema +declared on a class needs no key at all — it is named after the class. + +`#[OA\Components]` remains as a class-level container for declaring several components on one +class, and for the historic spellings below. + +::: warning Historic spellings, deprecated +Before 6.11 each type spelled its key after itself: `schema: 'Pet'`, `parameter: 'page'`, +`request: 'Body'`, `securityScheme: 'api'`, and — only when declared as a component — +`response: 'NotFound'`, `header: 'RateLimit'`, `link: 'Self'`, `example: 'Minimal'`. They still +work, produce the same document, and trigger a deprecation; they are removed in 8.0. Only the +component use is deprecated: `response: 404` on a nested response, or `header: 'X-Rate-Limit'` +on a header inside a response, is the nesting key and stays. + +Classic is not affected and reports nothing. `OpenApi\Attributes` keeps `schema:`, +`parameter:`, `request:` and `securityScheme:` as its component keys, and hybrid mode +translates them to `component:` through the bridge. What this does change is the price of +moving a classic codebase onto `OpenApi\Spec`: it is no longer a change of `use` line and +nothing else, because every component key is renamed with it. See +[Migration path](/guide/modes#migration-path). ::: ## Inheritance diff --git a/docs/reference/spec-attributes.md b/docs/reference/spec-attributes.md index 5e31ec2d1..19812378a 100644 --- a/docs/reference/spec-attributes.md +++ b/docs/reference/spec-attributes.md @@ -33,10 +33,9 @@ Place on a class to declare standalone components that go into the components se of the OpenAPI document. The Components attribute itself is not emitted — its children are promoted to their respective Specification buckets. -The primary use case is for DTOs that are NOT roots and therefore cannot be declared -at class level on their own: Parameter, Header, Link, and Example. Other types -(Schema, PathItem, SecurityScheme, named Response/RequestBody) are already roots and -can be declared directly on a class without needing a Components wrapper. +Any attribute given a `component:` key is a root on its own and needs no wrapper. The +wrapper remains for the historic spellings — a Header or Example keyed by `header`/`example` +— and for grouping several components on one class. #[Components] class SharedComponents { @@ -73,6 +72,10 @@ can be declared directly on a class without needing a Components wrapper.

No details available.

examples : list<Example>

No details available.

+
pathItems : list<PathItem>
+

No details available.

+
mediaTypes : list<MediaType>
+

OpenAPI 3.2; earlier compilers omit them with a warning

### [Contact](https://github.com/zircote/swagger-php/tree/master/src/Spec/Contact.php) @@ -161,7 +164,7 @@ Describes an example value for a parameter, media type, or schema. ---
example : string|null
-

Reusable example identifier (component key)

+

The example name — the key the example nests under in a media type, parameter or header

summary : string|null

Short description of the example

description : string|null
@@ -172,6 +175,8 @@ Describes an example value for a parameter, media type, or schema.

A URI pointing to the literal example

ref : string|null

A JSON Reference to a reusable example

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -364,7 +369,7 @@ Describes a single HTTP header. ---
header : string|null
-

The header name (component key)

+

The header name — the key the header nests under in a response or an encoding

description : string|null

A brief description of the header (CommonMark syntax)

required : bool|null
@@ -385,6 +390,8 @@ Describes a single HTTP header.

Examples of the header's value

content : MediaType|list<MediaType>|null

Content-type based header serialization

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -457,7 +464,7 @@ Describes a possible design-time link for a response. ---
link : string|null
-

Reusable link identifier (component key)

+

The link name — the key the link nests under in a response

operationRef : string|null

A relative or absolute URI reference to a linked operation

operationId : string|null
@@ -472,6 +479,8 @@ Describes a possible design-time link for a response.

A JSON Reference to a reusable link

server : Server|null

A server object to be used by the target operation

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -503,6 +512,8 @@ Describes the content payload for a specific media type.

Examples of the media type content

encoding : list<Encoding>|array<string,Encoding>|null

Encoding information for specific properties

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1070,7 +1081,7 @@ Produces: ---
parameter : string|null
-

Reusable parameter identifier (component key)

+

Deprecated since 6.11, removed in 8.0 - use `component` instead

name : string|null

The name of the parameter

in : string|ParameterIn|null
@@ -1099,6 +1110,8 @@ Produces:

Examples of the parameter's value

content : MediaType|list<MediaType>|null

Content-type based parameter serialization

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1311,6 +1324,10 @@ responses, and parameters accumulate from the full ancestor chain. Deduplication is by value (tags), by scheme (security), by status code (responses), and by name+in (parameters). +#### Allowed in +--- +Components + #### Nested elements --- Parameter, Parameter\Cookie, Parameter\Header, Parameter\Path, Parameter\Query, Response, Security\Requirement, Server @@ -1336,6 +1353,8 @@ name+in (parameters).

Security requirements to clone to contained operations

responses : list<Response>|null

Shared responses to clone to contained operations

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1410,7 +1429,7 @@ Describes a single request body. ---
request : string|null
-

Reusable request body identifier (component key)

+

Deprecated since 6.11, removed in 8.0 - use `component` instead

description : string|null

A brief description of the request body (CommonMark syntax)

required : bool|null
@@ -1419,6 +1438,8 @@ Describes a single request body.

A JSON Reference to a reusable request body

content : MediaType|list<MediaType>|null

The content of the request body

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1441,7 +1462,7 @@ Describes a single response from an API operation. ---
response : string|int|null
-

The HTTP status code or 'default'

+

The HTTP status code, a range such as '2XX', or 'default' — the key the response nests under in an operation

description : string|null

A description of the response (CommonMark syntax)

ref : string|Schema\Ref|null
@@ -1452,6 +1473,8 @@ Describes a single response from an API operation.

Possible response payloads

links : list<Link>|null

Design-time links for the response

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1500,7 +1523,7 @@ without it the schema has no component key and is reported as missing one. ---
schema : string|null
-

Reusable schema identifier (component key)

+

Deprecated since 6.11, removed in 8.0 - use `component` instead

title : string|null

A title for the schema

description : string|null
@@ -1609,6 +1632,8 @@ without it the schema has no component key and is reported as missing one.

Additional external documentation

xml : Xml|null

XML representation metadata

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1636,7 +1661,7 @@ schemas with constrained additional properties: ---
schema : string|null
-

Reusable schema identifier (component key)

+

Deprecated since 6.11, removed in 8.0 - use `component` instead

title : string|null

A title for the schema

description : string|null
@@ -1745,6 +1770,8 @@ schemas with constrained additional properties:

Additional external documentation

xml : Xml|null

XML representation metadata

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

### [Schema\Items](https://github.com/zircote/swagger-php/tree/master/src/Spec/Schema/Items.php) @@ -1780,7 +1807,7 @@ Since Items extends Schema, the implicit `OA\Property` shortcut applies — no e ---
schema : string|null
-

Reusable schema identifier (component key)

+

Deprecated since 6.11, removed in 8.0 - use `component` instead

title : string|null

A title for the schema

description : string|null
@@ -1889,6 +1916,8 @@ Since Items extends Schema, the implicit `OA\Property` shortcut applies — no e

Additional external documentation

xml : Xml|null

XML representation metadata

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference @@ -1973,7 +2002,7 @@ Typed subtypes are available for each security scheme type: ---
securityScheme : string|null
-

Reusable security scheme identifier (component key)

+

Deprecated since 6.11, removed in 8.0 - use `component` instead

type : string|OA\SchemeType|null

The type of the security scheme (apiKey, http, mutualTLS, oauth2, openIdConnect)

description : string|null
@@ -1992,6 +2021,8 @@ Typed subtypes are available for each security scheme type:

The available OAuth2 flows (oauth2)

ref : string|null

A JSON Reference to a reusable security scheme

+
component : string|null
+

The key this is filed under in `components`, which makes it a reusable component

#### Reference diff --git a/src/Augmenter/Enums.php b/src/Augmenter/Enums.php index 3a2ef3052..f362012dc 100644 --- a/src/Augmenter/Enums.php +++ b/src/Augmenter/Enums.php @@ -72,7 +72,7 @@ protected function expandEnumSchemas(Specification $specification): void $enumName = $reflector->getName(); $reflector = new \ReflectionEnum($enumName); - $schema->schema ??= $reflector->getShortName(); + $schema->component ??= $reflector->getShortName(); $useName = $this->shouldUseName($schema, $reflector); diff --git a/src/Augmenter/Inheritance/Schemas.php b/src/Augmenter/Inheritance/Schemas.php index 27b80ea03..3dab4d89d 100644 --- a/src/Augmenter/Inheritance/Schemas.php +++ b/src/Augmenter/Inheritance/Schemas.php @@ -131,7 +131,9 @@ protected function expandInterfaces(OA\Schema $schema, \ReflectionClass $reflect protected function addAllOfRef(OA\Schema $schema, OA\Schema $referenced): void { $schema->allOf ??= []; - $name = $referenced->schema ?? $referenced->getShortClassName(); + // the explicit key or the class name, as `Names` will key it — not `of()`, whose + // title fallback would name a class schema after its title before `Names` has run + $name = $referenced->component ?? $referenced->getShortClassName(); if ($name !== null) { $schema->allOf[] = new OA\Schema(ref: JsonPointer::ref('components', 'schemas', $name)); } diff --git a/src/Augmenter/Names.php b/src/Augmenter/Names.php index a49490821..4c4ab1034 100644 --- a/src/Augmenter/Names.php +++ b/src/Augmenter/Names.php @@ -29,9 +29,11 @@ class Names implements PipeInterface { /** * A security scheme is only ever declared inside `Components`, where the key is the whole - * point, so nothing is inferred for it. + * point, so nothing is inferred for it. A path item on a class is path-bound unless keyed + * by hand, and a media type is only ever a component by hand, so neither is named after + * the class either. */ - protected const SKIP_BUCKETS = ['securitySchemes']; + protected const SKIP_BUCKETS = ['securitySchemes', 'pathItems', 'mediaTypes']; public function __invoke(mixed $payload): mixed { @@ -52,7 +54,7 @@ public function group(): string|\BackedEnum protected function inferParameterNames(Specification $specification): void { foreach ($specification->parameters as $parameter) { - $parameter->parameter ??= $parameter->name; + $parameter->component ??= $parameter->name; } } diff --git a/src/Augmenter/PathItems.php b/src/Augmenter/PathItems.php index 7b3b06fff..391bdc04c 100644 --- a/src/Augmenter/PathItems.php +++ b/src/Augmenter/PathItems.php @@ -153,6 +153,10 @@ protected function resolvePathItemPaths(Specification $specification, PathItemHi $pathsByPathItem = $this->collectOperationPaths($specification, $hierarchy); foreach ($specification->pathItems as $pathItem) { + if ($pathItem->component !== null) { + continue; + } + $this->mergeAncestorParameters($pathItem, $hierarchy); if (!$this->hasSpecProperties($pathItem)) { diff --git a/src/Augmenter/Refs.php b/src/Augmenter/Refs.php index 715c8a282..878fd2fc6 100644 --- a/src/Augmenter/Refs.php +++ b/src/Augmenter/Refs.php @@ -9,6 +9,7 @@ use OpenApi\Contracts\AttributeInterface; use OpenApi\Spec as OA; use OpenApi\Specification; +use OpenApi\Specification\ComponentName; use OpenApi\Utils\JsonPointer; use OpenApi\Utils\PipeInterface; use Psr\Log\LoggerAwareInterface; @@ -132,8 +133,9 @@ protected function resolveAllOfPropertyRefs(Specification $specification): void if ($schema->allOf !== null && $schema->properties === null) { foreach ($schema->allOf as $index => $allOf) { if ($allOf instanceof OA\Schema && $allOf->properties !== null) { - if ($schema->schema !== null) { - $candidates[$schema->schema] = $index; + $name = ComponentName::of($schema); + if ($name !== null) { + $candidates[$name] = $index; } } } diff --git a/src/Builder.php b/src/Builder.php index f21461ace..174d53f7f 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -301,10 +301,15 @@ protected function doBuildSpec(bool $hybrid = false): Result $hook($specification); } + // every producer has run and nothing has read a component key yet; the resolver's + // index is the first reader + Specification\ComponentName::normalise($specification, deprecate: true); + $this->getResolver()->resolve($assembler); if ($hybrid) { $this->doHybridAssemble($specification); + Specification\ComponentName::normalise($specification, deprecate: false); } // share the token scanner cache ... diff --git a/src/Compiler/OpenApi30Compiler.php b/src/Compiler/OpenApi30Compiler.php index 5f58d4f9c..3a09b9b12 100644 --- a/src/Compiler/OpenApi30Compiler.php +++ b/src/Compiler/OpenApi30Compiler.php @@ -61,6 +61,11 @@ public function validate(Specification $specification): array $this->logger->warning('mutualTLS security schemes are not supported in OpenAPI 3.0 and will be omitted'); } + $hasPathItemComponents = (bool) array_filter($specification->pathItems, fn (OA\PathItem $pathItem): bool => $pathItem->component !== null); + if ($hasPathItemComponents) { + $this->logger->warning('pathItems components are not supported in OpenAPI 3.0 and will be omitted'); + } + if ($specification->info?->license instanceof OA\License) { $license = $specification->info->license; if ($license->identifier !== null) { @@ -77,6 +82,18 @@ protected function compileWebhooks(array $operations): array return []; } + /** + * @return array + */ + #[\Override] + protected function compileComponents(Specification $specification): array + { + $components = parent::compileComponents($specification); + unset($components['pathItems']); + + return $components; + } + #[\Override] protected function compileSecuritySchemes(array $schemes): array { @@ -254,33 +271,33 @@ protected function validateSchemas(Specification $specification): void } if ($type === 'array' && $schema->items === null) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ' has type "array" but no items'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ' has type "array" but no items'); } $this->validateSchemaType($schema); if ($schema->prefixItems !== null) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ': prefixItems is not supported in OpenAPI 3.0'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ': prefixItems is not supported in OpenAPI 3.0'); } if ($schema->unevaluatedProperties !== null) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ': unevaluatedProperties is not supported in OpenAPI 3.0'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ': unevaluatedProperties is not supported in OpenAPI 3.0'); } if ($schema->unevaluatedItems !== null) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ': unevaluatedItems is not supported in OpenAPI 3.0'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ': unevaluatedItems is not supported in OpenAPI 3.0'); } if ($schema->if instanceof OA\Schema || $schema->then instanceof OA\Schema || $schema->else instanceof OA\Schema) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ': if/then/else is not supported in OpenAPI 3.0'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ': if/then/else is not supported in OpenAPI 3.0'); } if ($schema->const !== Undefined::UNDEFINED) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ': const is not supported in OpenAPI 3.0, using enum fallback'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ': const is not supported in OpenAPI 3.0, using enum fallback'); } if ($schema->examples !== null) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ': examples array is not supported in OpenAPI 3.0, using first value as example'); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ': examples array is not supported in OpenAPI 3.0, using first value as example'); } } } diff --git a/src/Compiler/OpenApi31Compiler.php b/src/Compiler/OpenApi31Compiler.php index 721ebdc8f..9ec128564 100644 --- a/src/Compiler/OpenApi31Compiler.php +++ b/src/Compiler/OpenApi31Compiler.php @@ -97,6 +97,8 @@ public function validate(Specification $specification): array $this->validateNames($specification); + $this->validateVersionedComponents($specification); + $this->validateNestedNames($specification); $this->validateSchemaExamples($specification); @@ -238,7 +240,7 @@ protected function compilePaths(array $operations, array $pathItems = []): array } foreach ($pathItems as $pathItem) { - if ($pathItem->path === null) { + if ($pathItem->path === null || $pathItem->component !== null) { continue; } @@ -254,6 +256,7 @@ protected function compilePaths(array $operations, array $pathItems = []): array protected function compilePathItem(OA\PathItem $pathItem): array { return $this->filter([ + '$ref' => $pathItem->ref, 'summary' => $pathItem->summary, 'description' => $pathItem->description, 'parameters' => array_map($this->compileParameter(...), $pathItem->parameters ?? []), @@ -671,9 +674,29 @@ protected function compileComponents(Specification $specification): array 'securitySchemes' => $this->compileSecuritySchemes($specification->securitySchemes), 'links' => $this->compileComponentMap($specification->links, $this->compileLink(...)), 'examples' => $this->compileComponentMap($specification->examples, $this->compileExample(...)), + 'pathItems' => $this->compileComponentMap($specification->pathItems, $this->compilePathItem(...)), + ...$this->compileVersionedComponents($specification), ]); } + /** + * The component buckets a later version adds. 3.1 has none past `pathItems`; 3.2 adds + * `mediaTypes`. Reported, not silently dropped, by `validateVersionedComponents()`. + * + * @return array + */ + protected function compileVersionedComponents(Specification $specification): array + { + return []; + } + + protected function validateVersionedComponents(Specification $specification): void + { + if ($specification->mediaTypes !== []) { + $this->logger->warning('mediaTypes components are not supported before OpenAPI 3.2 and will be omitted'); + } + } + /** * A version that does not support every scheme type filters here; the keying stays shared. * @@ -782,6 +805,10 @@ protected function validateNames(Specification $specification): void $name = ComponentName::of($component); if ($name === null) { + if ($component instanceof OA\PathItem) { + continue; // path-bound, keyed by its path under `paths` + } + $this->logger->warning(sprintf( '%s is missing key-field: "%s" in %s', $type, @@ -852,7 +879,7 @@ protected function validateSchemaExamples(Specification $specification): void $this->logger->warning(sprintf( 'Schema%s: examples takes values, not Example objects, in %s', - $schema->schema !== null ? " \"{$schema->schema}\"" : '', + $this->schemaLabel($schema), $example->getSourceLocation(), )); } @@ -866,7 +893,7 @@ protected function validateSchemas(Specification $specification): void foreach ($allSchemas as $schema) { if ($schema->type !== null && (is_array($schema->type) ? in_array('array', $schema->type, true) : $schema->type === 'array')) { if ($schema->items === null && $schema->prefixItems === null && $schema->contains === null) { - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . ' has type "array" but no items in ' . $schema->getSourceLocation()); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . ' has type "array" but no items in ' . $schema->getSourceLocation()); } } @@ -885,7 +912,7 @@ protected function validateSchemaType(OA\Schema $schema): void continue; } - $this->logger->warning('Schema' . ($schema->schema ? " \"$schema->schema\"" : '') . " has unknown type \"$type\", expecting one of " . implode(', ', static::SCHEMA_TYPES) . ' in ' . $schema->getSourceLocation()); + $this->logger->warning('Schema' . $this->schemaLabel($schema) . " has unknown type \"$type\", expecting one of " . implode(', ', static::SCHEMA_TYPES) . ' in ' . $schema->getSourceLocation()); } } @@ -1019,10 +1046,18 @@ protected function compileKeyedMap(array $items, string $keyField, \Closure $com * Those have a meaningful fallback; a missing name does not, so it goes to * {@see compileKeyedMap()} instead. * - * @param list $items - * @param string|\Closure $key Property name or fn($item, $index): string * @return array */ + /** + * The schema's key, quoted, for a diagnostic — or nothing, for an inline one. + */ + protected function schemaLabel(OA\Schema $schema): string + { + $name = ComponentName::of($schema); + + return $name !== null ? " \"$name\"" : ''; + } + protected function compileNamedMap(array $items, string|\Closure $key, \Closure $compiler): array { $result = []; diff --git a/src/Compiler/OpenApi32Compiler.php b/src/Compiler/OpenApi32Compiler.php index 77e44ecb5..71fb50d47 100644 --- a/src/Compiler/OpenApi32Compiler.php +++ b/src/Compiler/OpenApi32Compiler.php @@ -39,6 +39,22 @@ public function validate(Specification $specification): array return $this->logger->entries(); } + #[\Override] + protected function validateVersionedComponents(Specification $specification): void + { + } + + /** + * @return array + */ + #[\Override] + protected function compileVersionedComponents(Specification $specification): array + { + return [ + 'mediaTypes' => $this->compileComponentMap($specification->mediaTypes, $this->compileMediaType(...)), + ]; + } + /** * @return array */ diff --git a/src/HybridBridge.php b/src/HybridBridge.php index cf8d876aa..1578e73a6 100644 --- a/src/HybridBridge.php +++ b/src/HybridBridge.php @@ -421,7 +421,6 @@ protected function convertOperation(OA\Operation $op, ?string $path, string $met protected function convertParameter(OA\Parameter $param): Spec\Parameter { $result = new Spec\Parameter( - parameter: $this->val($param->parameter), name: $this->val($param->name), in: $this->val($param->in), description: Undefined::isDefault($param->description) ? Undefined::UNDEFINED : $param->description, @@ -438,6 +437,7 @@ protected function convertParameter(OA\Parameter $param): Spec\Parameter ? null : array_values(array_map($this->convertExample(...), $param->examples)), content: $this->resolveContent($param), + component: $this->val($param->parameter), x: $this->extensions($param), ); $this->copyReflector($param, $result); @@ -480,11 +480,11 @@ protected function convertResponse(OA\Response $response): Spec\Response protected function convertRequestBody(OA\RequestBody $body): Spec\RequestBody { $result = new Spec\RequestBody( - request: $this->val($body->request), description: $this->val($body->description), required: $this->val($body->required), ref: $this->val($body->ref), content: $this->resolveContent($body), + component: $this->val($body->request), x: $this->extensions($body), ); $this->copyReflector($body, $result); @@ -655,7 +655,6 @@ protected function convertSchema(OA\Schema $schema): Spec\Schema } $result = new Spec\Schema( - schema: $this->val($schema->schema), title: $this->val($schema->title), description: Undefined::isDefault($schema->description) ? Undefined::UNDEFINED : $schema->description, ref: $this->val($schema->ref) ?? $this->typeAsRef($schema->type), @@ -720,6 +719,7 @@ enum: Undefined::isDefault($schema->enum) ? null : (is_string($schema->enum) ? [ ? null : $this->convertExternalDocs($schema->externalDocs), xml: Undefined::isDefault($schema->xml) ? null : $this->convertXml($schema->xml), + component: $this->val($schema->schema), x: $this->extensions($schema), ); $this->copyReflector($schema, $result); @@ -818,7 +818,6 @@ protected function convertSecurityScheme(OA\SecurityScheme $scheme): Spec\Securi } $result = new Spec\Security\Scheme( - securityScheme: $this->val($scheme->securityScheme), type: $this->val($scheme->type), description: $this->val($scheme->description), name: $this->val($scheme->name), @@ -828,6 +827,7 @@ protected function convertSecurityScheme(OA\SecurityScheme $scheme): Spec\Securi openIdConnectUrl: $this->val($scheme->openIdConnectUrl), flows: $flows, ref: $this->val($scheme->ref), + component: $this->val($scheme->securityScheme), x: $this->extensions($scheme), ); $this->copyReflector($scheme, $result); diff --git a/src/Spec/Components.php b/src/Spec/Components.php index 8955186ec..b801427b6 100644 --- a/src/Spec/Components.php +++ b/src/Spec/Components.php @@ -13,10 +13,9 @@ * of the OpenAPI document. The Components attribute itself is not emitted — its children * are promoted to their respective Specification buckets. * - * The primary use case is for DTOs that are NOT roots and therefore cannot be declared - * at class level on their own: Parameter, Header, Link, and Example. Other types - * (Schema, PathItem, SecurityScheme, named Response/RequestBody) are already roots and - * can be declared directly on a class without needing a Components wrapper. + * Any attribute given a `component:` key is a root on its own and needs no wrapper. The + * wrapper remains for the historic spellings — a Header or Example keyed by `header`/`example` + * — and for grouping several components on one class. * * #[Components] * class SharedComponents { @@ -42,6 +41,8 @@ class Components extends AbstractAttribute * @param list $securitySchemes * @param list $links * @param list $examples + * @param list $pathItems + * @param list $mediaTypes OpenAPI 3.2; earlier compilers omit them with a warning * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -54,6 +55,8 @@ public function __construct( public array $securitySchemes = [], public array $links = [], public array $examples = [], + public array $pathItems = [], + public array $mediaTypes = [], ?array $x = null, ?array $attachables = null, ) { diff --git a/src/Spec/Example.php b/src/Spec/Example.php index 1e666a347..90866a35a 100644 --- a/src/Spec/Example.php +++ b/src/Spec/Example.php @@ -16,13 +16,17 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] class Example extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** - * @param string|null $example Reusable example identifier (component key) + * @param string|null $example The example name — the key the example nests under in a media type, parameter or header * @param string|null $summary Short description of the example * @param string|null $description Long description of the example (CommonMark syntax) * @param mixed $value Embedded literal example value * @param string|null $externalValue A URI pointing to the literal example * @param string|null $ref A JSON Reference to a reusable example + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -33,10 +37,17 @@ public function __construct( public mixed $value = Undefined::UNDEFINED, public ?string $externalValue = null, public string|Schema\Ref|null $ref = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; + } + + public function isRoot(): bool + { + return $this->component !== null; } public function merge(): array diff --git a/src/Spec/Header.php b/src/Spec/Header.php index 28b3912b3..9071e3fd5 100644 --- a/src/Spec/Header.php +++ b/src/Spec/Header.php @@ -16,13 +16,16 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Header extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + public ?string $style = null; /** @var list|null */ public ?array $content = null; /** - * @param string|null $header The header name (component key) + * @param string|null $header The header name — the key the header nests under in a response or an encoding * @param string|null $description A brief description of the header (CommonMark syntax) * @param bool|null $required Whether the header is mandatory * @param bool|null $deprecated Whether the header is deprecated @@ -33,6 +36,7 @@ class Header extends AbstractAttribute * @param mixed $example Example of the header's value * @param list|null $examples Examples of the header's value * @param MediaType|list|null $content Content-type based header serialization + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -48,14 +52,21 @@ public function __construct( public mixed $example = Undefined::UNDEFINED, public ?array $examples = null, MediaType|array|null $content = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; $this->style = $style instanceof \BackedEnum ? $style->value : $style; $this->content = self::wrapList($content); } + public function isRoot(): bool + { + return $this->component !== null; + } + public function merge(): array { return [ diff --git a/src/Spec/Link.php b/src/Spec/Link.php index ef95e3108..ecd9f3878 100644 --- a/src/Spec/Link.php +++ b/src/Spec/Link.php @@ -16,8 +16,11 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Link extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** - * @param string|null $link Reusable link identifier (component key) + * @param string|null $link The link name — the key the link nests under in a response * @param string|null $operationRef A relative or absolute URI reference to a linked operation * @param string|null $operationId The name of an existing operation (mutually exclusive with operationRef) * @param array|null $parameters Values to pass to the linked operation's parameters @@ -25,6 +28,7 @@ class Link extends AbstractAttribute * @param string|null $description A description of the link (CommonMark syntax) * @param string|null $ref A JSON Reference to a reusable link * @param Server|null $server A server object to be used by the target operation + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -37,15 +41,17 @@ public function __construct( public ?string $description = null, public string|Schema\Ref|null $ref = null, public ?Server $server = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; } public function isRoot(): bool { - return $this->link !== null && $this->ref === null; + return $this->ref === null && ($this->component !== null || $this->link !== null); } public function merge(): array diff --git a/src/Spec/MediaType.php b/src/Spec/MediaType.php index 38dc0a273..75326e56f 100644 --- a/src/Spec/MediaType.php +++ b/src/Spec/MediaType.php @@ -16,12 +16,16 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class MediaType extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** * @param string|null $mediaType The media type identifier (e.g. 'application/json') * @param Schema|null $schema The schema defining the content * @param mixed $example Example of the media type content * @param list|null $examples Examples of the media type content * @param list|array|null $encoding Encoding information for specific properties + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -31,15 +35,23 @@ public function __construct( public mixed $example = Undefined::UNDEFINED, public ?array $examples = null, public ?array $encoding = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; + } + + public function isRoot(): bool + { + return $this->component !== null; } public function merge(): array { return [ + Components::class => 'mediaTypes[]', Response::class => 'content[]', RequestBody::class => 'content[]', Parameter::class => 'content[]', diff --git a/src/Spec/Parameter.php b/src/Spec/Parameter.php index fde460952..44a37b4aa 100644 --- a/src/Spec/Parameter.php +++ b/src/Spec/Parameter.php @@ -42,6 +42,9 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] class Parameter extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + public ?string $in = null; public ?string $style = null; @@ -50,7 +53,7 @@ class Parameter extends AbstractAttribute public ?array $content = null; /** - * @param string|null $parameter Reusable parameter identifier (component key) + * @param string|null $parameter Deprecated since 6.11, removed in 8.0 - use `component` instead * @param string|null $name The name of the parameter * @param string|ParameterIn|null $in The location of the parameter (query, header, path, cookie) * @param string|null $description A brief description of the parameter (CommonMark syntax) @@ -65,6 +68,7 @@ class Parameter extends AbstractAttribute * @param mixed $example Example of the parameter's value * @param list|null $examples Examples of the parameter's value * @param MediaType|list|null $content Content-type based parameter serialization + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -84,10 +88,16 @@ public function __construct( public mixed $example = Undefined::UNDEFINED, public ?array $examples = null, MediaType|array|null $content = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; + if ($component === null && $this->parameter !== null) { + trigger_deprecation('zircote/swagger-php', '6.11', '`parameter` is deprecated as the component key of %s and will be removed in 8.0; use `component`', static::class); + $this->component = $this->parameter; + } $this->in = $in instanceof \BackedEnum ? $in->value : $in; $this->style = $style instanceof \BackedEnum ? $style->value : $style; $this->content = self::wrapList($content); @@ -95,7 +105,7 @@ public function __construct( public function isRoot(): bool { - return $this->ref === null && $this->parameter !== null; + return $this->ref === null && $this->component !== null; } public function merge(): array diff --git a/src/Spec/PathItem.php b/src/Spec/PathItem.php index 396b3030e..96969eabd 100644 --- a/src/Spec/PathItem.php +++ b/src/Spec/PathItem.php @@ -52,6 +52,9 @@ #[\Attribute(\Attribute::TARGET_CLASS)] class PathItem extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** Resolved path — set by augmenter or HybridBridge, not user-authored. */ public ?string $path = null; @@ -65,6 +68,7 @@ class PathItem extends AbstractAttribute * @param list|null $tags Tags to clone to contained operations * @param list|null $security Security requirements to clone to contained operations * @param list|null $responses Shared responses to clone to contained operations + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -78,14 +82,23 @@ public function __construct( public ?array $tags = null, public ?array $security = null, public ?array $responses = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; } public function isRoot(): bool { return true; } + + public function merge(): array + { + return [ + Components::class => 'pathItems[]', + ]; + } } diff --git a/src/Spec/RequestBody.php b/src/Spec/RequestBody.php index 5f38762a5..67267fb21 100644 --- a/src/Spec/RequestBody.php +++ b/src/Spec/RequestBody.php @@ -14,15 +14,19 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] class RequestBody extends AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** @var list|null */ public ?array $content = null; /** - * @param string|null $request Reusable request body identifier (component key) + * @param string|null $request Deprecated since 6.11, removed in 8.0 - use `component` instead * @param string|null $description A brief description of the request body (CommonMark syntax) * @param bool|null $required Whether the request body is required * @param string|Schema\Ref|null $ref A JSON Reference to a reusable request body * @param MediaType|list|null $content The content of the request body + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -32,16 +36,22 @@ public function __construct( public ?bool $required = null, public string|Schema\Ref|null $ref = null, MediaType|array|null $content = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; + if ($component === null && $this->request !== null) { + trigger_deprecation('zircote/swagger-php', '6.11', '`request` is deprecated as the component key of %s and will be removed in 8.0; use `component`', static::class); + $this->component = $this->request; + } $this->content = self::wrapList($content); } public function isRoot(): bool { - return $this->request !== null; + return $this->component !== null; } public function merge(): array diff --git a/src/Spec/Response.php b/src/Spec/Response.php index 9929cc66a..df5b81d80 100644 --- a/src/Spec/Response.php +++ b/src/Spec/Response.php @@ -21,16 +21,20 @@ class Response extends AbstractAttribute */ public const STATUS_CODE_PATTERN = '/^(default|[1-5][0-9]{2}|[1-5]XX)$/'; + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** @var list|null */ public ?array $content = null; /** - * @param string|int|null $response The HTTP status code or 'default' + * @param string|int|null $response The HTTP status code, a range such as '2XX', or 'default' — the key the response nests under in an operation * @param string|null $description A description of the response (CommonMark syntax) * @param string|Schema\Ref|null $ref A JSON Reference to a reusable response * @param list
|null $headers Headers sent with the response * @param MediaType|list|null $content Possible response payloads * @param list|null $links Design-time links for the response + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -41,16 +45,18 @@ public function __construct( public ?array $headers = null, MediaType|array|null $content = null, public ?array $links = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; $this->content = self::wrapList($content); } public function isRoot(): bool { - return $this->ref === null && $this->response !== null; + return $this->ref === null && ($this->component !== null || $this->response !== null); } public function merge(): array diff --git a/src/Spec/Schema.php b/src/Spec/Schema.php index 76ab1e7c6..871b67561 100644 --- a/src/Spec/Schema.php +++ b/src/Spec/Schema.php @@ -57,8 +57,11 @@ class Schema extends AbstractAttribute 'unevaluatedItems', ]; + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + /** - * @param string|null $schema Reusable schema identifier (component key) + * @param string|null $schema Deprecated since 6.11, removed in 8.0 - use `component` instead * @param string|null $title A title for the schema * @param string|null $description A description of the schema (CommonMark syntax) * @param string|null $ref A JSON Reference to a reusable schema @@ -113,6 +116,7 @@ class Schema extends AbstractAttribute * @param Discriminator|null $discriminator Discriminator for polymorphism * @param ExternalDocumentation|null $externalDocs Additional external documentation * @param Xml|null $xml XML representation metadata + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -197,10 +201,16 @@ public function __construct( public ?Discriminator $discriminator = null, public ?ExternalDocumentation $externalDocs = null, public ?Xml $xml = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; + if ($component === null && $this->schema !== null) { + trigger_deprecation('zircote/swagger-php', '6.11', '`schema` is deprecated as the component key of %s and will be removed in 8.0; use `component`', static::class); + $this->component = $this->schema; + } } public function isRoot(): bool diff --git a/src/Spec/Security/Scheme.php b/src/Spec/Security/Scheme.php index b6d1e3c77..9fca0d184 100644 --- a/src/Spec/Security/Scheme.php +++ b/src/Spec/Security/Scheme.php @@ -26,12 +26,15 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] class Scheme extends OA\AbstractAttribute { + /** The key this attribute is filed under in `components`; null for an inline one. */ + public ?string $component = null; + public ?string $type = null; public ?string $in = null; /** - * @param string|null $securityScheme Reusable security scheme identifier (component key) + * @param string|null $securityScheme Deprecated since 6.11, removed in 8.0 - use `component` instead * @param string|OA\SchemeType|null $type The type of the security scheme (apiKey, http, mutualTLS, oauth2, openIdConnect) * @param string|null $description A description of the security scheme (CommonMark syntax) * @param string|null $name The name of the header, query, or cookie parameter (apiKey) @@ -41,6 +44,7 @@ class Scheme extends OA\AbstractAttribute * @param string|null $openIdConnectUrl The OpenID Connect URL to discover configuration (openIdConnect) * @param list|null $flows The available OAuth2 flows (oauth2) * @param string|null $ref A JSON Reference to a reusable security scheme + * @param string|null $component The key this is filed under in `components`, which makes it a reusable component * @param array|null $x Vendor extensions (x-* properties) * @param list|null $attachables Reusable custom attachable attributes */ @@ -55,10 +59,16 @@ public function __construct( public ?string $openIdConnectUrl = null, public ?array $flows = null, public string|OA\Schema\Ref|null $ref = null, + ?string $component = null, ?array $x = null, ?array $attachables = null, ) { parent::__construct(x: $x, attachables: $attachables); + $this->component = $component; + if ($component === null && $this->securityScheme !== null) { + trigger_deprecation('zircote/swagger-php', '6.11', '`securityScheme` is deprecated as the component key of %s and will be removed in 8.0; use `component`', static::class); + $this->component = $this->securityScheme; + } $this->type = $type instanceof \BackedEnum ? $type->value : $type; $this->in = $in instanceof \BackedEnum ? $in->value : $in; } diff --git a/src/Specification.php b/src/Specification.php index 8ce489325..1435a7081 100644 --- a/src/Specification.php +++ b/src/Specification.php @@ -60,6 +60,9 @@ class Specification /** @var list */ public array $examples = []; + /** @var list reusable media types, OpenAPI 3.2 — only a keyed MediaType is root and lands here */ + public array $mediaTypes = []; + /** @var list */ public array $attachables = []; @@ -87,6 +90,7 @@ public function add(AttributeInterface ...$attributes): static $attribute instanceof OA\Security\Scheme => $this->securitySchemes[] = $attribute, $attribute instanceof OA\Link => $this->links[] = $attribute, $attribute instanceof OA\Example => $this->examples[] = $attribute, + $attribute instanceof OA\MediaType => $this->mediaTypes[] = $attribute, $attribute instanceof OA\Attachable => $this->attachables[] = $attribute, $attribute instanceof OA\Components => $this->addComponentsChildren($attribute), default => throw OpenApiException::fromSource( diff --git a/src/Specification/ComponentName.php b/src/Specification/ComponentName.php index 3dc9bb2cc..a0073e588 100644 --- a/src/Specification/ComponentName.php +++ b/src/Specification/ComponentName.php @@ -8,6 +8,7 @@ use OpenApi\Contracts\AttributeInterface; use OpenApi\Spec as OA; +use OpenApi\Specification; /** * The key a component is filed under in its bucket. @@ -24,43 +25,112 @@ class ComponentName { /** * Every `components` bucket, named as both the `Specification` property holding it and the - * segment a `$ref` to it carries. + * segment a `$ref` to it carries. `pathItems` doubles as the `paths` source: a `PathItem` + * without a key is path-bound and is not a component. */ - public const BUCKETS = ['schemas', 'responses', 'parameters', 'requestBodies', 'headers', 'securitySchemes', 'links', 'examples']; + public const BUCKETS = ['schemas', 'responses', 'parameters', 'requestBodies', 'headers', 'securitySchemes', 'links', 'examples', 'pathItems', 'mediaTypes']; /** * The key, or null when the component has none and cannot be referenced. + * + * One field on every type, `component`. The historic per-type spellings — `schema`, + * `parameter`, `request`, `securityScheme` — alias onto it in their constructors. The + * ones that are also a nesting key — `response`, `header`, `link`, `example` — cannot: + * the object does not know at construction whether it is nested. This is only ever asked + * of an item in a root bucket, where it is not nested, so here the historic key *is* the + * component key and is read as a fallback. `normalise()` writes the same answer into + * `component` once per build, which is where the spelling is reported as deprecated. */ public static function of(AttributeInterface $item): ?string { - return match (true) { - $item instanceof OA\Schema => $item->schema ?? $item->title, - $item instanceof OA\Response => $item->response !== null ? (string) $item->response : null, - $item instanceof OA\Parameter => $item->parameter ?? $item->name, - $item instanceof OA\RequestBody => $item->request, - $item instanceof OA\Header => $item->header, - $item instanceof OA\Security\Scheme => $item->securityScheme, - $item instanceof OA\Link => $item->link, - $item instanceof OA\Example => $item->example, - default => null, - }; + if (!self::isComponentType($item)) { + return null; + } + + return $item->component ?? self::inferred($item)[1]; } /** * The constructor field a missing key is reported against, for diagnostics. */ public static function keyField(AttributeInterface $item): ?string + { + return self::of($item) === null && !self::isComponentType($item) ? null : 'component'; + } + + /** + * Whether the type can carry a component key at all. + */ + public static function isComponentType(AttributeInterface $item): bool + { + return $item instanceof OA\Schema + || $item instanceof OA\Response + || $item instanceof OA\Parameter + || $item instanceof OA\RequestBody + || $item instanceof OA\Header + || $item instanceof OA\Security\Scheme + || $item instanceof OA\Link + || $item instanceof OA\Example + || $item instanceof OA\PathItem + || $item instanceof OA\MediaType; + } + + /** + * Fill `component` on every root component that still spells its key the historic way. + * + * `response`, `header`, `link` and `example` are the nesting key when the attribute is + * nested and the component key when it is not, and the object cannot tell the two apart + * at construction — nesting is structural, and a nested attribute never reaches a root + * slot. Here it has: anything in a root bucket is a component, so its historic key is the + * component key and aliases across. The one inference that reads a value field — + * `Parameter::$name` — lives here for the same reason. + * + * Runs before the resolver builds the first `ComponentIndex`, and again after the hybrid + * bridge adds classic-derived attributes; only the first pass reports the spelling as + * deprecated, since the bridge's callers never wrote it. A `Specification` that never + * passes through here — hand-built and compiled directly — still keys correctly, because + * `of()` reads the same fallback; it only misses the deprecation notice. + */ + public static function normalise(Specification $specification, bool $deprecate): void + { + foreach (self::BUCKETS as $bucket) { + foreach ($specification->{$bucket} as $item) { + if (!self::isComponentType($item) || $item->component !== null) { + continue; + } + + [$field, $value] = self::inferred($item); + if ($value === null) { + continue; + } + + if ($field !== null && $deprecate) { + trigger_deprecation('zircote/swagger-php', '6.11', '`%s` is deprecated as the component key of %s and will be removed in 8.0; use `component`', $field, $item::class); + } + + $item->component = $value; + } + } + } + + /** + * What `component` falls back to: the historic spelling for the four nesting-key types, + * and the one inference that reads a value field — a parameter is keyed by its name. The + * field name is reported when the spelling is deprecated, and is null for the inference, + * which is not. A schema used to fall back to its title; nothing relied on it, and a + * keyless schema is better reported than silently named. + * + * @return array{string|null, string|null} [field, value] + */ + protected static function inferred(AttributeInterface $item): array { return match (true) { - $item instanceof OA\Schema => 'schema', - $item instanceof OA\Response => 'response', - $item instanceof OA\Parameter => 'parameter', - $item instanceof OA\RequestBody => 'request', - $item instanceof OA\Header => 'header', - $item instanceof OA\Security\Scheme => 'securityScheme', - $item instanceof OA\Link => 'link', - $item instanceof OA\Example => 'example', - default => null, + $item instanceof OA\Response => ['response', $item->response === null ? null : (string) $item->response], + $item instanceof OA\Header => ['header', $item->header], + $item instanceof OA\Link => ['link', $item->link], + $item instanceof OA\Example => ['example', $item->example], + $item instanceof OA\Parameter => [null, $item->name], + default => [null, null], }; } } diff --git a/src/Specification/PathItemHierarchy.php b/src/Specification/PathItemHierarchy.php index b886bb18b..24b50fe51 100644 --- a/src/Specification/PathItemHierarchy.php +++ b/src/Specification/PathItemHierarchy.php @@ -37,6 +37,10 @@ public function __construct( protected Specification $specification, ) { foreach ($specification->pathItems as $pathItem) { + if ($pathItem->component !== null) { + continue; // a reusable path item, filed under components; it governs no class + } + $className = $pathItem->getClassName(); if ($className !== null) { $this->classToPathItem[$className] = $pathItem; diff --git a/tests/AssemblerTest.php b/tests/AssemblerTest.php index e04a63f25..0ba613b9c 100644 --- a/tests/AssemblerTest.php +++ b/tests/AssemblerTest.php @@ -19,7 +19,7 @@ public function testHierarchyPropertyAbsorbedBySchema(): void $spec = $assembler->getSpecification(); $this->assertCount(1, $spec->schemas); - $this->assertEquals('SimpleProduct', $spec->schemas[0]->schema); + $this->assertEquals('SimpleProduct', $spec->schemas[0]->component); $this->assertNotNull($spec->schemas[0]->properties); $this->assertCount(2, $spec->schemas[0]->properties); } diff --git a/tests/Augmenter/EnumsTest.php b/tests/Augmenter/EnumsTest.php index 6af846495..fba3e39f9 100644 --- a/tests/Augmenter/EnumsTest.php +++ b/tests/Augmenter/EnumsTest.php @@ -24,7 +24,7 @@ public function testBasicEnumUsesNames(): void (new Augmenter\Enums())($spec); $schema = $spec->schemas[0]; - $this->assertSame('BasicEnum', $schema->schema); + $this->assertSame('BasicEnum', $schema->component); $this->assertSame('string', $schema->type); $this->assertSame(['GREEN', 'BLUE', 'RED'], $schema->enum); } @@ -36,7 +36,7 @@ public function testBackedEnumWithoutTypeUsesNames(): void (new Augmenter\Enums())($spec); $schema = $spec->schemas[0]; - $this->assertSame('BackedStringEnum', $schema->schema); + $this->assertSame('BackedStringEnum', $schema->component); $this->assertSame('string', $schema->type); $this->assertSame(['ACTIVE', 'INACTIVE'], $schema->enum); } @@ -48,7 +48,7 @@ public function testBackedEnumWithMatchingTypeUsesValues(): void (new Augmenter\Enums())($spec); $schema = $spec->schemas[0]; - $this->assertSame('BackedIntEnum', $schema->schema); + $this->assertSame('BackedIntEnum', $schema->component); $this->assertSame('integer', $schema->type); $this->assertSame([1, 2, 3], $schema->enum); } @@ -118,10 +118,10 @@ enum: [Fixtures\Augmenter\BasicEnum::class], public function testPreservesExplicitSchemaName(): void { $spec = $this->assemble(Fixtures\Augmenter\BasicEnum::class); - $spec->schemas[0]->schema = 'CustomName'; + $spec->schemas[0]->component = 'CustomName'; (new Augmenter\Enums())($spec); - $this->assertSame('CustomName', $spec->schemas[0]->schema); + $this->assertSame('CustomName', $spec->schemas[0]->component); } } diff --git a/tests/Augmenter/NamesTest.php b/tests/Augmenter/NamesTest.php index cf58bb13d..343c076a8 100644 --- a/tests/Augmenter/NamesTest.php +++ b/tests/Augmenter/NamesTest.php @@ -23,7 +23,7 @@ public function testInfersSchemaNameFromClass(): void (new Augmenter\Names())($spec); - $this->assertSame('TypeSchema', $spec->schemas[0]->schema); + $this->assertSame('TypeSchema', $spec->schemas[0]->component); } public function testInfersSchemaNameFromEnum(): void @@ -32,17 +32,17 @@ public function testInfersSchemaNameFromEnum(): void (new Augmenter\Names())($spec); - $this->assertSame('BasicEnum', $spec->schemas[0]->schema); + $this->assertSame('BasicEnum', $spec->schemas[0]->component); } public function testPreservesExplicitSchemaName(): void { $spec = $this->assemble(Fixtures\Augmenter\TypeSchema::class); - $spec->schemas[0]->schema = 'CustomName'; + $spec->schemas[0]->component = 'CustomName'; (new Augmenter\Names())($spec); - $this->assertSame('CustomName', $spec->schemas[0]->schema); + $this->assertSame('CustomName', $spec->schemas[0]->component); } public function testInfersParameterKeyFromName(): void @@ -53,7 +53,7 @@ public function testInfersParameterKeyFromName(): void (new Augmenter\Names())($spec); - $this->assertSame('page', $param->parameter); + $this->assertSame('page', $param->component); } public function testPreservesExplicitParameterKey(): void @@ -64,6 +64,6 @@ public function testPreservesExplicitParameterKey(): void (new Augmenter\Names())($spec); - $this->assertSame('custom', $param->parameter); + $this->assertSame('custom', $param->component); } } diff --git a/tests/Augmenter/RefsTest.php b/tests/Augmenter/RefsTest.php index ff33a4b90..78e46dd92 100644 --- a/tests/Augmenter/RefsTest.php +++ b/tests/Augmenter/RefsTest.php @@ -130,7 +130,7 @@ public function testResolvesDiscriminatorMapping(): void $discriminatorSchema = null; foreach ($spec->schemas as $schema) { - if ($schema->schema === 'DiscriminatorSchema') { + if ($schema->component === 'DiscriminatorSchema') { $discriminatorSchema = $schema; break; } diff --git a/tests/Augmenter/TypesTest.php b/tests/Augmenter/TypesTest.php index c082086fe..97e949b04 100644 --- a/tests/Augmenter/TypesTest.php +++ b/tests/Augmenter/TypesTest.php @@ -23,7 +23,7 @@ public function testInfersPropertyTypes(): void (new Augmenter\Types())($spec); $schema = $spec->schemas[0]; - $this->assertSame('TypeSchema', $schema->schema); + $this->assertSame('TypeSchema', $schema->component); $props = []; foreach ($schema->properties as $property) { diff --git a/tests/Builder/ReusableComponentsTest.php b/tests/Builder/ReusableComponentsTest.php new file mode 100644 index 000000000..f56b7587a --- /dev/null +++ b/tests/Builder/ReusableComponentsTest.php @@ -0,0 +1,181 @@ + */ + private array $deprecations = []; + + protected function setUp(): void + { + $this->deprecations = []; + set_error_handler(function (int $errno, string $message): bool { + if ($errno === E_USER_DEPRECATED) { + $this->deprecations[] = $message; + } + + return true; + }); + } + + protected function tearDown(): void + { + restore_error_handler(); + } + + public function testBothSpellingsProduceOneDocument(): void + { + $legacy = $this->build(['LegacySpelling'])->toArray(); + $reported = $this->deprecations; + + $this->deprecations = []; + $current = $this->build(['ComponentSpelling'])->toArray(); + + $this->assertSame($current, $legacy, 'the alias is exact'); + $this->assertSame([], $this->deprecations, 'the current spelling reports nothing'); + + sort($reported); + $fields = array_values(array_unique(array_map(fn (string $m): string => explode('`', $m)[1], $reported))); + $this->assertSame(['example', 'header', 'link', 'parameter', 'request', 'response', 'schema'], $fields, 'every historic spelling used is reported'); + + foreach (['schemas' => 'Pet', 'responses' => 'NotFound', 'requestBodies' => 'PetBody', 'parameters' => 'page', 'headers' => 'RateLimit', 'links' => 'Self', 'examples' => 'Minimal'] as $bucket => $key) { + $this->assertArrayHasKey($key, $current['components'][$bucket], $bucket); + } + } + + public function testTheNestingKeysAreNotReported(): void + { + $document = $this->build(['ComponentSpelling'])->toArray(); + $response = $document['paths']['/pets/{id}']['post']['responses']; + + $this->assertSame([], $this->deprecations); + $this->assertSame('#/components/headers/RateLimit', $response[200]['headers']['X-Rate-Limit']['$ref'], '`header:` on a nested header is the header name'); + $this->assertSame('#/components/links/Self', $response[200]['links']['self']['$ref']); + $this->assertSame('#/components/examples/Minimal', $response[200]['content']['application/json']['examples']['minimal']['$ref']); + $this->assertSame('#/components/responses/NotFound', $response[404]['$ref'], '`response:` on a nested response is the status code'); + } + + public function testAKeyedHeaderStandsAlone(): void + { + $document = $this->build(['StandaloneHeader'])->toArray(); + + $this->assertSame('Requests left', $document['components']['headers']['RateLimit']['description']); + $this->assertSame('#/components/headers/RateLimit', $document['paths']['/things']['get']['responses'][200]['headers']['X-Rate-Limit']['$ref']); + } + + public function testAKeyedPathItemIsAComponentAndGovernsNothing(): void + { + $result = $this->build(['SharedPathItem'], configure: fn (Builder $b): Builder => $b->withAugmenters(fn (Pipeline $p) => $p->get(Augmenter\Cleanup::class)?->setEnabled(false))); + $document = $result->toArray(); + + $this->assertSame(['/api/things'], array_keys($document['paths']), 'the prefixed path, and no path for the component'); + $this->assertSame('A paged listing', $document['components']['pathItems']['Paged']['summary']); + $this->assertSame('page', $document['components']['pathItems']['Paged']['parameters'][0]['name']); + $this->assertArrayNotHasKey('parameters', $document['paths']['/api/things']['get'], 'the component lends its parameters to no operation'); + $this->assertSame([], $result->warnings()); + } + + public function testAReferencedPathItemKeepsItsComponentThroughCleanup(): void + { + $result = (new Builder()) + ->setMode(Mode::SPEC) + ->withSpecification(function (Specification $specification): void { + $shared = new OA\PathItem(summary: 'A paged listing', component: 'Paged'); + $user = new OA\PathItem(ref: '#/components/pathItems/Paged'); + $user->path = '/users'; + $specification->add(new OA\Info(title: 'Ref', version: '1.0'), $shared, $user); + }) + ->build(); + $document = $result->toArray(); + + $this->assertSame('#/components/pathItems/Paged', $document['paths']['/users']['$ref']); + $this->assertSame('A paged listing', $document['components']['pathItems']['Paged']['summary']); + } + + public function testAPathItemComponentIsReportedAndOmittedIn30(): void + { + $result = $this->build(['SharedPathItem'], '3.0.0', fn (Builder $b): Builder => $b->withAugmenters(fn (Pipeline $p) => $p->get(Augmenter\Cleanup::class)?->setEnabled(false))); + + $this->assertArrayNotHasKey('components', $result->toArray()); + $this->assertContains('pathItems components are not supported in OpenAPI 3.0 and will be omitted', $result->warnings()); + } + + /** + * @return iterable + */ + public static function mediaTypeVersions(): iterable + { + yield '3.0.0' => ['3.0.0', false]; + yield '3.1.0' => ['3.1.0', false]; + yield '3.2.0' => ['3.2.0', true]; + } + + #[DataProvider('mediaTypeVersions')] + public function testAKeyedMediaTypeIsAComponentFrom32(string $version, bool $supported): void + { + $result = (new Builder()) + ->setMode(Mode::SPEC) + ->setVersion($version) + ->withAugmenters(fn (Pipeline $p) => $p->get(Augmenter\Cleanup::class)?->setEnabled(false)) + ->withSpecification(function (Specification $specification): void { + $specification->add( + new OA\Info(title: 'Patch', version: '1.0'), + new OA\Operation\Get(path: '/things', responses: [new OA\Response(response: 200, description: 'Things')]), + new OA\MediaType(mediaType: 'application/json-patch+json', schema: new OA\Schema(type: 'array'), component: 'Patch'), + ); + }) + ->build(); + $document = $result->toArray(); + + if ($supported) { + $this->assertSame('array', $document['components']['mediaTypes']['Patch']['schema']['type']); + $this->assertNotContains('mediaTypes components are not supported before OpenAPI 3.2 and will be omitted', $result->warnings()); + } else { + $this->assertArrayNotHasKey('components', $document); + $this->assertContains('mediaTypes components are not supported before OpenAPI 3.2 and will be omitted', $result->warnings()); + } + } + + /** + * The fixtures hold several classes per file — a component and the controller that + * references it — so they are scanned as files. + * + * @param list $files + */ + private function build(array $files, string $version = '3.1.0', ?callable $configure = null): Builder\Result + { + $builder = (new Builder())->setMode(Mode::SPEC)->setVersion($version); + foreach ($files as $file) { + $path = self::fixture("ComponentKey/{$file}.php"); + require_once $path; // fixtures are excluded from the classmap, as ScratchTest's are + $builder->addSource($path); + } + if ($configure !== null) { + $configure($builder); + } + + return $builder->build(); + } +} diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php index 3642c7447..46b8b2bd0 100644 --- a/tests/CompilerTest.php +++ b/tests/CompilerTest.php @@ -512,7 +512,7 @@ public static function validationProvider(): iterable yield 'component schema without a name' => [ new OpenApi31Compiler(), $specUnnamedSchema, - 'Schema is missing key-field: "schema" in unknown', + 'Schema is missing key-field: "component" in unknown', ]; $specUnnamedProperty = new Specification(); @@ -534,7 +534,7 @@ public static function validationProvider(): iterable yield 'component header without a name' => [ new OpenApi31Compiler(), $specUnnamedHeader, - 'Header is missing key-field: "header" in unknown', + 'Header is missing key-field: "component" in unknown', ]; $specNestedUnnamed = new Specification(); diff --git a/tests/Concerns/AssertsSchemaStructure.php b/tests/Concerns/AssertsSchemaStructure.php index c1eb72825..15c74874f 100644 --- a/tests/Concerns/AssertsSchemaStructure.php +++ b/tests/Concerns/AssertsSchemaStructure.php @@ -115,7 +115,7 @@ function (OA\Schema $s): ?string { protected function findSchemaByName(Specification $specification, string $name): ?OA\Schema { foreach ($specification->schemas as $schema) { - if ($schema->schema === $name) { + if ($schema->component === $name) { return $schema; } diff --git a/tests/ExtensionPointsSnippetTest.php b/tests/ExtensionPointsSnippetTest.php index 87fdfd8b5..649195b5e 100644 --- a/tests/ExtensionPointsSnippetTest.php +++ b/tests/ExtensionPointsSnippetTest.php @@ -61,7 +61,7 @@ public function testSubclassedAttributeDerivesSchemaAndRequired(): void $schema = $result->specification()->schemas[0]; - $this->assertSame('Pet', $schema->schema); + $this->assertSame('Pet', $schema->component); $this->assertSame(['name', 'age'], $schema->required); } } diff --git a/tests/Fixtures/Assembler/AmbiguousMerge.php b/tests/Fixtures/Assembler/AmbiguousMerge.php index 3a1d24893..b89f37f0b 100644 --- a/tests/Fixtures/Assembler/AmbiguousMerge.php +++ b/tests/Fixtures/Assembler/AmbiguousMerge.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'AmbiguousMerge')] +#[OA\Schema(component: 'AmbiguousMerge')] class AmbiguousMerge { #[OA\Schema(description: 'ambiguous')] diff --git a/tests/Fixtures/Assembler/ImplicitPropertyProduct.php b/tests/Fixtures/Assembler/ImplicitPropertyProduct.php index 5aa1c9952..5c078126d 100644 --- a/tests/Fixtures/Assembler/ImplicitPropertyProduct.php +++ b/tests/Fixtures/Assembler/ImplicitPropertyProduct.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'ImplicitPropertyProduct')] +#[OA\Schema(component: 'ImplicitPropertyProduct')] class ImplicitPropertyProduct { #[OA\Schema(format: 'int64')] diff --git a/tests/Fixtures/Assembler/PromotedProduct.php b/tests/Fixtures/Assembler/PromotedProduct.php index 20a0fa74c..fe5461c13 100644 --- a/tests/Fixtures/Assembler/PromotedProduct.php +++ b/tests/Fixtures/Assembler/PromotedProduct.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'PromotedProduct')] +#[OA\Schema(component: 'PromotedProduct')] class PromotedProduct { public function __construct( diff --git a/tests/Fixtures/Assembler/SimpleProduct.php b/tests/Fixtures/Assembler/SimpleProduct.php index da3624d36..68824a266 100644 --- a/tests/Fixtures/Assembler/SimpleProduct.php +++ b/tests/Fixtures/Assembler/SimpleProduct.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'SimpleProduct')] +#[OA\Schema(component: 'SimpleProduct')] class SimpleProduct { #[OA\Property(property: 'name')] diff --git a/tests/Fixtures/Assembler/WithAttachables.php b/tests/Fixtures/Assembler/WithAttachables.php index c0e80d58b..8dfd66a35 100644 --- a/tests/Fixtures/Assembler/WithAttachables.php +++ b/tests/Fixtures/Assembler/WithAttachables.php @@ -9,7 +9,7 @@ use OpenApi\Spec as OA; use OpenApi\Tests\Fixtures\Assembler\Attachable\PropertyAttachable; -#[OA\Schema(schema: 'WithAttachables', attachables: [new OA\Attachable()])] +#[OA\Schema(component: 'WithAttachables', attachables: [new OA\Attachable()])] #[OA\Attachable] #[OA\Attachable] class WithAttachables diff --git a/tests/Fixtures/Assembler/WithConstant.php b/tests/Fixtures/Assembler/WithConstant.php index 0aff44629..86d4c052f 100644 --- a/tests/Fixtures/Assembler/WithConstant.php +++ b/tests/Fixtures/Assembler/WithConstant.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'WithConstant')] +#[OA\Schema(component: 'WithConstant')] class WithConstant { #[OA\Property(property: 'kind')] diff --git a/tests/Fixtures/Assembler/WithInvalidAttachables.php b/tests/Fixtures/Assembler/WithInvalidAttachables.php index 36a39a239..8c3a2407e 100644 --- a/tests/Fixtures/Assembler/WithInvalidAttachables.php +++ b/tests/Fixtures/Assembler/WithInvalidAttachables.php @@ -9,7 +9,7 @@ use OpenApi\Spec as OA; use OpenApi\Tests\Fixtures\Assembler\Attachable\InvalidMergePropertyAttachable; -#[OA\Schema(schema: 'WithInvalidAttachables')] +#[OA\Schema(component: 'WithInvalidAttachables')] class WithInvalidAttachables { #[OA\Property(attachables: [new OA\Attachable()])] diff --git a/tests/Fixtures/Augmenter/DiscriminatorSchema.php b/tests/Fixtures/Augmenter/DiscriminatorSchema.php index cd86a02e0..e91638c49 100644 --- a/tests/Fixtures/Augmenter/DiscriminatorSchema.php +++ b/tests/Fixtures/Augmenter/DiscriminatorSchema.php @@ -9,7 +9,7 @@ use OpenApi\Spec as OA; #[OA\Schema( - schema: 'DiscriminatorSchema', + component: 'DiscriminatorSchema', oneOf: [new OA\Schema(ref: RefTarget::class)], discriminator: new OA\Discriminator(propertyName: 'type', mapping: ['target' => RefTarget::class]), )] diff --git a/tests/Fixtures/Augmenter/DocblockSchema.php b/tests/Fixtures/Augmenter/DocblockSchema.php index 30e3f1a41..58a8f7d06 100644 --- a/tests/Fixtures/Augmenter/DocblockSchema.php +++ b/tests/Fixtures/Augmenter/DocblockSchema.php @@ -13,7 +13,7 @@ * * @deprecated */ -#[OA\Schema(schema: 'DocblockSchema')] +#[OA\Schema(component: 'DocblockSchema')] class DocblockSchema { #[OA\Property(property: 'name')] diff --git a/tests/Fixtures/Augmenter/Hierarchy/Spec/TraitWithSchema.php b/tests/Fixtures/Augmenter/Hierarchy/Spec/TraitWithSchema.php index 22c057e34..d6c1d5e8e 100644 --- a/tests/Fixtures/Augmenter/Hierarchy/Spec/TraitWithSchema.php +++ b/tests/Fixtures/Augmenter/Hierarchy/Spec/TraitWithSchema.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'NameTrait')] +#[OA\Schema(component: 'NameTrait')] trait TraitWithSchema { #[OA\Property(property: 'name')] diff --git a/tests/Fixtures/Augmenter/RefTarget.php b/tests/Fixtures/Augmenter/RefTarget.php index aaec90395..ed97ccfad 100644 --- a/tests/Fixtures/Augmenter/RefTarget.php +++ b/tests/Fixtures/Augmenter/RefTarget.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'RefTarget', description: 'A target for refs.')] +#[OA\Schema(component: 'RefTarget', description: 'A target for refs.')] class RefTarget { #[OA\Property(property: 'id')] diff --git a/tests/Fixtures/Augmenter/SuppressedSchema.php b/tests/Fixtures/Augmenter/SuppressedSchema.php index f27c5483d..17660bf42 100644 --- a/tests/Fixtures/Augmenter/SuppressedSchema.php +++ b/tests/Fixtures/Augmenter/SuppressedSchema.php @@ -11,7 +11,7 @@ /** * A documented schema whose description is suppressed. */ -#[OA\Schema(schema: 'SuppressedSchema', description: null)] +#[OA\Schema(component: 'SuppressedSchema', description: null)] class SuppressedSchema { #[OA\Property(property: 'name')] diff --git a/tests/Fixtures/Augmenter/TypeSchema.php b/tests/Fixtures/Augmenter/TypeSchema.php index f93559226..34191c44a 100644 --- a/tests/Fixtures/Augmenter/TypeSchema.php +++ b/tests/Fixtures/Augmenter/TypeSchema.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'TypeSchema')] +#[OA\Schema(component: 'TypeSchema')] class TypeSchema { #[OA\Property] diff --git a/tests/Fixtures/ComponentIndex/OddlyNamed.php b/tests/Fixtures/ComponentIndex/OddlyNamed.php index 156dfe4d0..cee617889 100644 --- a/tests/Fixtures/ComponentIndex/OddlyNamed.php +++ b/tests/Fixtures/ComponentIndex/OddlyNamed.php @@ -12,7 +12,7 @@ * A component name is free-form, so both characters that are structural in a JSON Pointer * are legal in one. */ -#[OA\Schema(schema: 'Odd/Name~With')] +#[OA\Schema(component: 'Odd/Name~With')] class OddlyNamed { #[OA\Property] diff --git a/tests/Fixtures/ComponentIndex/Product.php b/tests/Fixtures/ComponentIndex/Product.php index ab0890429..37b4cc795 100644 --- a/tests/Fixtures/ComponentIndex/Product.php +++ b/tests/Fixtures/ComponentIndex/Product.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'product')] +#[OA\Schema(component: 'product')] class Product { #[OA\Property] diff --git a/tests/Fixtures/ComponentKey/ComponentSpelling.php b/tests/Fixtures/ComponentKey/ComponentSpelling.php new file mode 100644 index 000000000..600622951 --- /dev/null +++ b/tests/Fixtures/ComponentKey/ComponentSpelling.php @@ -0,0 +1,64 @@ + 'Rex'])] +class ComponentMinimalExample +{ +} + +#[OA\Info(title: 'ComponentKey', version: '1.0')] +#[OA\Operation\Post( + path: '/pets/{id}', + operationId: 'getPet', + parameters: [new OA\Parameter(ref: '#/components/parameters/page')], + requestBody: new OA\RequestBody(ref: '#/components/requestBodies/PetBody'), + responses: [ + new OA\Response( + response: 200, + description: 'A pet', + headers: [new OA\Header(header: 'X-Rate-Limit', ref: '#/components/headers/RateLimit')], + content: [new OA\MediaType\Json(ref: '#/components/schemas/Pet', examples: [new OA\Example(example: 'minimal', ref: '#/components/examples/Minimal')])], + links: [new OA\Link(link: 'self', ref: '#/components/links/Self')], + ), + new OA\Response(response: 404, ref: '#/components/responses/NotFound'), + ], +)] +class ComponentController +{ +} diff --git a/tests/Fixtures/ComponentKey/LegacySpelling.php b/tests/Fixtures/ComponentKey/LegacySpelling.php new file mode 100644 index 000000000..148ba74c4 --- /dev/null +++ b/tests/Fixtures/ComponentKey/LegacySpelling.php @@ -0,0 +1,64 @@ + 'Rex'])] +class LegacyMinimalExample +{ +} + +#[OA\Info(title: 'ComponentKey', version: '1.0')] +#[OA\Operation\Post( + path: '/pets/{id}', + operationId: 'getPet', + parameters: [new OA\Parameter(ref: '#/components/parameters/page')], + requestBody: new OA\RequestBody(ref: '#/components/requestBodies/PetBody'), + responses: [ + new OA\Response( + response: 200, + description: 'A pet', + headers: [new OA\Header(header: 'X-Rate-Limit', ref: '#/components/headers/RateLimit')], + content: [new OA\MediaType\Json(ref: '#/components/schemas/Pet', examples: [new OA\Example(example: 'minimal', ref: '#/components/examples/Minimal')])], + links: [new OA\Link(link: 'self', ref: '#/components/links/Self')], + ), + new OA\Response(response: 404, ref: '#/components/responses/NotFound'), + ], +)] +class LegacyController +{ +} diff --git a/tests/Fixtures/ComponentKey/SharedPathItem.php b/tests/Fixtures/ComponentKey/SharedPathItem.php new file mode 100644 index 000000000..7dae607bc --- /dev/null +++ b/tests/Fixtures/ComponentKey/SharedPathItem.php @@ -0,0 +1,28 @@ + ['billingAddress']], dependentSchemas: ['creditCard' => new OA\Schema(required: ['billingAddress'])], @@ -56,7 +56,7 @@ class SchemaKeywordsDependentSpec } #[OA\Schema( - schema: 'embedded', + component: 'embedded', type: 'string', contentEncoding: 'base64', contentMediaType: 'application/json', @@ -66,14 +66,14 @@ class SchemaKeywordsEmbeddedSpec { } -#[OA\Schema(schema: 'decoded', type: 'object')] +#[OA\Schema(component: 'decoded', type: 'object')] class SchemaKeywordsDecodedSpec { } // a nested schema slot takes a ref like any other schema position #[OA\Schema( - schema: 'envelope', + component: 'envelope', type: 'string', contentMediaType: 'application/json', contentSchema: new OA\Schema\Ref(SchemaKeywordsDecodedSpec::class), diff --git a/tests/Fixtures/Scratch/Types-spec.php b/tests/Fixtures/Scratch/Types-spec.php index 765130460..959349975 100644 --- a/tests/Fixtures/Scratch/Types-spec.php +++ b/tests/Fixtures/Scratch/Types-spec.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'Types')] +#[OA\Schema(component: 'Types')] class TypesSpec { #[OA\Property] diff --git a/tests/Fixtures/Scratch/XmlContentEquiv-spec.php b/tests/Fixtures/Scratch/XmlContentEquiv-spec.php index 98e0eca64..1ef59ff47 100644 --- a/tests/Fixtures/Scratch/XmlContentEquiv-spec.php +++ b/tests/Fixtures/Scratch/XmlContentEquiv-spec.php @@ -8,7 +8,7 @@ use OpenApi\Spec as OA; -#[OA\Schema(schema: 'XmlContentEquiv')] +#[OA\Schema(component: 'XmlContentEquiv')] class XmlContentEquivSpec { #[OA\Property] diff --git a/tests/ResolverTest.php b/tests/ResolverTest.php index f4428a431..f6125804c 100644 --- a/tests/ResolverTest.php +++ b/tests/ResolverTest.php @@ -125,7 +125,7 @@ protected function assembler(string ...$classes): Assembler protected function schemaNames(Assembler $assembler): array { return array_map( - static fn (OA\Schema $schema): ?string => $schema->schema, + static fn (OA\Schema $schema): ?string => $schema->component, $assembler->getSpecification()->schemas ); } diff --git a/tests/Specification/ComponentNameTest.php b/tests/Specification/ComponentNameTest.php new file mode 100644 index 000000000..a9e05ca98 --- /dev/null +++ b/tests/Specification/ComponentNameTest.php @@ -0,0 +1,185 @@ + */ + private array $deprecations = []; + + protected function setUp(): void + { + $this->deprecations = []; + set_error_handler(function (int $errno, string $message): bool { + if ($errno === E_USER_DEPRECATED) { + $this->deprecations[] = $message; + } + + return true; + }); + } + + protected function tearDown(): void + { + restore_error_handler(); + } + + /** + * @return iterable + */ + public static function constructorAliases(): iterable + { + yield 'schema' => [static fn (): OA\AbstractAttribute => new OA\Schema(schema: 'Pet'), 'schema']; + yield 'parameter' => [static fn (): OA\AbstractAttribute => new OA\Parameter(parameter: 'page', name: 'page'), 'parameter']; + yield 'request' => [static fn (): OA\AbstractAttribute => new OA\RequestBody(request: 'Body'), 'request']; + yield 'securityScheme' => [static fn (): OA\AbstractAttribute => new OA\Security\Scheme(securityScheme: 'api', type: 'apiKey'), 'securityScheme']; + } + + #[DataProvider('constructorAliases')] + public function testASpellingThatCanOnlyBeTheKeyAliasesInTheConstructor(callable $build, string $field): void + { + $attribute = $build(); + + $this->assertSame($attribute->{$field}, $attribute->component); + $this->assertCount(1, $this->deprecations); + $this->assertStringContainsString("`{$field}` is deprecated as the component key", $this->deprecations[0]); + } + + public function testComponentWinsOverTheSpellingAndIsNotReported(): void + { + $schema = new OA\Schema(schema: 'Old', component: 'New'); + + $this->assertSame('New', $schema->component); + $this->assertSame('Old', $schema->schema, 'the historic field is left as written'); + $this->assertSame([], $this->deprecations); + } + + /** + * @return iterable + */ + public static function keysRead(): iterable + { + yield 'response by component' => [new OA\Response(response: 404, component: 'NotFound'), 'NotFound']; + yield 'response by spelling' => [new OA\Response(response: 'NotFound'), 'NotFound']; + yield 'response code as spelling' => [new OA\Response(response: 200), '200']; + yield 'header by spelling' => [new OA\Header(header: 'RateLimit'), 'RateLimit']; + yield 'link by spelling' => [new OA\Link(link: 'Self'), 'Self']; + yield 'example by spelling' => [new OA\Example(example: 'Minimal'), 'Minimal']; + yield 'parameter by name' => [new OA\Parameter(name: 'page'), 'page']; + yield 'schema, title is not a key' => [new OA\Schema(title: 'A title'), null]; + yield 'path item by component' => [new OA\PathItem(component: 'Paged'), 'Paged']; + yield 'path item, path-bound' => [new OA\PathItem(prefix: '/api'), null]; + yield 'media type by component' => [new OA\MediaType(mediaType: 'application/json-patch+json', component: 'Patch'), 'Patch']; + yield 'media type inline' => [new OA\MediaType(mediaType: 'application/json'), null]; + yield 'not a component type' => [new OA\Tag(name: 'pets'), null]; + } + + #[DataProvider('keysRead')] + public function testOfReadsComponentAndFallsBackToTheSpellingWhereItIsUnambiguous(OA\AbstractAttribute $attribute, ?string $expected): void + { + $this->assertSame($expected, ComponentName::of($attribute)); + $this->assertSame([], $this->deprecations, 'reading is never what reports the spelling'); + } + + public function testNormalizeWritesTheKeyAndReportsEachSpellingOnce(): void + { + $spec = new Specification(); + $spec->responses[] = $response = new OA\Response(response: 'NotFound'); + $spec->headers[] = $header = new OA\Header(header: 'RateLimit'); + $spec->links[] = $link = new OA\Link(link: 'Self'); + $spec->examples[] = $example = new OA\Example(example: 'Minimal'); + $spec->parameters[] = $parameter = new OA\Parameter(name: 'page'); + + ComponentName::normalise($spec, deprecate: true); + + $this->assertSame('NotFound', $response->component); + $this->assertSame('RateLimit', $header->component); + $this->assertSame('Self', $link->component); + $this->assertSame('Minimal', $example->component); + $this->assertSame('page', $parameter->component, 'a parameter is keyed by its name'); + + $fields = array_map(fn (string $m): string => explode('`', $m)[1], $this->deprecations); + $this->assertSame(['response', 'header', 'link', 'example'], $fields, 'the inferences are not spellings and are not reported'); + + $this->deprecations = []; + ComponentName::normalise($spec, deprecate: true); + $this->assertSame([], $this->deprecations, 'once written, there is nothing left to report'); + } + + public function testNormalizeDoesNotReportWhatItDidNotAuthor(): void + { + $spec = new Specification(); + $spec->responses[] = $response = new OA\Response(response: 'NotFound'); + + ComponentName::normalise($spec, deprecate: false); + + $this->assertSame('NotFound', $response->component); + $this->assertSame([], $this->deprecations); + } + + public function testATitleNeverBecomesAKey(): void + { + $spec = $this->assemble(TypeSchema::class); + $schema = $spec->schemas[0]; + $schema->component = null; + $schema->title = 'A title, not a name'; + + ComponentName::normalise($spec, deprecate: true); + $this->assertNull($schema->component); + + (new Augmenter\Names())($spec); + $this->assertSame('TypeSchema', $schema->component, 'the class names it, as before'); + } + + /** + * @return iterable + */ + public static function rootness(): iterable + { + yield 'header, unkeyed' => [new OA\Header(description: 'inline'), false]; + yield 'header, keyed' => [new OA\Header(component: 'RateLimit'), true]; + yield 'header, nesting key only' => [new OA\Header(header: 'X-Rate-Limit'), false]; + yield 'example, keyed' => [new OA\Example(component: 'Minimal'), true]; + yield 'example, nesting key only' => [new OA\Example(example: 'minimal'), false]; + yield 'media type, keyed' => [new OA\MediaType(component: 'Patch'), true]; + yield 'media type, inline' => [new OA\MediaType(mediaType: 'application/json'), false]; + yield 'response, keyed' => [new OA\Response(component: 'NotFound'), true]; + yield 'response, keyed with ref' => [new OA\Response(ref: '#/components/responses/NotFound', component: 'Alias'), false]; + yield 'response, code with ref' => [new OA\Response(response: 404, ref: '#/components/responses/NotFound'), false]; + yield 'response, historic spelling' => [new OA\Response(response: 'NotFound'), true]; + yield 'link, historic spelling' => [new OA\Link(link: 'Self'), true]; + yield 'link, keyed with ref' => [new OA\Link(ref: '#/components/links/Other', component: 'Self'), false]; + yield 'request body, keyed' => [new OA\RequestBody(component: 'Body'), true]; + yield 'request body, keyed with ref' => [new OA\RequestBody(ref: '#/components/requestBodies/Body', component: 'Alias'), true]; + yield 'parameter, keyed' => [new OA\Parameter(name: 'page', component: 'page'), true]; + yield 'parameter, name only' => [new OA\Parameter(name: 'page'), false]; + yield 'path item, keyed' => [new OA\PathItem(component: 'Paged'), true]; + yield 'path item, path-bound' => [new OA\PathItem(prefix: '/api'), true]; + } + + #[DataProvider('rootness')] + public function testIsRootFollowsComponent(OA\AbstractAttribute $attribute, bool $root): void + { + $this->assertSame($root, $attribute->isRoot()); + } +} diff --git a/tests/Utils/AttributeFactoryTest.php b/tests/Utils/AttributeFactoryTest.php index 5a35d9db2..22af04a8a 100644 --- a/tests/Utils/AttributeFactoryTest.php +++ b/tests/Utils/AttributeFactoryTest.php @@ -237,7 +237,7 @@ public function getAttributes(\ReflectionClassConstant|\ReflectionParameter|\Ref public function translate(array $attributes, array $created, \ReflectionClassConstant|\ReflectionParameter|\ReflectionMethod|\ReflectionClass|\ReflectionProperty $reflector): array { - if ($attributes[0] instanceof OA\Schema && $attributes[0]->schema === 'SimpleProduct') { + if ($attributes[0] instanceof OA\Schema && $attributes[0]->component === 'SimpleProduct') { $property = new OA\Property( property: 'extra', schema: new OA\Schema(type: 'bool'),