Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
81 changes: 69 additions & 12 deletions packages/openapi3/test/tagmetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
},
]);
});

Expand All @@ -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",
},
]);
});

Expand All @@ -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",
},
]);
});
});
Loading