diff --git a/lib/normalize-spec.mjs b/lib/normalize-spec.mjs index 023e5a9..7e592ac 100644 --- a/lib/normalize-spec.mjs +++ b/lib/normalize-spec.mjs @@ -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"; @@ -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); +} + /** * 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 @@ -280,6 +309,7 @@ export function normalizeSpec(inputPath, outputPath) { } dropNumberSafeFormats(spec); + inlineAnnotatedAllOf(spec); writeFileSync(outputPath, JSON.stringify(spec)); return spec; } diff --git a/test/descriptions.test.ts b/test/descriptions.test.ts index 134f15a..f39cd90 100644 --- a/test/descriptions.test.ts +++ b/test/descriptions.test.ts @@ -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", () => { @@ -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"); diff --git a/test/normalize-spec.test.ts b/test/normalize-spec.test.ts index fcf608b..9322189 100644 --- a/test/normalize-spec.test.ts +++ b/test/normalize-spec.test.ts @@ -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); + }); +});