diff --git a/.chronus/changes/copilot-fix-tagmetadata-order-2026-5-22-14-0-0.md b/.chronus/changes/copilot-fix-tagmetadata-order-2026-5-22-14-0-0.md new file mode 100644 index 00000000000..7a489f8f496 --- /dev/null +++ b/.chronus/changes/copilot-fix-tagmetadata-order-2026-5-22-14-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fix tag order not being preserved when `@tagMetadata` decorator is used. Tags are now emitted in TypeSpec declaration order. diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 8425d7ad001..ed1441b537a 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1825,7 +1825,9 @@ function createOAPIEmitter( } } - for (const [name, tag] of Object.entries(metadata || {})) { + // Reverse the entries to preserve TypeSpec declaration order. + // Decorators run bottom-up, so metadata entries are stored in reverse declaration order. + for (const [name, tag] of Object.entries(metadata || {}).reverse()) { const tagData: OpenAPI3Tag = { name: name, ...tag }; // For OpenAPI 3.0 and 3.1, drop the 'parent' field (only supported in 3.2) if (specVersion !== "3.2.0" && tag.parent) { diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 946a0d5a744..d957be825f0 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -149,6 +149,63 @@ worksFor(supportedVersions, ({ openApiFor, openapisFor }) => { }, ]); }); + + it("preserves the declaration order of tags when multiple @tagMetadata are used", async () => { + const res = await openApiFor( + ` + @service + @tagMetadata("First", #{description: "First tag"}) + @tagMetadata("Second", #{description: "Second tag"}) + @tagMetadata("Third", #{description: "Third tag"}) + namespace PetStore {}; + `, + ); + + deepStrictEqual(res.tags, [ + { name: "First", description: "First tag" }, + { name: "Second", description: "Second tag" }, + { name: "Third", description: "Third tag" }, + ]); + }); + + it("operation-level tag not defined in @tagMetadata is inserted before tagMetadata tags", async () => { + const res = await openApiFor( + ` + @service + @tagMetadata("MetaTag", #{description: "Metadata tag"}) + namespace PetStore { + @tag("OpOnlyTag") op op1(): string; + }; + `, + ); + + // Tags used only in operations (not in @tagMetadata) are emitted first, + // followed by tags defined with @tagMetadata. + deepStrictEqual(res.tags, [ + { name: "OpOnlyTag" }, + { name: "MetaTag", description: "Metadata tag" }, + ]); + }); + + it("operation-level tag also defined in @tagMetadata is emitted once in tagMetadata position", async () => { + const res = await openApiFor( + ` + @service + @tagMetadata("First", #{description: "First tag"}) + @tagMetadata("Second", #{description: "Second tag"}) + namespace PetStore { + @tag("First") op op1(): string; + }; + `, + ); + + // Tags used in both operations and @tagMetadata are not duplicated; + // they appear in their @tagMetadata-declared position with metadata. + deepStrictEqual(res.tags, [ + { name: "First", description: "First tag" }, + { name: "Second", description: "Second tag" }, + ]); + }); }); // Test for parent field - version specific behavior @@ -166,15 +223,15 @@ describe("tag metadata with parent field", () => { ); deepStrictEqual(res.tags, [ + { + name: "ParentTag", + description: "Parent tag", + }, { name: "ChildTag", description: "Child tag", parent: "ParentTag", }, - { - name: "ParentTag", - description: "Parent tag", - }, ]); }); @@ -191,14 +248,14 @@ describe("tag metadata with parent field", () => { ); deepStrictEqual(res.tags, [ - { - name: "ChildTag", - description: "Child tag", - }, { name: "ParentTag", description: "Parent tag", }, + { + name: "ChildTag", + description: "Child tag", + }, ]); }); @@ -215,14 +272,14 @@ describe("tag metadata with parent field", () => { ); deepStrictEqual(res.tags, [ - { - name: "ChildTag", - description: "Child tag", - }, { name: "ParentTag", description: "Parent tag", }, + { + name: "ChildTag", + description: "Child tag", + }, ]); }); });