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
4 changes: 2 additions & 2 deletions packages/docs/src/server/openapi/data-responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
17 changes: 4 additions & 13 deletions packages/openapi/src/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, SchemaObject> = {
data: resourceSchema,
Expand All @@ -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));
}),
},
};
Expand Down Expand Up @@ -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"],
Expand Down
18 changes: 18 additions & 0 deletions packages/openapi/src/json-schema.ts
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 3 additions & 3 deletions packages/openapi/src/query.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions packages/openapi/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const buildResourceSchemaObject = (
},
type: {
type: "string",
enum: [options.type],
const: options.type,
},
};

Expand All @@ -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"],
};
Expand Down
43 changes: 34 additions & 9 deletions packages/openapi/test/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe("body", () => {
type: "object",
required: ["type"],
properties: {
type: { type: "string", enum: ["book"] },
type: { type: "string", const: "book" },
},
additionalProperties: false,
},
Expand All @@ -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" },
},
},
Expand All @@ -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: {
Expand All @@ -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",
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -258,23 +283,23 @@ describe("body", () => {
type: "object",
properties: {
lid: { type: "string" },
type: { type: "string", enum: ["comment"] },
type: { type: "string", const: "comment" },
attributes: { type: "object" },
},
},
{
type: "object",
properties: {
lid: { type: "string" },
type: { type: "string", enum: ["author"] },
type: { type: "string", const: "author" },
relationships: { type: "object" },
},
},
{
type: "object",
properties: {
lid: { type: "string" },
type: { type: "string", enum: ["empty"] },
type: { type: "string", const: "empty" },
},
},
],
Expand Down
15 changes: 15 additions & 0 deletions packages/openapi/test/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
8 changes: 4 additions & 4 deletions packages/openapi/test/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("response", () => {
},
type: {
type: "string",
enum: ["book"],
const: "book",
},
},
required: ["id", "type"],
Expand Down Expand Up @@ -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"],
},
Expand Down Expand Up @@ -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"],
},
Expand Down Expand Up @@ -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"],
},
Expand Down
Loading