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,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
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down Expand Up @@ -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<string, ApiDefinition>();
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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)));
}
Expand Down Expand Up @@ -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
Expand Down