From 19c41f15e82fe2a5d18be4becf37d48e230b1989 Mon Sep 17 00:00:00 2001 From: "Ben Scholzen (DASPRiD)" Date: Mon, 28 Sep 2026 21:22:41 +0200 Subject: [PATCH] fix(openapi): emit OpenAPI 3.1 schemas instead of 3.0 ones Request bodies and query parameters were converted with zod's openapi-3.0 target, although every builder documents its output as OpenAPI 3.1. Under 3.1 that output is misread: `nullable: true` is ignored, so a validator rejects null where the schema allows it, and a boolean `exclusiveMinimum` fails the meta-schema. Conversions now use JSON Schema 2020-12, which OpenAPI 3.1 builds on, without the root `$schema` the conversion adds. Resource and relationship `type` fields are written as `const` rather than a single-value `enum`, matching what the conversion emits for literals. Specs generated with this version change shape: literals become `const`, nullable primitives `type: [..., "null"]` and other nullable schemas an `anyOf` with `{ type: "null" }`, exclusive bounds numbers, tuples `prefixItems`, and extracted definitions move from `definitions` to `$defs`. Snapshot tests of a generated spec need updating, and request body and query parameter output no longer fits an OpenAPI 3.0 document. --- .../docs/src/server/openapi/data-responses.md | 4 +- packages/openapi/src/body.ts | 17 ++------ packages/openapi/src/json-schema.ts | 18 ++++++++ packages/openapi/src/query.ts | 6 +-- packages/openapi/src/response.ts | 4 +- packages/openapi/test/body.test.ts | 43 +++++++++++++++---- packages/openapi/test/query.test.ts | 15 +++++++ packages/openapi/test/response.test.ts | 8 ++-- 8 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 packages/openapi/src/json-schema.ts diff --git a/packages/docs/src/server/openapi/data-responses.md b/packages/docs/src/server/openapi/data-responses.md index 1f09897..951540e 100644 --- a/packages/docs/src/server/openapi/data-responses.md +++ b/packages/docs/src/server/openapi/data-responses.md @@ -15,7 +15,7 @@ const articleSchema = { type: "object", properties: { id: { type: "string" }, - type: { type: "string", enum: ["articles"] }, + type: { type: "string", const: "articles" }, attributes: { type: "object", properties: { @@ -49,7 +49,7 @@ const response = buildDataResponseObject({ type: "object", properties: { id: { type: "string" }, - type: { type: "string", enum: ["people"] }, + type: { type: "string", const: "people" }, attributes: { type: "object", properties: { diff --git a/packages/openapi/src/body.ts b/packages/openapi/src/body.ts index 1014f2a..efb7d2d 100644 --- a/packages/openapi/src/body.ts +++ b/packages/openapi/src/body.ts @@ -2,7 +2,7 @@ import type { AnyParseResourceRequestOptions } from "@jsonapi-serde/server/reque import type { ContentObject, SchemaObject } from "openapi3-ts/oas31"; import { z } from "zod/v4"; import type { $ZodType } from "zod/v4/core"; -import { toJSONSchema } from "zod/v4/core"; +import { toSchemaObject } from "./json-schema.js"; /** * Generates an OpenAPI 3.1 content object for resource requests based on parser options @@ -30,10 +30,7 @@ export const buildResourceRequestContentObject = ( resourceShape.meta = options.metaSchema; } - const resourceSchema = toJSONSchema(z.strictObject(resourceShape), { - io: "input", - target: "openapi-3.0", - }) as SchemaObject; + const resourceSchema = toSchemaObject(z.strictObject(resourceShape)); const rootProperties: Record = { data: resourceSchema, @@ -57,10 +54,7 @@ export const buildResourceRequestContentObject = ( shape.relationships = options.relationshipsSchema; } - return toJSONSchema(z.strictObject(shape), { - io: "input", - target: "openapi-3.0", - }) as SchemaObject; + return toSchemaObject(z.strictObject(shape)); }), }, }; @@ -95,10 +89,7 @@ export const buildRelationshipsRequestContentObject = ( properties: { type: { const: type }, id: idSchema - ? (toJSONSchema(idSchema, { - io: "input", - target: "openapi-3.0", - }) as SchemaObject) + ? toSchemaObject(idSchema) : { type: "string", example: "abc" }, }, required: ["id", "type"], diff --git a/packages/openapi/src/json-schema.ts b/packages/openapi/src/json-schema.ts new file mode 100644 index 0000000..b498740 --- /dev/null +++ b/packages/openapi/src/json-schema.ts @@ -0,0 +1,18 @@ +import type { SchemaObject } from "openapi3-ts/oas31"; +import type { $ZodType } from "zod/v4/core"; +import { toJSONSchema } from "zod/v4/core"; + +/** + * Converts the input side of a zod schema to JSON Schema 2020-12, which OpenAPI 3.1 builds on + * + * The root `$schema` the conversion adds is removed: per OpenAPI 3.1.1 ยง4.8.24.5 it would override + * the document's dialect, the OpenAPI one by default, with plain JSON Schema 2020-12. + */ +export const toSchemaObject = (schema: $ZodType): SchemaObject => { + const { $schema: _dialect, ...schemaObject } = toJSONSchema(schema, { + io: "input", + target: "draft-2020-12", + }); + + return schemaObject as SchemaObject; +}; diff --git a/packages/openapi/src/query.ts b/packages/openapi/src/query.ts index 3f6d583..9fd963b 100644 --- a/packages/openapi/src/query.ts +++ b/packages/openapi/src/query.ts @@ -1,7 +1,7 @@ import type { ParseQueryOptions, SparseFieldSets } from "@jsonapi-serde/server/request"; -import type { ParameterObject, SchemaObject } from "openapi3-ts/oas31"; +import type { ParameterObject } from "openapi3-ts/oas31"; import type { $ZodShape, $ZodType } from "zod/v4/core"; -import { toJSONSchema } from "zod/v4/core"; +import { toSchemaObject } from "./json-schema.js"; /** * Generates OpenAPI 3.1 parameters based on query options @@ -101,7 +101,7 @@ const buildSchemaParameter = ( name, in: "query", ...(style ? { style } : {}), - schema: toJSONSchema(schema, { io: "input", target: "openapi-3.0" }) as SchemaObject, + schema: toSchemaObject(schema), required: schema._zod.optin === undefined ? true : undefined, }); diff --git a/packages/openapi/src/response.ts b/packages/openapi/src/response.ts index 97fb0f6..df16bd9 100644 --- a/packages/openapi/src/response.ts +++ b/packages/openapi/src/response.ts @@ -57,7 +57,7 @@ export const buildResourceSchemaObject = ( }, type: { type: "string", - enum: [options.type], + const: options.type, }, }; @@ -81,7 +81,7 @@ export const buildResourceSchemaObject = ( type: "object", properties: { id: relationship.id ?? { type: "string", example: "abc" }, - type: { type: "string", enum: [relationship.type] }, + type: { type: "string", const: relationship.type }, }, required: ["id", "type"], }; diff --git a/packages/openapi/test/body.test.ts b/packages/openapi/test/body.test.ts index 5e33d6c..c0209b6 100644 --- a/packages/openapi/test/body.test.ts +++ b/packages/openapi/test/body.test.ts @@ -22,7 +22,7 @@ describe("body", () => { type: "object", required: ["type"], properties: { - type: { type: "string", enum: ["book"] }, + type: { type: "string", const: "book" }, }, additionalProperties: false, }, @@ -49,7 +49,7 @@ describe("body", () => { type: "object", required: ["type", "id"], properties: { - type: { type: "string", enum: ["book"] }, + type: { type: "string", const: "book" }, id: { type: "string", format: "uuid" }, }, }, @@ -75,7 +75,7 @@ describe("body", () => { type: "object", required: ["type", "attributes"], properties: { - type: { type: "string", enum: ["book"] }, + type: { type: "string", const: "book" }, attributes: { type: "object", properties: { @@ -91,6 +91,31 @@ describe("body", () => { }); }); + it("describes a nullable attribute as a type union including null", () => { + const result = buildResourceRequestContentObject({ + type: "book", + attributesSchema: z.object({ subtitle: z.string().nullable() }), + }); + + assert.partialDeepStrictEqual(result, { + "application/vnd.api+json": { + schema: { + properties: { + data: { + properties: { + attributes: { + properties: { + subtitle: { type: ["string", "null"] }, + }, + }, + }, + }, + }, + }, + }, + }); + }); + it('includes "relationships" when relationshipsSchema is provided', () => { const result = buildResourceRequestContentObject({ type: "book", @@ -109,7 +134,7 @@ describe("body", () => { type: "object", required: ["type", "relationships"], properties: { - type: { type: "string", enum: ["book"] }, + type: { type: "string", const: "book" }, relationships: { type: "object", properties: { @@ -156,7 +181,7 @@ describe("body", () => { type: "object", required: ["type", "meta"], properties: { - type: { type: "string", enum: ["book"] }, + type: { type: "string", const: "book" }, meta: { type: "object", properties: { @@ -193,7 +218,7 @@ describe("body", () => { type: "object", required: ["type", "id", "attributes", "relationships", "meta"], properties: { - type: { type: "string", enum: ["book"] }, + type: { type: "string", const: "book" }, id: { type: "string" }, attributes: { type: "object", @@ -258,7 +283,7 @@ describe("body", () => { type: "object", properties: { lid: { type: "string" }, - type: { type: "string", enum: ["comment"] }, + type: { type: "string", const: "comment" }, attributes: { type: "object" }, }, }, @@ -266,7 +291,7 @@ describe("body", () => { type: "object", properties: { lid: { type: "string" }, - type: { type: "string", enum: ["author"] }, + type: { type: "string", const: "author" }, relationships: { type: "object" }, }, }, @@ -274,7 +299,7 @@ describe("body", () => { type: "object", properties: { lid: { type: "string" }, - type: { type: "string", enum: ["empty"] }, + type: { type: "string", const: "empty" }, }, }, ], diff --git a/packages/openapi/test/query.test.ts b/packages/openapi/test/query.test.ts index 68524ba..6aa4857 100644 --- a/packages/openapi/test/query.test.ts +++ b/packages/openapi/test/query.test.ts @@ -132,6 +132,21 @@ describe("query", () => { assert.equal(result[0].required, true); }); + it("describes a nullable `filter` field as a type union including null", () => { + const result = buildQueryParameters({ + filter: z.object({ publishedBefore: z.string().nullable() }), + }); + + assert.partialDeepStrictEqual(result[0], { + name: "filter", + schema: { + properties: { + publishedBefore: { type: ["string", "null"] }, + }, + }, + }); + }); + it("includes optional `page` parameter when zod type is optional", () => { const result = buildQueryParameters({ page: z.object({ number: z.number() }).optional(), diff --git a/packages/openapi/test/response.test.ts b/packages/openapi/test/response.test.ts index 633775d..66df765 100644 --- a/packages/openapi/test/response.test.ts +++ b/packages/openapi/test/response.test.ts @@ -25,7 +25,7 @@ describe("response", () => { }, type: { type: "string", - enum: ["book"], + const: "book", }, }, required: ["id", "type"], @@ -90,7 +90,7 @@ describe("response", () => { type: "object", properties: { id: { type: "string" }, - type: { type: "string", enum: ["person"] }, + type: { type: "string", const: "person" }, }, required: ["id", "type"], }, @@ -125,7 +125,7 @@ describe("response", () => { type: "object", properties: { id: { type: "string" }, - type: { type: "string", enum: ["person"] }, + type: { type: "string", const: "person" }, }, required: ["id", "type"], }, @@ -163,7 +163,7 @@ describe("response", () => { type: "object", properties: { id: { type: "string", example: "abc" }, - type: { type: "string", enum: ["comment"] }, + type: { type: "string", const: "comment" }, }, required: ["id", "type"], },