diff --git a/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml b/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml new file mode 100644 index 000000000000..d67a0e625652 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml @@ -0,0 +1,8 @@ +- summary: | + Fix ledger publish reordering JSON keys in API example bodies. The + `stableStringify` serialization sorted all object keys recursively, + which reordered preformatted request/response example JSON. Example + body fields (`requestBody`, `responseBody`, webhook `payload`, and + websocket message `body`) are now treated as preformatted content + and skipped by the key-sorting replacer. + type: fix diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts index d5d933b04242..9a8d43814975 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts @@ -266,11 +266,10 @@ describe("buildLedgerInput", () => { it("apiManifest blob hash is stable across Map insertion order (determinism guard)", () => { // Reproduces the docs-ledger deterministic-hash bug: `apiDefinitions` // is built by `Promise.all` of /api/register calls in CLI, so its Map - // insertion order is whichever round-trip completed first. Without - // `stableStringify`, byte-identical content would hash differently - // across publishes and the docs-ledger "no-op republish" fast-path - // would never fire. The two manifests below have identical entries - // inserted in opposite orders and MUST produce the same blob hash. + // insertion order is whichever round-trip completed first. `stableStringify` + // sorts object keys so two semantically identical payloads hash the same + // regardless of insertion order. The two manifests below have identical + // entries inserted in opposite orders and MUST produce the same blob hash. const minimalApiDefinition: ApiDefinition = { types: {}, subpackages: {}, @@ -396,6 +395,86 @@ describe("buildLedgerInput", () => { expect(translatedParsed["api-1"].apiName).toBe("plant-api-zh"); }); + it("preserves example body key order in apiManifest blob (PREFORMATTED_KEYS skip)", () => { + // API example bodies (requestBody / responseBody) are preformatted + // user content — their field order is intentional. stableStringify + // skips recursive key-sorting for values under PREFORMATTED_KEYS, + // so the serialized blob preserves the original key order in example + // JSON while still sorting structural keys for determinism. + // + // We embed an endpoint with a responseBody whose keys are + // intentionally NOT in alphabetical order. After serialization, + // the blob must preserve that order. + const apiDefWithExample: ApiDefinition = { + types: {}, + subpackages: {}, + rootPackage: { + endpoints: [ + { + description: undefined, + availability: undefined, + id: "getPlant", + name: undefined, + path: { + parts: [], + pathParameters: [] + }, + queryParameters: [], + headers: [], + request: undefined, + response: undefined, + errors: undefined, + examples: [ + { + path: "/plants/plant-123", + pathParameters: {}, + queryParameters: {}, + headers: {}, + requestBody: undefined, + responseStatusCode: 200, + responseBody: { + name: "Monstera Deliciosa", + species: "Monstera", + id: "plant-123", + care: { + water: "weekly", + sunlight: "indirect", + difficulty: "easy" + } + } + } + ], + method: "GET", + environments: undefined + } + ], + types: [], + subpackages: [], + websockets: [], + webhooks: [] + }, + auth: undefined, + snippetsConfiguration: {}, + globalHeaders: [] + }; + + const apiDefinitions = new Map(); + apiDefinitions.set("api-1", apiDefWithExample); + + const { localeEntry, blobs } = buildLedgerInput({ + docsDefinition: makeDocsDefinition(), + apiDefinitions + }); + + const manifestBuf = blobs.get(localeEntry.apiManifest?.hash ?? ""); + expect(manifestBuf).toBeDefined(); + const parsed = JSON.parse(manifestBuf?.toString("utf-8") ?? ""); + + const responseBody = parsed["api-1"].rootPackage.endpoints[0].examples[0].responseBody; + expect(Object.keys(responseBody)).toEqual(["name", "species", "id", "care"]); + expect(Object.keys(responseBody.care)).toEqual(["water", "sunlight", "difficulty"]); + }); + it("forwards fileManifest unchanged (file blobs are loaded lazily, not included in blob map)", () => { // Image entry — exercises width/height fields. const imageBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG header diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts index 6920c2b948ba..8ab7ebadd763 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts @@ -74,14 +74,48 @@ interface BlobRef { } /** - * `JSON.stringify` with deterministic key ordering at every level. Arrays - * keep their original ordering (positions are meaningful); object keys are - * sorted lexicographically via the replacer. Two inputs that differ only by - * key insertion order serialize identically — required so the apiManifest + * Keys whose values are preformatted user content (e.g. API example + * request/response JSON) and must not have their object keys reordered. + * {@link stableStringify} skips recursive key-sorting for the entire + * subtree rooted at these values. + * + * Covers endpoint examples (`requestBody`, `responseBody`), webhook + * examples (`payload`), and websocket message examples (`body`). All + * are typed as `z.unknown()` in the fdr-sdk register contract. + */ +const PREFORMATTED_KEYS: ReadonlySet = new Set(["requestBody", "responseBody", "payload", "body"]); + +/** + * `JSON.stringify` with deterministic key ordering at every level, except + * for subtrees rooted at {@link PREFORMATTED_KEYS} which are passed through + * as-is to preserve their original field order. Arrays keep their original + * ordering (positions are meaningful); all other object keys are sorted + * lexicographically via the replacer. Two inputs that differ only by key + * insertion order serialize identically — required so the apiManifest * blob hash is stable across publishes (cf. FDR `stableStringify`). + * + * Uses a WeakSet to track which objects belong to a preformatted subtree + * so that deeply nested keys (e.g. `care.water` inside a `responseBody`) + * are also preserved. */ function stableStringify(value: unknown): string { - return JSON.stringify(value, (_key, val) => { + const preformatted = new WeakSet(); + return JSON.stringify(value, function (key, val) { + // When we encounter a preformatted key, mark its value and return + // immediately — the entire subtree should be serialized as-is. + if (PREFORMATTED_KEYS.has(key) && val != null && typeof val === "object") { + preformatted.add(val); + return val; + } + // `this` is the holder (parent) object for the current key. + // If the parent is in a preformatted subtree, propagate the mark + // to child objects and return the value without sorting. + if (this != null && typeof this === "object" && preformatted.has(this)) { + if (val != null && typeof val === "object") { + preformatted.add(val); + } + return val; + } if (val != null && typeof val === "object" && !Array.isArray(val)) { return Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); } @@ -188,8 +222,9 @@ export function buildLedgerInput({ // `Promise.all`, so the Map's insertion order reflects whichever HTTP // round-trip completed first — non-deterministic across publishes. // {@link jsonBlobRef} uses {@link stableStringify}, which sorts object - // keys at every level, so the resulting bytes are stable regardless of - // Map iteration order. + // keys at every level for deterministic hashing — except for values + // under {@link PREFORMATTED_KEYS} (example bodies), which are passed + // through as-is to preserve their original field order. // // Determinism caveat: stable apiManifest bytes are necessary but not // sufficient for a deterministic deployment hash. Page bodies must also