Skip to content
Merged
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:
- "@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.
27 changes: 7 additions & 20 deletions packages/typespec-azure-examples/src/migrate/dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = sortValue((value as Record<string, unknown>)[key]);
}
return out;
}
return value;
}

/** Present keys in the canonical emission order: title, since, id, request, responses. */
function orderKeys(variant: MigratedVariant): MigratedVariant {
return {
Expand Down
6 changes: 4 additions & 2 deletions packages/typespec-azure-examples/src/migrate/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -66,6 +67,53 @@ async function buildFixture(): Promise<string> {
}

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}
Expand Down
Loading