Skip to content
Draft
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
140 changes: 84 additions & 56 deletions app/api/v1/_lib/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,42 @@ import { z } from 'zod';

/**
* `Z-5`: the spec is generated from the Zod schemas, never hand-written. A walker rather than a
* dependency because `zod-to-openapi` is not installed and `package.json` is frozen — and the
* subset of Zod this API uses is small and entirely under our control.
* dependency because `zod-to-openapi` is not installed and the subset of Zod this API uses is
* small and entirely under our control.
*
* Anything not handled below degrades to `{}`, which is a valid "any" in JSON Schema, so an
* unrecognised type produces a permissive spec rather than a wrong one.
* unrecognised type produces a permissive spec rather than a wrong one. That is a kind fallback in
* the ordinary case and a dangerous one here: Zod 4 moved every internal this file reads, from
* `_def.typeName: 'ZodString'` to `_zod.def.type: 'string'`, and a version skew would therefore
* produce a spec that is empty rather than a build that fails. `openapi.test.ts` pins enough of the
* output that the walker cannot quietly stop recognising the schemas it is walking.
*/

export type JsonSchema = Record<string, unknown>;

/** Zod 4 keeps the shape of a schema under `_zod.def`, and its refinements under `check._zod.def`. */
type ZodDef = {
type: string;
innerType?: z.ZodTypeAny;
in?: z.ZodTypeAny;
out?: z.ZodTypeAny;
element?: z.ZodTypeAny;
valueType?: z.ZodTypeAny;
options?: z.ZodTypeAny[];
entries?: Record<string, string>;
values?: unknown[];
catchall?: z.ZodTypeAny;
checks?: { _zod: { def: Record<string, unknown> } }[];
};

function defOf(schema: z.ZodTypeAny): ZodDef {
return (schema as unknown as { _zod: { def: ZodDef } })._zod.def;
}

function checksOf(def: ZodDef): Record<string, unknown>[] {
return (def.checks ?? []).map((check) => check._zod.def);
}

type Unwrapped = {
schema: z.ZodTypeAny;
optional: boolean;
Expand All @@ -22,33 +49,34 @@ function unwrap(schema: z.ZodTypeAny): Unwrapped {
let current = schema;
let optional = false;
let nullable = false;
let description = current._def.description as string | undefined;
let description = current.description;

for (;;) {
const def = current._def as {
typeName?: string;
innerType?: z.ZodTypeAny;
description?: string;
};
description = description ?? def.description;
const def = defOf(current);
description = description ?? current.description;

if (def.typeName === 'ZodOptional') {
if (def.type === 'optional') {
optional = true;
current = def.innerType as z.ZodTypeAny;
current = def.innerType!;
continue;
}
if (def.typeName === 'ZodNullable') {
if (def.type === 'nullable') {
nullable = true;
current = def.innerType as z.ZodTypeAny;
current = def.innerType!;
continue;
}
if (def.typeName === 'ZodDefault' || def.typeName === 'ZodCatch') {
if (def.type === 'default' || def.type === 'catch') {
optional = true;
current = def.innerType as z.ZodTypeAny;
current = def.innerType!;
continue;
}
if (def.typeName === 'ZodEffects') {
current = (current._def as unknown as { schema: z.ZodTypeAny }).schema;
// `.transform()` and `z.preprocess()` are both pipes in Zod 4, and they face opposite ways:
// a transform pipes the declared schema into a coercion, a preprocess pipes a coercion into
// the declared schema. Either way the side that is not the `transform` is the one describing
// the shape a caller sends — the `limit`/`offset` query params are preprocessed numbers, and
// reading `in` unconditionally would document them as an untyped `{}`.
if (def.type === 'pipe') {
current = defOf(def.in!).type === 'transform' ? def.out! : def.in!;
continue;
}
break;
Expand All @@ -58,60 +86,59 @@ function unwrap(schema: z.ZodTypeAny): Unwrapped {
schema: current,
optional,
nullable,
description: description ?? current._def.description,
description: description ?? current.description,
};
}

export function toJsonSchema(input: z.ZodTypeAny): JsonSchema {
const { schema, nullable, description } = unwrap(input);
const def = schema._def as Record<string, unknown>;
const typeName = def.typeName as string;
const def = defOf(schema);

const base = ((): JsonSchema => {
switch (typeName) {
case 'ZodString': {
switch (def.type) {
case 'string': {
const out: JsonSchema = { type: 'string' };
for (const check of (def.checks as {
kind: string;
value?: unknown;
}[]) ?? []) {
if (check.kind === 'email') out.format = 'email';
if (check.kind === 'url') out.format = 'uri';
if (check.kind === 'datetime') out.format = 'date-time';
if (check.kind === 'min') out.minLength = check.value;
if (check.kind === 'max') out.maxLength = check.value;
for (const check of checksOf(def)) {
if (check.check === 'string_format') {
if (check.format === 'email') out.format = 'email';
if (check.format === 'url') out.format = 'uri';
if (check.format === 'datetime') out.format = 'date-time';
}
if (check.check === 'min_length') out.minLength = check.minimum;
if (check.check === 'max_length') out.maxLength = check.maximum;
}
return out;
}
case 'ZodNumber': {
const checks = (def.checks as { kind: string; value?: unknown }[]) ?? [];
case 'number': {
const checks = checksOf(def);
// `.int()` is a number *format* in Zod 4 rather than a range check of its own.
const out: JsonSchema = {
type: checks.some((c) => c.kind === 'int') ? 'integer' : 'number',
type: checks.some((c) => c.check === 'number_format' && c.format === 'safeint')
? 'integer'
: 'number',
};
for (const check of checks) {
if (check.kind === 'min') out.minimum = check.value;
if (check.kind === 'max') out.maximum = check.value;
if (check.check === 'greater_than') out.minimum = check.value;
if (check.check === 'less_than') out.maximum = check.value;
}
return out;
}
case 'ZodBoolean':
case 'boolean':
return { type: 'boolean' };
case 'ZodEnum':
return { type: 'string', enum: def.values as string[] };
case 'ZodLiteral':
return { const: def.value };
case 'ZodArray':
return { type: 'array', items: toJsonSchema(def.type as z.ZodTypeAny) };
case 'ZodRecord':
return {
type: 'object',
additionalProperties: toJsonSchema(def.valueType as z.ZodTypeAny),
};
case 'ZodUnion':
return { anyOf: (def.options as z.ZodTypeAny[]).map(toJsonSchema) };
case 'ZodNull':
case 'enum':
return { type: 'string', enum: Object.values(def.entries ?? {}) };
case 'literal':
// Zod 4 literals hold a set; every one this API declares holds exactly one member.
return { const: def.values?.[0] };
case 'array':
return { type: 'array', items: toJsonSchema(def.element!) };
case 'record':
return { type: 'object', additionalProperties: toJsonSchema(def.valueType!) };
case 'union':
return { anyOf: (def.options ?? []).map(toJsonSchema) };
case 'null':
return { type: 'null' };
case 'ZodObject': {
case 'object': {
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
const properties: Record<string, JsonSchema> = {};
const required: string[] = [];
Expand All @@ -122,12 +149,13 @@ export function toJsonSchema(input: z.ZodTypeAny): JsonSchema {
}

const out: JsonSchema = { type: 'object', properties };
if ((def.unknownKeys as string | undefined) === 'strict') out.additionalProperties = false;
// `z.strictObject` is a `never` catchall in Zod 4, where it used to be `unknownKeys`.
if (def.catchall && defOf(def.catchall).type === 'never') out.additionalProperties = false;
if (required.length > 0) out.required = required;
return out;
}
case 'ZodUnknown':
case 'ZodAny':
case 'unknown':
case 'any':
return {};
default:
return {};
Expand Down
6 changes: 3 additions & 3 deletions app/api/v1/_lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export const submissionSchema = z
level: z.string().nullable(),
tags: z.array(z.string()),
submitter: z.object({ name: z.string().nullable(), email: z.string() }),
answers: z.record(z.unknown()).describe('Custom form answers, keyed by field key'),
answers: z.record(z.string(), z.unknown()).describe('Custom form answers, keyed by field key'),
submittedAt: z.string().nullable().describe('ISO 8601'),
decidedAt: z.string().nullable().describe('ISO 8601'),
})
Expand Down Expand Up @@ -378,7 +378,7 @@ export const formFieldSchema = z.object({
step: z.number().int(),
required: z.boolean(),
options: z.array(z.string()).nullable(),
optionLabels: z.record(z.string()).nullable(),
optionLabels: z.record(z.string(), z.string()).nullable(),
showIf: conditionSchema.nullable(),
minLength: z.number().int().nullable(),
maxLength: z.number().int().nullable(),
Expand Down Expand Up @@ -772,7 +772,7 @@ export const errorResponse = z
]),
message: z.string(),
details: z
.record(z.string())
.record(z.string(), z.string())
.optional()
.describe('Field-keyed messages when code is invalid'),
}),
Expand Down
Loading