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
34 changes: 32 additions & 2 deletions lib/normalize-spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
*
* It also drops `format: uint64`, and `format: int64` from integer schemas
* whose bounds fit in a JS number, so the generated validators match the
* generated `number` types.
* generated `number` types, and inlines single-member `allOf`s that only add
* annotations, so a field keeps its own description.
*/

import { readFileSync, writeFileSync } from "node:fs";
Expand Down Expand Up @@ -224,10 +225,38 @@ function dropNumberSafeFormats(node) {
Object.values(node).forEach(dropNumberSafeFormats);
}

// Keywords that only annotate a schema, so they can sit on the member of a
// single-member `allOf` without changing what it accepts.
const ANNOTATION_KEYWORDS = new Set(["default", "deprecated", "description", "example", "nullable", "readOnly", "title", "writeOnly"]);

/**
* Inlines the member of a single-member `allOf` whose other keywords only
* annotate it, such as `{ allOf: [X], description, nullable }`. Hey API merges
* such a member over its wrapper, so the member's own description replaced the
* field's. Inlining keeps the wrapper's annotations and the member's schema.
*/
function inlineAnnotatedAllOf(node) {
if (Array.isArray(node)) {
node.forEach(inlineAnnotatedAllOf);
return;
}
if (typeof node !== "object" || node === null) return;
const { allOf, ...annotations } = node;
if (
allOf?.length === 1 && typeof allOf[0] === "object" && allOf[0] !== null && !("$ref" in allOf[0])
&& Object.keys(annotations).every(key => ANNOTATION_KEYWORDS.has(key))
) {
delete node.allOf;
Object.assign(node, allOf[0], annotations);
}
Object.values(node).forEach(inlineAnnotatedAllOf);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested allOf wrappers are not flattened

Low Severity

inlineAnnotatedAllOf inlines the current node before walking children, so a nested single-member allOf copied from the member stays on the wrapper. Hey API then merges that leftover member over the wrapper and the inner description replaces the field's.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9fa9fad. Configure here.


/**
* Reads the spec at `inputPath`, rewrites all sentence-style operationIds to
* short REST-conventional names, drops uint64 formats and int64 formats from
* integers that fit in a JS number, and writes the result to `outputPath`.
* integers that fit in a JS number, inlines annotated single-member `allOf`s,
* and writes the result to `outputPath`.
*
* Throws on name collisions — two operations normalizing to the same identifier
* would produce duplicate SDK exports, which must be resolved explicitly via
Expand Down Expand Up @@ -280,6 +309,7 @@ export function normalizeSpec(inputPath, outputPath) {
}

dropNumberSafeFormats(spec);
inlineAnnotatedAllOf(spec);
writeFileSync(outputPath, JSON.stringify(spec));
return spec;
}
15 changes: 13 additions & 2 deletions test/descriptions.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs";
import * as v from "valibot";
import { describe, expect, test } from "vitest";
import { vDeployResponse, vUpdateOrganizationDetectorResponse } from "../src/valibot";
import { zDeployResponse, zUpdateOrganizationDetectorResponse } from "../src/zod";
import { vCreateOrganizationDashboardData, vDeployResponse, vUpdateOrganizationDetectorResponse } from "../src/valibot";
import { zCreateOrganizationDashboardData, zDeployResponse, zUpdateOrganizationDetectorResponse } from "../src/zod";

const spec = JSON.parse(readFileSync(new URL("../openapi-derefed.json", import.meta.url), "utf8"));

describe("validator descriptions", () => {
test("Valibot fields carry their OpenAPI description as a description action", () => {
Expand All @@ -18,6 +21,14 @@ describe("validator descriptions", () => {
expect(zUpdateOrganizationDetectorResponse.shape.config.description).toEqual(expect.any(String));
});

test("fields that wrap one schema in allOf keep their own description", () => {
const description = spec.paths["/api/0/organizations/{organization_id_or_slug}/dashboards/"]
.post.requestBody.content["application/json"].schema.properties.permissions.description;
expect(description).toEqual(expect.any(String));
expect(v.getDescription(vCreateOrganizationDashboardData.entries.body.entries.permissions.wrapped)).toBe(description);
expect(zCreateOrganizationDashboardData.shape.body.shape.permissions.unwrap().unwrap().description).toBe(description);
});

test("Valibot top-level schemas stay unpiped for object methods", () => {
expect("pipe" in vDeployResponse).toBe(false);
expect(v.partial(vDeployResponse).entries.id.type).toBe("optional");
Expand Down
21 changes: 21 additions & 0 deletions test/normalize-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,24 @@ describe("normalizeSpec int64 formats", () => {
unlinkSync(output);
});
});

describe("normalizeSpec single-member allOf", () => {
it("inlines a member wrapped only by annotations, keeping the wrapper's annotations", () => {
const member = { type: "object", description: "Member description", properties: { id: { type: "string" } } };
const schemas = {
annotated: { allOf: [member], description: "Field description", nullable: true },
constrained: { allOf: [member], description: "Field description", required: ["id"] },
composed: { allOf: [member, { type: "object" }], description: "Field description" },
};
const input = join(tmpdir(), `test-spec-${Date.now()}.json`);
writeFileSync(input, JSON.stringify({ paths: {}, components: { schemas } }));
const output = join(tmpdir(), `out-${Date.now()}.json`);
normalizeSpec(input, output);
const result = JSON.parse(readFileSync(output, "utf8")).components.schemas;
expect(result.annotated).toEqual({ ...member, description: "Field description", nullable: true });
expect(result.constrained).toEqual(schemas.constrained);
expect(result.composed).toEqual(schemas.composed);
unlinkSync(input);
unlinkSync(output);
});
});
Loading