Skip to content

Native OpenAPI 3.2.0 Tag Object support (parent, summary, kind, externalDocs) - #1077

Open
jeffreyvanhees wants to merge 5 commits into
dedoc:mainfrom
jeffreyvanhees:feature/x-taggroups-support
Open

Native OpenAPI 3.2.0 Tag Object support (parent, summary, kind, externalDocs)#1077
jeffreyvanhees wants to merge 5 commits into
dedoc:mainfrom
jeffreyvanhees:feature/x-taggroups-support

Conversation

@jeffreyvanhees

@jeffreyvanhees jeffreyvanhees commented Feb 4, 2026

Copy link
Copy Markdown
Contributor

This PR adds support for the native OpenAPI 3.2.0 Tag Object fields: spec-compliant hierarchical tag grouping, plus the other metadata the object gained.

What changed

  • Bumped OpenAPI version from 3.1.0 to 3.2.0
  • Native parent field on tags, so groups nest
  • New summary, kind, externalDocs fields on tags
  • New Tag attribute for declaring a tag that no endpoint claims
  • Auto-creates parent tags when referenced by a child but never declared

Hierarchical grouping

Group says where an endpoint belongs. parent says what that group hangs under:

#[Group(name: 'Products', parent: 'Catalogue')]
class ProductController {}

#[Group(name: 'Options', parent: 'Catalogue')]
class OptionController {}
{
  "tags": [
    { "name": "Products", "parent": "Catalogue" },
    { "name": "Options", "parent": "Catalogue" },
    { "name": "Catalogue" }
  ]
}

Declaring a tag with the Tag attribute

Catalogue above holds no endpoints of its own, so nothing describes it and it reaches the document as a bare name. Group cannot fill that gap: it is not repeatable, and a second one on a method moves the endpoint into that group rather than describing the parent.

Tag states what a tag is, where Group states where an endpoint belongs:

#[Group(name: 'Products', parent: 'Catalogue')]
#[Tag('Catalogue', 'Everything a restaurant sells, and what it costs.')]
class ProductController {}
{
  "tags": [
    { "name": "Products", "parent": "Catalogue" },
    { "name": "Catalogue", "description": "Everything a restaurant sells, and what it costs." }
  ]
}

It is repeatable, so one class can declare a whole branch, and it carries parent itself so a parent may sit under a parent:

#[Group(name: 'Invoices', parent: 'Finances')]
#[Tag('Finances', 'What was earned and charged.', parent: 'Business')]
#[Tag('Business', 'The company behind the storefronts.')]
class InvoiceController {}

A Group of the same name wins on every field it already states, so declaring a tag never moves an endpoint or overrides where one says it belongs.

Tag summary

A short summary, separate from the longer description:

#[Group(name: 'Users', summary: 'User CRUD', description: 'Full user management including registration, profiles, and preferences.')]

Tag kind

A machine-readable category. The spec allows any string and names nav, badge and audience as the common ones:

#[Group(name: 'Partner', kind: 'audience')]

External documentation

#[Group(name: 'Payments', externalDocsUrl: 'https://docs.stripe.com', externalDocsDescription: 'Stripe API reference')]
{
  "tags": [{
    "name": "Payments",
    "externalDocs": {
      "url": "https://docs.stripe.com",
      "description": "Stripe API reference"
    }
  }]
}

Why this is worth having now

Scalar merged nested tag rendering in scalar/scalar#9406, shipped in the 2026-08-20 release. It reads the native parent field first and falls back to x-tagGroups for older documents, with arbitrary depth and a parent that holds no operations rendering as a section. So 3.2.0 tags out of Scramble now show up in the reference rather than only being more correct on paper.

Breaking changes

  • The OpenAPI version is now 3.2.0. Tools that do not support it may not recognise the new tag fields.

Backward compatibility

  • Existing Group attributes without parent are unaffected
  • Every new parameter is optional
  • Tag is additive; nothing needs it

Known gap

Circular parent references are not detected. The spec forbids them, but a cycle currently generates a document that violates it without any warning. Happy to add that here, though it may belong with the other diagnostics rather than in the transformer.

@jeffreyvanhees

Copy link
Copy Markdown
Contributor Author

Tests are failing because composer tries to install the oldest compatible versions of dependencies. Recent updates to Pest PHP and its dependencies now require PHP 8.2+ creating a conflict with the PHP 8.1 test configuration.

@romalytvynenko

Copy link
Copy Markdown
Member

@jeffreyvanhees what do you think about how this PR fits/relates to newly introduced OpenAPI 3.2.0 tags that support child/parent relationships?

Replace the Redoc x-tagGroups extension approach with spec-native
OpenAPI 3.2.0 Tag Object fields: parent, summary, kind, externalDocs.

- Add ExternalDocumentation class
- Extend Tag with summary, parent, kind, externalDocs properties
- Add summary, kind, externalDocsUrl, externalDocsDescription to Group attribute
- Update AddDocumentTags to set native fields and auto-create parent tags
- Remove AddTagGroups transformer (no longer needed)
- Bump OAS version from 3.1.0 to 3.2.0
- Rewrite tests for native parent field assertions
@jeffreyvanhees jeffreyvanhees changed the title Add OpenAPI x-tagGroups via parent parameter Native OpenAPI 3.2.0 Tag Object support (parent, summary, kind, externalDocs) Feb 23, 2026
@jeffreyvanhees

jeffreyvanhees commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@jeffreyvanhees what do you think about how this PR fits/relates to newly introduced OpenAPI 3.2.0 tags that support child/parent relationships?

Sorry, this sat for far too long. Had another look and I think we were saying the same thing: that's what this PR does. The parent field in the diff is the native 3.2.0 Tag Object field you linked to, together with summary, kind and externalDocs, and the version bump in Generator.php. I read your question as "there's a different approach now" and answered as if I had homework, when really there was nothing to change.

Since then it got more interesting. Scalar merged nested tag rendering last week (scalar/scalar#9406, in the 2026-08-20 release), and it reads the native parent field first, falling back to x-tagGroups for older documents. So Scramble emitting 3.2.0 tags now actually shows up in the reference instead of only being more correct on paper.

I've merged current main in and pushed. CI is green across the matrix, and the PHP 8.1 job I complained about back in February is gone, so prefer-lowest passes too.

Three things came out of the merge:

AddDocumentTags reads the attribute through newInstance() now, following main, instead of the positional getArguments() indexes this branch was written against.

The tag key is back to plain $name. I had been keying on "{$parent}/{$name}" so the same name could appear under different parents, but tag names have to be unique and tags[].name is what operations reference, so two tags called Orders was never going to resolve anyway.

The kind docblock no longer claims "navigation" or "api". The spec allows any string and mentions nav, badge and audience as the common ones, so I had invented both of mine.

GroupTest keeps both sets of tests, and the snapshots that moved from .yml to .json upstream carry 3.2.0 now. One deliberately doesn't: OpenApiBuildersTest builds its own OpenApi('3.1.0') independently of the generator, so that snapshot stays put.

Auto-creating missing parent tags stays, since the spec wants the named parent to exist. The half that's still missing is circular parent/child detection. Right now a cycle just generates a document that quietly violates the spec, no warning. Happy to add it, though it might belong with the other diagnostics rather than in the transformer. Let me know which you'd prefer.

Resolves the conflicts main introduced since this branch was opened, and
corrects two things the spec made clear on re-reading.

AddDocumentTags now reads the attribute through newInstance() and typed
properties, following main, instead of the positional getArguments() indexes
this branch was written against.

The tag key is back to the plain name. Keying on "{parent}/{name}" was meant to
allow one name under several parents, but tag names must be unique and
tags[].name is what operations reference, so two tags of one name cannot be
resolved either way.

The kind docblock no longer claims "navigation" (default) or "api". The spec
allows any string and names nav, badge and audience as the common ones.

GroupTest keeps both sets of tests: main's six and this branch's nine.
Snapshots moved from .yml to .json upstream; the .json ones now carry 3.2.0.
A parent named by `Group::$parent` holds no endpoints of its own, so nothing
carried its description and it reached the document as a bare name. `Group` is
not repeatable, and putting a second one on a method moves the endpoint into
that group instead of describing the parent, so neither existing route worked.

`Tag` states what a tag is, where `Group` states where an endpoint belongs. It
is repeatable, so one class can declare the whole branch it hangs under, and it
carries `parent` itself so a parent may sit under a parent.

A group of the same name wins on every field it already states. Declaring a tag
therefore never moves an endpoint or overrides where one says it belongs.
@jeffreyvanhees

jeffreyvanhees commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Pushed another commit, and updated the description to match.

Some context on where it came from, since it was not planned. I took this branch for a spin on a real API (366 endpoints, three audiences) to see whether the nesting actually helps, and grouped everything into parents: Selling, Operating, Business, that sort of thing. It does help. But every parent came out of the generator as a bare name, because a parent holds no endpoints and nothing else was carrying its description.

Two things I tried first, both dead ends worth recording:

Putting a controller in the parent group works, but then those endpoints sit next to the subgroups instead of inside one, which is the flat list I was trying to get rid of.

A second #[Group] on the class throws Attribute "Group" must not be repeated. Moving it to a method does generate, but getTagsAnnotatedByGroups() collects method attributes before class ones and only [0] decides the tag, so the endpoint moved into the parent. GET /restaurants ended up under Browsing rather than Restaurants.

So the gap is real and neither existing route closes it. My first attempt was a parentDescription: parameter on Group, which I threw away: it describes a tag that is not the one being annotated, and with five controllers under one parent, five of them could disagree about the description with no way to see which won.

Tag avoids that by being what it says. Group places endpoints, Tag describes tags. It is repeatable, so a class can declare the whole branch it hangs under, and it carries parent itself so parents can nest further. Where a Group and a Tag name the same tag, the group wins on every field it already states, so declaring a tag can never move an endpoint.

Five tests cover it, including that last precedence rule and the endpoint staying put. Full suite is green at 1216.

One naming note: Parent is reserved in PHP and cannot be a class name, so Tag it is. It also happens to be the name of the OpenAPI object you are declaring.

Still open from before: circular parent references aren't detected. Happy to add it, but it might sit better with the diagnostics than in the transformer. Your call on both that and whether Tag is the shape you want here.

Examples are upadted in PR description

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants