Skip to content

Commit 154627f

Browse files
authored
Restoring immutable depicters (#2583)
Delegating prop replacement to the framework, so that public interface for custom brands handling could remain similar and more convenient.
1 parent 3829416 commit 154627f

7 files changed

Lines changed: 167 additions & 140 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- Express Zod API implements some overrides and improvements to fit it into OpenAPI 3.1 that extends JSON Schema;
1616
- The `numericRange` option removed from `Documentation` class constructor argument;
1717
- The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4;
18-
- The `Depicter` type changed to `Overrider` having different signature;
18+
- The `Depicter` type signature changed;
1919
- The `optionalPropStyle` option removed from `Integration` class constructor:
2020
- Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface;
2121
- Changes to the plugin:

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,28 +1387,28 @@ const routing: Routing = {
13871387
You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your
13881388
schema to make it special and distinguishable for the framework in runtime. Using symbols is recommended for branding.
13891389
After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. In case you
1390-
need to reuse a handling rule for multiple brands, use the exposed types `Overrider` and `Producer`.
1390+
need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`.
13911391

13921392
```ts
13931393
import ts from "typescript";
13941394
import { z } from "zod";
13951395
import {
13961396
Documentation,
13971397
Integration,
1398-
Overrider,
1398+
Depicter,
13991399
Producer,
14001400
} from "express-zod-api";
14011401

14021402
const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose
14031403
const myBrandedSchema = z.string().brand(myBrand);
14041404

14051405
const ruleForDocs: Overrider = (
1406-
{ zodSchema, jsonSchema }, // adjust jsonSchema for overrides
1407-
{ path, method, isResponse }, // handle a nested schema using next()
1408-
) => {
1409-
delete jsonSchema.format;
1410-
jsonSchema.summary = "Special type of data";
1411-
};
1406+
{ zodSchema, jsonSchema }, // return changed jsonSchema
1407+
{ path, method, isResponse },
1408+
) => ({
1409+
...jsonSchema,
1410+
summary: "Special type of data",
1411+
});
14121412

14131413
const ruleForClient: Producer = (
14141414
schema: typeof myBrandedSchema, // you should assign type yourself

express-zod-api/src/documentation-helpers.ts

Lines changed: 81 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ export interface OpenAPIContext {
6565
method: Method;
6666
}
6767

68-
export type Overrider = (
68+
export type Depicter = (
6969
zodCtx: { zodSchema: $ZodType; jsonSchema: JSONSchema.BaseSchema },
7070
oasCtx: OpenAPIContext,
71-
) => void;
71+
) => JSONSchema.BaseSchema | SchemaObject;
7272

7373
/** @desc Using defaultIsHeader when returns null or undefined */
7474
export type IsHeader = (
@@ -77,7 +77,7 @@ export type IsHeader = (
7777
path: string,
7878
) => boolean | null | undefined;
7979

80-
export type BrandHandling = Record<string | symbol, Overrider>;
80+
export type BrandHandling = Record<string | symbol, Depicter>;
8181

8282
interface ReqResHandlingProps<S extends $ZodType>
8383
extends Omit<OpenAPIContext, "isResponse"> {
@@ -104,34 +104,36 @@ const samples = {
104104
export const reformatParamsInPath = (path: string) =>
105105
path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`);
106106

107-
export const onDefault: Overrider = ({ zodSchema, jsonSchema }) =>
108-
(jsonSchema.default =
107+
export const onDefault: Depicter = ({ zodSchema, jsonSchema }) => ({
108+
...jsonSchema,
109+
default:
109110
globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ??
110-
jsonSchema.default);
111+
jsonSchema.default,
112+
});
111113

112-
export const onUpload: Overrider = ({ jsonSchema }, ctx) => {
114+
export const onUpload: Depicter = ({}, ctx) => {
113115
if (ctx.isResponse)
114116
throw new DocumentationError("Please use ez.upload() only for input.", ctx);
115-
Object.assign(jsonSchema, { type: "string", format: "binary" });
117+
return { type: "string", format: "binary" };
116118
};
117119

118-
export const onFile: Overrider = ({ jsonSchema }) => {
119-
delete jsonSchema.anyOf; // undo default
120-
Object.assign(jsonSchema, {
121-
type: "string",
122-
format:
123-
jsonSchema.type === "string"
124-
? jsonSchema.format === "base64"
125-
? "byte"
126-
: "file"
127-
: "binary",
128-
});
129-
};
120+
export const onFile: Depicter = ({ jsonSchema }) => ({
121+
type: "string",
122+
format:
123+
jsonSchema.type === "string"
124+
? jsonSchema.format === "base64"
125+
? "byte"
126+
: "file"
127+
: "binary",
128+
});
130129

131-
export const onUnion: Overrider = ({ zodSchema, jsonSchema }) => {
132-
if (!zodSchema._zod.disc) return;
130+
export const onUnion: Depicter = ({ zodSchema, jsonSchema }) => {
131+
if (!zodSchema._zod.disc) return jsonSchema;
133132
const propertyName = Array.from(zodSchema._zod.disc.keys()).pop();
134-
jsonSchema.discriminator ??= { propertyName };
133+
return {
134+
...jsonSchema,
135+
discriminator: jsonSchema.discriminator ?? { propertyName },
136+
};
135137
};
136138

137139
const propsMerger = (a: unknown, b: unknown) => {
@@ -180,22 +182,20 @@ const intersect = (
180182
return R.map((fn) => fn(left, right), suitable);
181183
};
182184

183-
export const onIntersection: Overrider = ({ jsonSchema }) => {
184-
if (!jsonSchema.allOf) return;
185-
try {
186-
const attempt = intersect(jsonSchema.allOf);
187-
delete jsonSchema.allOf; // undo default
188-
Object.assign(jsonSchema, attempt);
189-
} catch {}
185+
export const onIntersection: Depicter = ({ jsonSchema }) => {
186+
if (jsonSchema.allOf) {
187+
try {
188+
return intersect(jsonSchema.allOf);
189+
} catch {}
190+
}
191+
return jsonSchema;
190192
};
191193

192194
/** @since OAS 3.1 nullable replaced with type array having null */
193-
export const onNullable: Overrider = ({ jsonSchema }) => {
194-
if (!jsonSchema.anyOf) return;
195+
export const onNullable: Depicter = ({ jsonSchema }) => {
196+
if (!jsonSchema.anyOf) return jsonSchema;
195197
const original = jsonSchema.anyOf[0];
196-
Object.assign(original, { type: makeNullableType(original.type) });
197-
Object.assign(jsonSchema, original);
198-
delete jsonSchema.anyOf;
198+
return Object.assign(original, { type: makeNullableType(original.type) });
199199
};
200200

201201
const isSupportedType = (subject: string): subject is SchemaObjectType =>
@@ -226,49 +226,47 @@ const ensureCompliance = ({
226226
return valid;
227227
};
228228

229-
export const onDateIn: Overrider = ({ jsonSchema }, ctx) => {
229+
export const onDateIn: Depicter = ({}, ctx) => {
230230
if (ctx.isResponse)
231231
throw new DocumentationError("Please use ez.dateOut() for output.", ctx);
232-
delete jsonSchema.anyOf; // undo default
233-
Object.assign(jsonSchema, {
232+
return {
234233
description: "YYYY-MM-DDTHH:mm:ss.sssZ",
235234
type: "string",
236235
format: "date-time",
237236
pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source,
238237
externalDocs: {
239238
url: isoDateDocumentationUrl,
240239
},
241-
});
240+
};
242241
};
243242

244-
export const onDateOut: Overrider = ({ jsonSchema }, ctx) => {
243+
export const onDateOut: Depicter = ({}, ctx) => {
245244
if (!ctx.isResponse)
246245
throw new DocumentationError("Please use ez.dateIn() for input.", ctx);
247-
Object.assign(jsonSchema, {
246+
return {
248247
description: "YYYY-MM-DDTHH:mm:ss.sssZ",
249248
type: "string",
250249
format: "date-time",
251250
externalDocs: {
252251
url: isoDateDocumentationUrl,
253252
},
254-
});
253+
};
255254
};
256255

257-
export const onBigInt: Overrider = ({ jsonSchema }) =>
258-
Object.assign(jsonSchema, {
259-
type: "string",
260-
format: "bigint",
261-
pattern: /^-?\d+$/.source,
262-
});
256+
export const onBigInt: Depicter = () => ({
257+
type: "string",
258+
format: "bigint",
259+
pattern: /^-?\d+$/.source,
260+
});
263261

264262
/**
265263
* @since OAS 3.1 using prefixItems for depicting tuples
266264
* @since 17.5.0 added rest handling, fixed tuple type
267265
*/
268-
export const onTuple: Overrider = ({ zodSchema, jsonSchema }) => {
269-
if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return;
266+
export const onTuple: Depicter = ({ zodSchema, jsonSchema }) => {
267+
if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return jsonSchema;
270268
// does not appear to support items:false, so not:{} is a recommended alias
271-
jsonSchema.items = { not: {} };
269+
return { ...jsonSchema, items: { not: {} } };
272270
};
273271

274272
const makeSample = (depicted: SchemaObject) => {
@@ -292,44 +290,43 @@ const makeNullableType = (
292290
);
293291
};
294292

295-
export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => {
293+
export const onPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => {
296294
const target = (zodSchema as $ZodPipe)._zod.def[
297295
ctx.isResponse ? "out" : "in"
298296
];
299297
const opposite = (zodSchema as $ZodPipe)._zod.def[
300298
ctx.isResponse ? "in" : "out"
301299
];
302-
if (!isSchema<$ZodTransform>(target, "transform")) return;
300+
if (!isSchema<$ZodTransform>(target, "transform")) return jsonSchema;
303301
const opposingDepiction = depict(opposite, { ctx });
304302
if (isSchemaObject(opposingDepiction)) {
305303
if (!ctx.isResponse) {
306304
const { type: opposingType, ...rest } = opposingDepiction;
307-
Object.assign(jsonSchema, {
305+
return {
308306
...rest,
309307
format: `${rest.format || opposingType} (preprocessed)`,
310-
});
308+
};
311309
} else {
312310
const targetType = getTransformedType(
313311
target,
314312
makeSample(opposingDepiction),
315313
);
316314
if (targetType && ["number", "string", "boolean"].includes(targetType)) {
317-
Object.assign(jsonSchema, {
315+
return {
318316
type: targetType as "number" | "string" | "boolean",
319-
});
317+
};
320318
}
321319
}
322320
}
321+
return jsonSchema;
323322
};
324323

325-
export const onRaw: Overrider = ({ jsonSchema }) => {
326-
if (jsonSchema.type !== "object") return;
324+
export const onRaw: Depicter = ({ jsonSchema }) => {
325+
if (jsonSchema.type !== "object") return jsonSchema;
327326
const objSchema = jsonSchema as JSONSchema.ObjectSchema;
328-
if (!objSchema.properties) return;
329-
if (!("raw" in objSchema.properties)) return;
330-
Object.assign(jsonSchema, objSchema.properties.raw);
331-
delete jsonSchema.properties; // undo default
332-
delete jsonSchema.required;
327+
if (!objSchema.properties) return jsonSchema;
328+
if (!("raw" in objSchema.properties)) return jsonSchema;
329+
return objSchema.properties.raw;
333330
};
334331

335332
const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>
@@ -425,7 +422,7 @@ export const depictRequestParams = ({
425422
: undefined;
426423
if (!location) return acc;
427424
const depicted = depict(paramSchema, {
428-
rules: { ...brandHandling, ...overrides },
425+
rules: { ...brandHandling, ...depicters },
429426
ctx: { isResponse: false, makeRef, path, method },
430427
});
431428
const result =
@@ -446,7 +443,7 @@ export const depictRequestParams = ({
446443
);
447444
};
448445

449-
const overrides: Partial<Record<FirstPartyKind | ProprietaryBrand, Overrider>> =
446+
const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =
450447
{
451448
nullable: onNullable,
452449
default: onDefault,
@@ -462,15 +459,17 @@ const overrides: Partial<Record<FirstPartyKind | ProprietaryBrand, Overrider>> =
462459
[ezRawBrand]: onRaw,
463460
};
464461

465-
const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => {
462+
const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
463+
const result = { ...jsonSchema };
466464
if (!isResponse && doesAccept(zodSchema, null))
467-
Object.assign(jsonSchema, { type: makeNullableType(jsonSchema.type) });
465+
Object.assign(result, { type: makeNullableType(jsonSchema.type) });
468466
const examples = getExamples({
469467
schema: zodSchema,
470468
variant: isResponse ? "parsed" : "original",
471469
validate: true,
472470
});
473-
if (examples.length) jsonSchema.examples = examples.slice();
471+
if (examples.length) result.examples = examples.slice();
472+
return result;
474473
};
475474

476475
/**
@@ -514,7 +513,7 @@ const unref = (
514513

515514
const depict = (
516515
subject: $ZodType,
517-
{ ctx, rules = overrides }: { ctx: OpenAPIContext; rules?: BrandHandling },
516+
{ ctx, rules = depicters }: { ctx: OpenAPIContext; rules?: BrandHandling },
518517
) => {
519518
const { $defs = {}, properties = {} } = z.toJSONSchema(
520519
z.object({ subject }), // avoiding "document root" references
@@ -525,10 +524,16 @@ const depict = (
525524
unref(zodCtx.jsonSchema);
526525
const { brand } =
527526
globalRegistry.get(zodCtx.zodSchema)?.[metaSymbol] ?? {};
528-
rules[
529-
brand && brand in rules ? brand : zodCtx.zodSchema._zod.def.type
530-
]?.(zodCtx, ctx);
531-
onEach(zodCtx, ctx);
527+
const depicter =
528+
rules[
529+
brand && brand in rules ? brand : zodCtx.zodSchema._zod.def.type
530+
];
531+
if (depicter) {
532+
const overrides = { ...depicter(zodCtx, ctx) };
533+
for (const key in zodCtx.jsonSchema) delete zodCtx.jsonSchema[key];
534+
Object.assign(zodCtx.jsonSchema, overrides);
535+
}
536+
Object.assign(zodCtx.jsonSchema, onEach(zodCtx, ctx));
532537
},
533538
},
534539
) as JSONSchema.ObjectSchema;
@@ -587,7 +592,7 @@ export const depictResponse = ({
587592
if (!mimeTypes) return { description };
588593
const depictedSchema = excludeExamplesFromDepiction(
589594
depict(schema, {
590-
rules: { ...brandHandling, ...overrides },
595+
rules: { ...brandHandling, ...depicters },
591596
ctx: { isResponse: true, makeRef, path, method },
592597
}),
593598
);
@@ -709,7 +714,7 @@ export const depictBody = ({
709714
}) => {
710715
const [withoutParams, hasRequired] = excludeParamsFromDepiction(
711716
depict(schema, {
712-
rules: { ...brandHandling, ...overrides },
717+
rules: { ...brandHandling, ...depicters },
713718
ctx: { isResponse: false, makeRef, path, method },
714719
}),
715720
paramNames,

express-zod-api/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export { EventStreamFactory } from "./sse";
3333
export { ez } from "./proprietary-schemas";
3434

3535
// Convenience types
36-
export type { Overrider } from "./documentation-helpers";
36+
export type { Depicter } from "./documentation-helpers";
3737
export type { Producer } from "./zts-helpers";
3838

3939
// Interfaces exposed for augmentation

0 commit comments

Comments
 (0)