Skip to content

Commit e0e3b5a

Browse files
authored
Using native storage for examples (#2632)
New approach to #2562. I'm considering to remove the feature of transforming examples and devote to users its proper placement. The native metadata examples are typed as `z.output<>` of the schema, which is the opposite of what it used to be established by the framework.
1 parent be7dc37 commit e0e3b5a

23 files changed

Lines changed: 225 additions & 314 deletions

CHANGELOG.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
- the temporary nature of this transition;
1414
- the advantages of Zod 4 that provide opportunities to simplifications and corrections of known issues.
1515
- `IOSchema` type had to be simplified down to a schema resulting to an `object`, but not an `array`;
16-
- Despite supporting examples by the new Zod method `.meta()`, users should still use `.example()` to set them;
1716
- Refer to [Migration guide on Zod 4](https://v4.zod.dev/v4/changelog) for adjusting your schemas;
18-
- Generating Documentation is partially delegated to Zod 4 `z.toJSONSchema()`:
17+
- Changes to `ZodType::example()` (Zod plugin method):
18+
- Now acts as an alias for `ZodType::meta({ examples })`;
19+
- The argument has to be the output type of the schema (used to be the opposite):
20+
- This change is only breaking for transforming schemas;
21+
- In order to specify an input example for a transforming schema the `.example()` method must be called before it;
22+
- Generating Documentation is mostly delegated to Zod 4 `z.toJSONSchema()`:
1923
- The basic depiction of each schema is now natively performed by Zod 4;
2024
- Express Zod API implements some overrides and improvements to fit it into OpenAPI 3.1 that extends JSON Schema;
2125
- The `numericRange` option removed from `Documentation` class constructor argument;
@@ -26,6 +30,7 @@
2630
- Use `.or(z.undefined())` to add `undefined` to the type of the object property;
2731
- Reasoning: https://x.com/colinhacks/status/1919292504861491252;
2832
- `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality.
33+
- The `getExamples()` public helper removed — use `.meta()?.examples` instead;
2934
- Consider the automated migration using the built-in ESLint rule.
3035

3136
```js
@@ -44,6 +49,14 @@ export default [
4449
+ import { z } from "zod/v4";
4550
```
4651

52+
```diff
53+
z.string()
54+
+ .example("123")
55+
.transform(Number)
56+
- .example("123")
57+
+ .example(123)
58+
```
59+
4760
## Version 23
4861

4962
### v23.5.0

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1271,7 +1271,11 @@ const exampleEndpoint = defaultEndpointsFactory.build({
12711271
shortDescription: "Retrieves the user.", // <—— this becomes the summary line
12721272
description: "The detailed explanaition on what this endpoint does.",
12731273
input: z.object({
1274-
id: z.number().describe("the ID of the user").example(123),
1274+
id: z
1275+
.string()
1276+
.example("123") // input examples should be set before transformations
1277+
.transform(Number)
1278+
.describe("the ID of the user"),
12751279
}),
12761280
// ..., similarly for output and middlewares
12771281
});

example/endpoints/update-user.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import createHttpError from "http-errors";
22
import assert from "node:assert/strict";
3-
import { z } from "zod/v4";
3+
import { $brand, z } from "zod/v4";
44
import { ez } from "express-zod-api";
55
import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories";
66

@@ -12,15 +12,17 @@ export const updateUserEndpoint =
1212
// id is the route path param of /v1/user/:id
1313
id: z
1414
.string()
15+
.example("12") // before transformation
1516
.transform((value) => parseInt(value, 10))
16-
.refine((value) => value >= 0, "should be greater than or equal to 0")
17-
.example("12"),
17+
.refine((value) => value >= 0, "should be greater than or equal to 0"),
1818
name: z.string().nonempty().example("John Doe"),
19-
birthday: ez.dateIn().example("1963-04-21"),
19+
birthday: ez.dateIn().example(new Date("1963-04-21") as Date & $brand),
2020
}),
2121
output: z.object({
2222
name: z.string().example("John Doe"),
23-
createdAt: ez.dateOut().example(new Date("2021-12-31")),
23+
createdAt: ez
24+
.dateOut()
25+
.example("2021-12-31T00:00:00.000Z" as string & $brand),
2426
}),
2527
handler: async ({
2628
input: { id, name },

example/example.documentation.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,9 @@ paths:
111111
description: PATCH /v1/user/:id Parameter
112112
schema:
113113
type: string
114-
minLength: 1
115114
examples:
116115
- "1234567890"
116+
minLength: 1
117117
examples:
118118
example1:
119119
value: "1234567890"
@@ -136,15 +136,15 @@ paths:
136136
type: object
137137
properties:
138138
key:
139-
type: string
140-
minLength: 1
141139
examples:
142140
- 1234-5678-90
143-
name:
144141
type: string
145142
minLength: 1
143+
name:
146144
examples:
147145
- John Doe
146+
type: string
147+
minLength: 1
148148
birthday:
149149
description: YYYY-MM-DDTHH:mm:ss.sssZ
150150
type: string
@@ -153,7 +153,7 @@ paths:
153153
externalDocs:
154154
url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
155155
examples:
156-
- 1963-04-21
156+
- 1963-04-21T00:00:00.000Z
157157
required:
158158
- key
159159
- name
@@ -163,7 +163,7 @@ paths:
163163
value:
164164
key: 1234-5678-90
165165
name: John Doe
166-
birthday: 1963-04-21
166+
birthday: 1963-04-21T00:00:00.000Z
167167
required: true
168168
security:
169169
- APIKEY_1: []
@@ -183,9 +183,9 @@ paths:
183183
type: object
184184
properties:
185185
name:
186-
type: string
187186
examples:
188187
- John Doe
188+
type: string
189189
createdAt:
190190
description: YYYY-MM-DDTHH:mm:ss.sssZ
191191
type: string

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

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { globalRegistry, z } from "zod/v4";
55
import { CommonConfig, InputSource, InputSources } from "./config-type";
66
import { contentTypes } from "./content-type";
77
import { OutputValidationError } from "./errors";
8-
import { metaSymbol } from "./metadata";
98
import { AuxMethod, Method } from "./method";
109

1110
/** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */
@@ -93,9 +92,9 @@ export const isSchema = <T extends $ZodType>(
9392

9493
/** Takes the original unvalidated examples from the properties of ZodObject schema shape */
9594
export const pullExampleProps = <T extends $ZodObject>(subject: T) =>
96-
Object.entries(subject._zod.def.shape).reduce<Partial<z.input<T>>[]>(
95+
Object.entries(subject._zod.def.shape).reduce<Partial<z.output<T>>[]>(
9796
(acc, [key, schema]) => {
98-
const { examples = [] } = globalRegistry.get(schema)?.[metaSymbol] || {};
97+
const { examples = [] } = globalRegistry.get(schema) || {};
9998
return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({
10099
...left,
101100
...right,
@@ -104,47 +103,6 @@ export const pullExampleProps = <T extends $ZodObject>(subject: T) =>
104103
[],
105104
);
106105

107-
export const getExamples = <
108-
T extends $ZodType,
109-
V extends "original" | "parsed" | undefined,
110-
>({
111-
schema,
112-
variant = "original",
113-
validate = variant === "parsed",
114-
pullProps = false,
115-
}: {
116-
schema: T;
117-
/**
118-
* @desc examples variant: original or parsed
119-
* @example "parsed" — for the case when possible schema transformations should be applied
120-
* @default "original"
121-
* @override validate: variant "parsed" activates validation as well
122-
* */
123-
variant?: V;
124-
/**
125-
* @desc filters out the examples that do not match the schema
126-
* @default variant === "parsed"
127-
* */
128-
validate?: boolean;
129-
/**
130-
* @desc should pull examples from properties — applicable to ZodObject only
131-
* @default false
132-
* */
133-
pullProps?: boolean;
134-
}): ReadonlyArray<V extends "parsed" ? z.output<T> : z.input<T>> => {
135-
let examples = globalRegistry.get(schema)?.[metaSymbol]?.examples || [];
136-
if (!examples.length && pullProps && isSchema<$ZodObject>(schema, "object"))
137-
examples = pullExampleProps(schema);
138-
if (!validate && variant === "original") return examples;
139-
const result: Array<z.input<T> | z.output<T>> = [];
140-
for (const example of examples) {
141-
const parsedExample = z.safeParse(schema, example);
142-
if (parsedExample.success)
143-
result.push(variant === "parsed" ? parsedExample.data : example);
144-
}
145-
return result;
146-
};
147-
148106
export const combinations = <T>(
149107
a: T[],
150108
b: T[],

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

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,10 @@ import {
2323
TagObject,
2424
} from "openapi3-ts/oas31";
2525
import * as R from "ramda";
26-
import { z } from "zod/v4";
26+
import { globalRegistry, z } from "zod/v4";
2727
import { ResponseVariant } from "./api-response";
2828
import {
2929
FlatObject,
30-
getExamples,
3130
getRoutePathParams,
3231
getTransformedType,
3332
isObject,
@@ -154,6 +153,7 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({
154153
...jsonSchema,
155154
});
156155

156+
/** @todo might no longer be required */
157157
export const depictObject: Depicter = (
158158
{ zodSchema, jsonSchema },
159159
{ isResponse },
@@ -195,31 +195,35 @@ const ensureCompliance = ({
195195
return valid;
196196
};
197197

198-
export const depictDateIn: Depicter = ({}, ctx) => {
198+
export const depictDateIn: Depicter = ({ zodSchema }, ctx) => {
199199
if (ctx.isResponse)
200200
throw new DocumentationError("Please use ez.dateOut() for output.", ctx);
201-
return {
201+
const jsonSchema: JSONSchema.StringSchema = {
202202
description: "YYYY-MM-DDTHH:mm:ss.sssZ",
203203
type: "string",
204204
format: "date-time",
205205
pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source,
206-
externalDocs: {
207-
url: isoDateDocumentationUrl,
208-
},
206+
externalDocs: { url: isoDateDocumentationUrl },
209207
};
208+
const examples = globalRegistry
209+
.get(zodSchema) // zod::toJSONSchema() does not provide examples for the input size of a pipe
210+
?.examples?.filter((one) => one instanceof Date)
211+
.map((one) => one.toISOString());
212+
if (examples?.length) jsonSchema.examples = examples;
213+
return jsonSchema;
210214
};
211215

212-
export const depictDateOut: Depicter = ({}, ctx) => {
216+
export const depictDateOut: Depicter = ({ jsonSchema: { examples } }, ctx) => {
213217
if (!ctx.isResponse)
214218
throw new DocumentationError("Please use ez.dateIn() for input.", ctx);
215-
return {
219+
const jsonSchema: JSONSchema.StringSchema = {
216220
description: "YYYY-MM-DDTHH:mm:ss.sssZ",
217221
type: "string",
218222
format: "date-time",
219-
externalDocs: {
220-
url: isoDateDocumentationUrl,
221-
},
223+
externalDocs: { url: isoDateDocumentationUrl },
222224
};
225+
if (examples?.length) jsonSchema.examples = examples;
226+
return jsonSchema;
223227
};
224228

225229
export const depictBigInt: Depicter = () => ({
@@ -282,6 +286,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => {
282286
);
283287
if (targetType && ["number", "string", "boolean"].includes(targetType)) {
284288
return {
289+
...jsonSchema,
285290
type: targetType as "number" | "string" | "boolean",
286291
};
287292
}
@@ -373,7 +378,7 @@ export const depictRequestParams = ({
373378
schema: result,
374379
examples: enumerateExamples(
375380
isSchemaObject(depicted) && depicted.examples?.length
376-
? depicted.examples // own examples or from the flat:
381+
? depicted.examples // own examples or from the flat: // @todo check if both still needed
377382
: R.pluck(
378383
name,
379384
flat.examples?.filter(R.both(isObject, R.has(name))) || [],
@@ -403,18 +408,6 @@ const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =
403408
[ezRawBrand]: depictRaw,
404409
};
405410

406-
const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
407-
const result = { ...jsonSchema };
408-
const examples = getExamples({
409-
schema: zodSchema,
410-
variant: isResponse ? "parsed" : "original",
411-
validate: true,
412-
pullProps: true,
413-
});
414-
if (examples.length) result.examples = examples.slice();
415-
return result;
416-
};
417-
418411
/**
419412
* postprocessing refs: specifying "uri" function and custom registries didn't allow to customize ref name
420413
* @todo is there a less hacky way to do that?
@@ -462,7 +455,6 @@ const depict = (
462455
for (const key in zodCtx.jsonSchema) delete zodCtx.jsonSchema[key];
463456
Object.assign(zodCtx.jsonSchema, overrides);
464457
}
465-
Object.assign(zodCtx.jsonSchema, onEach(zodCtx, ctx));
466458
},
467459
},
468460
) as JSONSchema.ObjectSchema;
@@ -681,7 +673,7 @@ export const depictBody = ({
681673
examples: enumerateExamples(
682674
examples.length
683675
? examples
684-
: flattenIO(request)
676+
: flattenIO(request) // @todo this branch might no longer be required
685677
.examples?.filter(
686678
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
687679
)

express-zod-api/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export {
66
defaultEndpointsFactory,
77
arrayEndpointsFactory,
88
} from "./endpoints-factory";
9-
export { getExamples, getMessageFromError } from "./common-helpers";
9+
export { getMessageFromError } from "./common-helpers";
1010
export { ensureHttpError } from "./result-helpers";
1111
export { BuiltinLogger } from "./builtin-logger";
1212
export { Middleware } from "./middleware";

express-zod-api/src/json-schema-helpers.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,6 @@ export const flattenIO = (
5151
}
5252
if (entry.anyOf) stack.push(...R.map(nestOptional, entry.anyOf));
5353
if (entry.oneOf) stack.push(...R.map(nestOptional, entry.oneOf));
54-
if (!isJsonObjectSchema(entry)) continue;
55-
if (entry.properties) {
56-
flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)(
57-
flat.properties,
58-
entry.properties,
59-
);
60-
if (!isOptional && entry.required) flatRequired.push(...entry.required);
61-
}
6254
if (entry.examples?.length) {
6355
if (isOptional) {
6456
flat.examples = R.concat(flat.examples || [], entry.examples);
@@ -70,6 +62,14 @@ export const flattenIO = (
7062
);
7163
}
7264
}
65+
if (!isJsonObjectSchema(entry)) continue;
66+
if (entry.properties) {
67+
flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)(
68+
flat.properties,
69+
entry.properties,
70+
);
71+
if (!isOptional && entry.required) flatRequired.push(...entry.required);
72+
}
7373
if (entry.propertyNames) {
7474
const keys: string[] = [];
7575
if (typeof entry.propertyNames.const === "string")

0 commit comments

Comments
 (0)