diff --git a/.chronus/changes/fix-examples-payload-order-2026-9-22-8-57-0.md b/.chronus/changes/fix-examples-payload-order-2026-9-22-8-57-0.md new file mode 100644 index 0000000000..5a677adf5d --- /dev/null +++ b/.chronus/changes/fix-examples-payload-order-2026-9-22-8-57-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-azure-examples" +--- + +Preserve request and response payload field order during migration. Retain version-specific variants when only field order changes, including nested objects and array items, and keep response body and headers in their original order. \ No newline at end of file diff --git a/packages/typespec-azure-examples/src/migrate/dedup.ts b/packages/typespec-azure-examples/src/migrate/dedup.ts index 93d71df246..8317aee80c 100644 --- a/packages/typespec-azure-examples/src/migrate/dedup.ts +++ b/packages/typespec-azure-examples/src/migrate/dedup.ts @@ -29,8 +29,8 @@ export interface BuildLineagesOptions { /** * Collapse an operation's collected examples into unified variants: group by lineage (example * name), order by version, keep one base entry, and emit a `since` variant only when the content - * changes from the previously-emitted entry. If the operation has a single lineage, the `title` is - * omitted (single-example case). + * or its field order changes from the previously-emitted entry. If the operation has a single + * lineage, the `title` is omitted (single-example case). */ export function buildLineages( examples: readonly CollectedExample[], @@ -52,7 +52,11 @@ export function buildLineages( let lastContent: string | undefined; let isFirst = true; for (const entry of ordered) { - const content = canonicalize(entry.variant); + // Include field order so each version reproduces its original payload. + const content = JSON.stringify({ + request: entry.variant.request, + responses: entry.variant.responses, + }); if (!isFirst && content === lastContent) continue; const variant: MigratedVariant = { @@ -80,23 +84,6 @@ export function buildLineages( return result; } -/** Stable, key-order-independent serialization used to compare variant content for equality. */ -function canonicalize(variant: MigratedVariant): string { - return JSON.stringify(sortValue({ request: variant.request, responses: variant.responses })); -} - -function sortValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortValue); - if (value !== null && typeof value === "object") { - const out: Record = {}; - for (const key of Object.keys(value as Record).sort()) { - out[key] = sortValue((value as Record)[key]); - } - return out; - } - return value; -} - /** Present keys in the canonical emission order: title, since, id, request, responses. */ function orderKeys(variant: MigratedVariant): MigratedVariant { return { diff --git a/packages/typespec-azure-examples/src/migrate/transform.ts b/packages/typespec-azure-examples/src/migrate/transform.ts index 6e72f8c58b..8fdc416b13 100644 --- a/packages/typespec-azure-examples/src/migrate/transform.ts +++ b/packages/typespec-azure-examples/src/migrate/transform.ts @@ -41,8 +41,10 @@ export function transformExample( for (const [code, response] of Object.entries(doc.responses ?? {})) { const mapped: MigratedResponse = {}; if (response && typeof response === "object") { - if (response.headers !== undefined) mapped.headers = response.headers; - if (response.body !== undefined) mapped.body = response.body; + for (const key of Object.keys(response)) { + if (key === "headers" && response.headers !== undefined) mapped.headers = response.headers; + if (key === "body" && response.body !== undefined) mapped.body = response.body; + } } responses[code] = mapped; } diff --git a/packages/typespec-azure-examples/test/migrate/migrate.e2e.test.ts b/packages/typespec-azure-examples/test/migrate/migrate.e2e.test.ts index 8aff9e1afc..ee73c32079 100644 --- a/packages/typespec-azure-examples/test/migrate/migrate.e2e.test.ts +++ b/packages/typespec-azure-examples/test/migrate/migrate.e2e.test.ts @@ -5,6 +5,7 @@ import { afterAll, describe, expect, it } from "vitest"; import { parse } from "yaml"; import { loadExampleFile } from "../../src/loader.js"; import { migrate } from "../../src/migrate/index.js"; +import { resolveLegacyExamples } from "../../src/resolve/legacy.js"; import { validateExampleFiles } from "../../src/validate.js"; const swagger = (version: string) => ({ @@ -66,6 +67,53 @@ async function buildFixture(): Promise { } describe("migrate (end-to-end)", () => { + it("preserves payload field order through YAML and legacy expansion for each version", async () => { + const root = await buildFixture(); + const versions = ["2023-01-01", "2024-06-01"]; + const bodies = [ + { zeta: "last", nested: { second: 2, first: 1 }, items: [{ value: 1, name: "item" }] }, + { items: [{ name: "item", value: 1 }], nested: { first: 1, second: 2 }, zeta: "last" }, + ]; + const originals = versions.map((version, index) => ({ + title: "Create", + operationId: "Things_Create", + parameters: { + "api-version": version, + subscriptionId: "sub", + id: "1", + resource: bodies[index], + }, + responses: { + "200": + index === 0 + ? { body: bodies[index], headers: { "x-second": "2", "x-first": "1" } } + : { headers: { "x-first": "1", "x-second": "2" }, body: bodies[index] }, + }, + })); + for (const [index, version] of versions.entries()) { + await writeFile( + join(root, "stable", version, "examples", "Create.json"), + JSON.stringify(originals[index]), + ); + } + await writeFile( + join(root, "service.yaml"), + 'versions:\n - version: "2023-01-01"\n - version: "2024-06-01"\n', + ); + + const result = await migrate(root); + expect(result.diagnostics).toEqual([]); + expect(parse(result.files[0].content)["Things.create"]).toHaveLength(2); + await writeFile(join(root, "examples.yaml"), result.files[0].content); + + for (const [index, version] of versions.entries()) { + const expanded = await resolveLegacyExamples(root, version); + expect(expanded.diagnostics).toEqual([]); + const document = expanded.files.find((file) => file.fileName === "Create.json")?.document; + expect(JSON.stringify(document)).toBe(JSON.stringify(originals[index])); + } + }); + it("produces a valid, collapsed examples.yaml", async () => { const root = await buildFixture(); const result = await migrate(root); diff --git a/packages/typespec-azure-examples/test/roundtrip/roundtrip.test.ts b/packages/typespec-azure-examples/test/roundtrip/roundtrip.test.ts index f0e9ba7eb1..e04e77e633 100644 --- a/packages/typespec-azure-examples/test/roundtrip/roundtrip.test.ts +++ b/packages/typespec-azure-examples/test/roundtrip/roundtrip.test.ts @@ -86,6 +86,9 @@ describe("migrate → resolve round-trip (Microsoft.Contoso)", () => { // The full reconstructed document (title + operationId envelope, parameters, responses) // must match the original x-ms-examples file exactly. expect(legacy, `document mismatch for ${key}`).toEqual(original!.doc); + expect(JSON.stringify(legacy.responses), `response field order mismatch for ${key}`).toBe( + JSON.stringify(original!.doc.responses), + ); // `title` must be the first key, matching the dominant legacy convention. expect(Object.keys(legacy).slice(0, 2)).toEqual(["title", "operationId"]); }