Skip to content

feat(openapi): add array form for @tagMetadata to control tag declaration order#10770

Draft
Copilot wants to merge 11 commits into
mainfrom
copilot/fix-operation-order-preservation
Draft

feat(openapi): add array form for @tagMetadata to control tag declaration order#10770
Copilot wants to merge 11 commits into
mainfrom
copilot/fix-operation-order-preservation

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 22, 2026

Adds a new array form for the @tagMetadata decorator that lets authors explicitly specify tags and their order in a single decorator call, rather than relying on the bottom-up decorator execution order.

Changes

  • packages/openapi/lib/decorators.tsp: Added TagMetadataWithName model; updated @tagMetadata signature to accept string | TagMetadataWithName[] with the second parameter optional (only used in inline form).
  • packages/openapi/generated-defs/TypeSpec.OpenAPI.ts: Regenerated with new TagMetadataWithName interface (including summary and kind fields) and updated TagMetadataDecorator type.
  • packages/openapi/src/lib.ts: Added mixed-tag-metadata-form diagnostic — emitted when mixing the array form and inline form on the same namespace. Added tag-metadata-array-with-metadata-arg diagnostic — emitted when the second tagMetadata argument is provided alongside the array form.
  • packages/openapi/src/types.ts: Added publicly documented TagMetadataWithName interface (with summary and kind fields).
  • packages/openapi/src/index.ts: Exported new public type.
  • packages/openapi/src/decorators.ts: State storage changed from { [name: string]: TagMetadata } to TagMetadataWithName[]; handles both inline and array forms; reports error on mixing or on using the second argument with the array form.
  • packages/openapi3/src/openapi.ts: Updated resolveDocumentTags to iterate the new array-based state.
  • packages/openapi3/src/cli/actions/convert/generators/generate-tags.ts: Updated the OpenAPI→TypeSpec converter to emit @tagMetadata(#[...]) (array form) instead of multiple inline calls, ensuring import/export order symmetry. Emits parent, summary, and kind fields natively for OpenAPI 3.2.0 tags.
  • packages/openapi3/src/cli/actions/convert/transforms/transform-tags.ts: Passes through parent, summary, and kind fields from OpenAPITag3_2; reads summary and kind from x-oai-summary/x-oai-kind extensions as fallbacks for OpenAPI 3.0/3.1 documents.
  • packages/openapi3/src/cli/actions/convert/interfaces.ts: Added parent, summary, and kind fields to TypeSpecTagMetadata.
  • packages/openapi/test/decorators.test.ts: Updated existing tests for the new array return type; added tests for array form, mixing error, and second-argument-with-array-form error.
  • packages/openapi3/test/tagmetadata.test.ts: Added tests for array form ordering, operation-tag insertion behavior, and parent/child tag scenarios.
  • packages/openapi3/test/tsp-openapi3/convert-openapi3-doc.test.ts: Added unit tests verifying converter handling of OpenAPI 3.2.0 parent, summary, and kind tag fields, and x-oai- extension fallbacks for 3.0/3.1 documents.
  • Converter snapshot outputs (tag-metadata, playground-http-service, petstore-swagger, tag-metadata-3-2): Updated to reflect the new array form emitted by the converter.

Array form example

@service
@tagMetadata(#[
  #{ name: "First Tag", description: "First tag description" },
  #{ name: "Second Tag", description: "Second tag description" },
  #{ name: "Third Tag", description: "Third tag description" },
])
namespace PetStore {}

Tags are emitted in the exact order specified in the array.

Mixing forms is an error

Using both forms on the same namespace reports a mixed-tag-metadata-form diagnostic:

@service
@tagMetadata(#[#{ name: "tag1" }])
@tagMetadata("tag2", #{})  // error: cannot mix array and inline form
namespace PetStore {}

Passing the second argument with the array form is an error

@service
@tagMetadata(#[#{ name: "tag1" }], #{description: "not allowed"})  // error: tag-metadata-array-with-metadata-arg
namespace PetStore {}

Converter output

The OpenAPI→TypeSpec converter now emits the array form so that tag order in the source OpenAPI document is preserved in the output TypeSpec. OpenAPI 3.2.0 parent, summary, and kind tag fields are emitted natively. For OpenAPI 3.0/3.1 documents, summary and kind are also read from x-oai-summary and x-oai-kind extensions:

@tagMetadata(#[
  #{ name: "pet", description: "Everything about your Pets", externalDocs: #{ url: "...", description: "Find out more" } },
  #{ name: "store", description: "Access to Petstore orders", externalDocs: #{ url: "...", description: "Find out more about our store" } },
  #{ name: "user", description: "Operations about user" },
  #{ name: "extensive", description: "A tag with all 3.2 fields", summary: "Short summary", kind: "OperationGroup", parent: "parent-tag" },
])
namespace SwaggerPetstoreOpenAPI30;

Tag insertion behavior (unchanged)

  • Tags used only at the operation level (via @tag) and not declared with @tagMetadata appear first in the output.
  • Tags declared with @tagMetadata follow in their stored order.
  • A tag used at both the operation level and in @tagMetadata is emitted exactly once, at its @tagMetadata-declared position with its metadata.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:openapi3 Issues for @typespec/openapi3 emitter label May 22, 2026
Copilot AI changed the title [WIP] Fix operation order preservation when using @tagMetadata decorator fix(openapi3): preserve @tagMetadata tag declaration order in emitted OpenAPI May 22, 2026
Copilot AI requested a review from baywet May 22, 2026 14:05
Copilot finished work on behalf of baywet May 22, 2026 14:05
@baywet
Copy link
Copy Markdown
Member

baywet commented May 22, 2026

@copilot add multiple test cases if we don't have them already: one where a tag is used at the operation level, but not defined at the tagMetadata/service level to see where it "inserts" itself. one where a tag is used at the operation level, and defined at the tagMetadata/service level to see where it "inserts" itself.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/openapi@10770
npm i https://pkg.pr.new/@typespec/openapi3@10770

commit: bccdd84

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 22, 2026

@copilot add multiple test cases if we don't have them already: one where a tag is used at the operation level, but not defined at the tagMetadata/service level to see where it "inserts" itself. one w...

Added two new tests in commit 76ffa16:

  1. "operation-level tag not defined in @tagMetadata is inserted before tagMetadata tags" — a tag used only via @tag on an operation (not in @tagMetadata) appears first, followed by the @tagMetadata-defined tags.

  2. "operation-level tag also defined in @tagMetadata is emitted once in tagMetadata position" — a tag used via @tag on an operation that is also declared with @tagMetadata is not duplicated; it appears exactly once in its @tagMetadata-declared position (with its metadata).

Copilot finished work on behalf of baywet May 22, 2026 14:23
@baywet baywet marked this pull request as ready for review May 22, 2026 14:33
@baywet baywet requested a review from johanste May 22, 2026 14:34
@baywet baywet enabled auto-merge May 22, 2026 14:34
@azure-sdk-automation
Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

@markcowl markcowl added the int:azure-specs Run integration tests against azure-rest-api-specs label May 22, 2026
Copy link
Copy Markdown
Contributor

@markcowl markcowl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can take this, as it is a breaking behavior change. If the order of tags is important, I think the best non-breaking short term solution would be to allow arrays in @tagMetadata. I think the longer term solution is probably here: #10771

@baywet baywet closed this May 25, 2026
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 25, 2026

@copilot let's restart this pull request entirely :

  • reset, checkout the current commits, and force push the new solution.
  • update the PR title and description
    ...

Done. The PR has been restarted with the new approach based on the guidance in the issue comment.

What changed:

  • Added TagMetadataWithName model ({name: string, ...TagMetadata}) to packages/openapi/lib/decorators.tsp
  • Updated @tagMetadata signature to accept string | TagMetadataWithName[] (making the second param optional)
  • New mixed-tag-metadata-form diagnostic fires when mixing array and inline forms on the same namespace
  • Changed state storage from { [name: string]: TagMetadata } to TagMetadataWithName[]
  • Exported TagMetadataRecord and TagMetadataWithName from @typespec/openapi

Array form usage:

@service()
@tagMetadata(#[
  #{ name: "First Tag", description: "First tag description" },
  #{ name: "Second Tag", description: "Second tag description" },
])
namespace PetStore {}

Mixing error example:

@tagMetadata(#[#{ name: "tag1" }])
@tagMetadata("tag2", #{})  // error: mixed-tag-metadata-form
namespace PetStore {}

Commit: ef9f21d

Copilot AI changed the title fix(openapi3): preserve @tagMetadata tag declaration order in emitted OpenAPI feat(openapi): add array form for @tagMetadata to control tag declaration order May 25, 2026
Copilot finished work on behalf of baywet May 25, 2026 17:56
Copilot AI requested a review from baywet May 25, 2026 17:56
Comment thread .chronus/changes/copilot-tagmetadata-array-form-2026-5-25-17-0-0.md Outdated
Comment thread packages/openapi/src/types.ts Outdated
/**
* Metadata for a single tag used by operations.
*/
export interface TagMetadataRecord {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't we already have this type generated in packages/openapi/generated-defs/TypeSpec.OpenAPI.ts and TagMetadataWithName is there now too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — TagMetadataWithName is generated in generated-defs/TypeSpec.OpenAPI.ts. However, the auto-generated version has readonly fields and no JSDoc, which causes api-extractor to fail with ae-undocumented errors (since it's reachable from the exported TagMetadataDecorator signature). We need to keep a JSDoc-annotated version in types.ts for the public API surface.

I've removed the intermediate TagMetadataRecord base type in commit bccdd84TagMetadataWithName in types.ts now has its fields inlined directly, eliminating that extra layer of duplication.

Co-authored-by: Timothee Guerin <[email protected]>
@github-actions
Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @typespec/openapi
  • @typespec/openapi3
Show changes

@typespec/openapi - feature ✏️

Add array form for @tagMetadata decorator to allow explicit control of tag declaration order.,> ,> typespec,> @service,> @tagMetadata(#[,> #{ name: "First Tag", description: "First tag description" },,> #{ name: "Second Tag", description: "Second tag description" },,> ]),> namespace PetStore {},> ,> ,> Using @tagMetadata(#[...]) and @tagMetadata("name", #{...}) on the same namespace is a diagnostic error.

@typespec/openapi3 - feature ✏️

Add array form for @tagMetadata decorator to allow explicit control of tag declaration order.,> ,> typespec,> @service,> @tagMetadata(#[,> #{ name: "First Tag", description: "First tag description" },,> #{ name: "Second Tag", description: "Second tag description" },,> ]),> namespace PetStore {},> ,> ,> Using @tagMetadata(#[...]) and @tagMetadata("name", #{...}) on the same namespace is a diagnostic error.

Copy link
Copy Markdown
Member

@baywet baywet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot also update the converter logic. We should always use the array form to maintain order symmetry between import and emission.

Comment thread packages/openapi/lib/decorators.tsp
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 26, 2026

@copilot also update the converter logic. We should always use the array form to maintain order symmetry between import and emission.

Done in commit bccdd84. The converter's generate-tags.ts now emits the array form:

@tagMetadata(#[
  #{ name: "pet", description: "Everything about your Pets", externalDocs: #{ url: "...", description: "..." } },
  #{ name: "store", description: "...", externalDocs: #{ ... } },
  #{ name: "user", description: "Operations about user" }
])

Snapshot tests for tag-metadata, playground-http-service, and petstore-swagger have been updated accordingly.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the openapi3:converter Issues for @typespec/openapi3 openapi to typespec converter label May 26, 2026
Copilot finished work on behalf of baywet May 26, 2026 14:37
Copilot AI requested a review from baywet May 26, 2026 14:37
}

const tagItems = tags.map((tag) => {
const fields: string[] = [`name: "${tag.name}"`];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot it doesn't look like the import logic supports importing the parent and the kind fields from OpenAPI 3.2.0. Check that we have tests/implementation for those, if not add it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added support in commit 7d824e7:

  • parent (natively supported in TypeSpec's TagMetadata): passed through from OpenAPITag3_2.parent and emitted as parent: "..." in the array form.
  • kind (not natively supported in TypeSpec's TagMetadata): emitted as `x-kind`: "..." extension, since TypeSpec only allows x--prefixed extension keys.

Changes:

  • interfaces.ts: added parent?: string and kind?: string to TypeSpecTagMetadata
  • transform-tags.ts: destructures both fields from the tag object (safely undefined for 3.0/3.1 docs)
  • generate-tags.ts: emits parent and `x-kind` fields when present
  • Added specs/tag-metadata-3-2/service.yml (3.2.0 spec with all three variants) and its generated snapshot
  • Added unit tests in convert-openapi3-doc.test.ts covering 3.2 conversion and 3.0/3.1 passthrough

Copilot finished work on behalf of baywet May 26, 2026 17:58
Copilot AI requested a review from baywet May 26, 2026 17:58
@baywet
Copy link
Copy Markdown
Member

baywet commented May 26, 2026

@copilot handle the conflicts here. We had done some changes in this PR for parent/kind/summary of tags that were actually already implemented in another PR

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 26, 2026

@copilot handle the conflicts here. We had done some changes in this PR for parent/kind/summary of tags that were actually already implemented in another PR

Done. The merge conflicts have been resolved in commit ae5a97c. Here's a summary of how each conflict was handled:

  • generate-tags.ts: Combined the array form (from our PR) with summary/kind native field emission (from main's Add summary and kind fields to @tagMetadata decorator #10769). The generator now emits all fields in order: name, description, externalDocs, summary, kind, parent.
  • interfaces.ts: TypeSpecTagMetadata now has all five extra fields: description, externalDocs, summary, kind, parent.
  • transform-tags.ts: Reads parent (3.2 only), summary (3.2 native or x-oai-summary extension for 3.0/3.1), and kind (3.2 native or x-oai-kind extension for 3.0/3.1).
  • specs/tag-metadata-3-2/service.yml: Kept main's version (single "extensive" tag with description, summary, kind, and parent).
  • Snapshot files: Regenerated to use the array form with all native fields.
  • generated-defs/TypeSpec.OpenAPI.ts and types.ts: Added summary and kind to TagMetadataWithName (they were only added to TagMetadata by the auto-merge).

Since kind is now a native field in TagMetadata (thanks to #10769), it is emitted as kind: "..." directly rather than as an \x-kind`` extension. All 2523 openapi3 tests and 57 openapi tests pass.

Copilot finished work on behalf of baywet May 26, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:openapi3 Issues for @typespec/openapi3 emitter int:azure-specs Run integration tests against azure-rest-api-specs lib:openapi openapi3:converter Issues for @typespec/openapi3 openapi to typespec converter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Operation order is not preserved when @tagMetadata decorator is used

4 participants