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'),