Skip to content

Commit 0030d2b

Browse files
authored
Ref: Move mixing and pulling request examples to postprocessing (#2651)
The need of it discovered during #2649 Examples set before transformation are not getting to body depiction. see #2649 (comment) this is because mixing and pulling before in preprocess, while to should be devoted to Zod to depict them first, and then do mixing/pulling in a postprocess, e.g. `flattenIO`
1 parent 7db698e commit 0030d2b

14 files changed

Lines changed: 138 additions & 188 deletions

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

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { $ZodObject, $ZodTransform, $ZodType } from "zod/v4/core";
21
import { Request } from "express";
32
import * as R from "ramda";
4-
import { globalRegistry, z } from "zod/v4";
3+
import { z } from "zod/v4";
4+
import type { $ZodTransform, $ZodType } from "zod/v4/core";
55
import { CommonConfig, InputSource, InputSources } from "./config-type";
66
import { contentTypes } from "./content-type";
77
import { OutputValidationError } from "./errors";
@@ -90,19 +90,6 @@ export const isSchema = <T extends $ZodType>(
9090
type: T["_zod"]["def"]["type"],
9191
): subject is T => subject._zod.def.type === type;
9292

93-
/** Takes the original unvalidated examples from the properties of ZodObject schema shape */
94-
export const pullExampleProps = <T extends $ZodObject>(subject: T) =>
95-
Object.entries(subject._zod.def.shape).reduce<Partial<z.output<T>>[]>(
96-
(acc, [key, schema]) => {
97-
const { examples = [] } = globalRegistry.get(schema) || {};
98-
return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({
99-
...left,
100-
...right,
101-
}));
102-
},
103-
[],
104-
);
105-
10693
export const combinations = <T>(
10794
a: T[],
10895
b: T[],

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as R from "ramda";
2525
import { globalRegistry, z } from "zod/v4";
2626
import { ResponseVariant } from "./api-response";
2727
import {
28+
FlatObject,
2829
getRoutePathParams,
2930
getTransformedType,
3031
isObject,
@@ -649,7 +650,15 @@ export const depictBody = ({
649650
composition === "components"
650651
? makeRef(schema, withoutParams, makeCleanId(description))
651652
: withoutParams,
652-
examples: enumerateExamples(examples),
653+
examples: enumerateExamples(
654+
examples.length
655+
? examples
656+
: flattenIO(request)
657+
.examples?.filter(
658+
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
659+
)
660+
.map(R.omit(paramNames)) || [],
661+
),
653662
};
654663
const body: RequestBodyObject = {
655664
description,

express-zod-api/src/io-schema.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import * as R from "ramda";
22
import { z } from "zod/v4";
3-
import { mixExamples } from "./metadata";
43
import { AbstractMiddleware } from "./middleware";
54

65
type Base = object & { [Symbol.iterator]?: never };
@@ -13,20 +12,15 @@ export type IOSchema = z.ZodType<Base>;
1312
* @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas()
1413
* @since 05.03.2023 is immutable to metadata
1514
* @since 26.05.2024 uses the regular ZodIntersection
16-
* @see mixExamples
15+
* @since 22.05.2025 does not mix examples in after switching to Zod 4
1716
*/
1817
export const getFinalEndpointInputSchema = <
1918
MIN extends IOSchema,
2019
IN extends IOSchema,
2120
>(
2221
middlewares: AbstractMiddleware[],
2322
input: IN,
24-
): z.ZodIntersection<MIN, IN> => {
25-
const allSchemas: IOSchema[] = R.pluck("schema", middlewares);
26-
allSchemas.push(input);
27-
const finalSchema = allSchemas.reduce((acc, schema) => acc.and(schema));
28-
return allSchemas.reduce(
29-
(acc, schema) => mixExamples(schema, acc),
30-
finalSchema,
31-
) as z.ZodIntersection<MIN, IN>;
32-
};
23+
) =>
24+
R.pluck("schema", middlewares)
25+
.concat(input)
26+
.reduce((acc, schema) => acc.and(schema)) as z.ZodIntersection<MIN, IN>;

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { JSONSchema } from "zod/v4/core";
22
import * as R from "ramda";
3-
import { combinations, isObject } from "./common-helpers";
3+
import { combinations, FlatObject, isObject } from "./common-helpers";
44

55
const isJsonObjectSchema = (
66
subject: JSONSchema.BaseSchema,
@@ -63,6 +63,7 @@ export const flattenIO = (
6363
}
6464
}
6565
if (!isJsonObjectSchema(entry)) continue;
66+
stack.push([isOptional, { examples: pullRequestExamples(entry) }]);
6667
if (entry.properties) {
6768
flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)(
6869
flat.properties,
@@ -87,3 +88,14 @@ export const flattenIO = (
8788
if (flatRequired.length) flat.required = [...new Set(flatRequired)];
8889
return flat;
8990
};
91+
92+
/** @see pullResponseExamples */
93+
export const pullRequestExamples = (subject: JSONSchema.ObjectSchema) =>
94+
Object.entries(subject.properties || {}).reduce<FlatObject[]>(
95+
(acc, [key, { examples = [] }]) =>
96+
combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({
97+
...left,
98+
...right,
99+
})),
100+
[],
101+
);

express-zod-api/src/metadata.ts

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,9 @@
1-
import type { $ZodType, $ZodObject } from "zod/v4/core";
2-
import { combinations, isSchema, pullExampleProps } from "./common-helpers";
3-
import { z } from "zod/v4";
4-
import * as R from "ramda";
1+
import type { $ZodType } from "zod/v4/core";
52

63
export const metaSymbol = Symbol.for("express-zod-api");
74

8-
export const mixExamples = <A extends z.ZodType, B extends z.ZodType>(
9-
src: A,
10-
dest: B,
11-
): B => {
12-
const {
13-
examples: srcExamples = isSchema<$ZodObject>(src, "object")
14-
? pullExampleProps(src)
15-
: undefined,
16-
} = src.meta() || {};
17-
if (!srcExamples?.length) return dest;
18-
const { examples: destExamples = [] } = dest.meta() || {};
19-
const examples = combinations<z.output<A> & z.output<B>>(
20-
destExamples,
21-
srcExamples,
22-
([destExample, srcExample]) =>
23-
typeof destExample === "object" &&
24-
typeof srcExample === "object" &&
25-
destExample &&
26-
srcExample
27-
? R.mergeDeepRight(destExample, srcExample)
28-
: srcExample, // not supposed to be called on non-object schemas
29-
);
30-
return dest.meta({ examples });
31-
};
32-
335
export const getBrand = (subject: $ZodType) => {
34-
const { brand } = subject._zod.bag;
6+
const { brand } = subject._zod.bag || {};
357
if (
368
typeof brand === "symbol" ||
379
typeof brand === "string" ||

express-zod-api/src/result-handler.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@ import {
66
defaultStatusCodes,
77
NormalizedResponse,
88
} from "./api-response";
9-
import {
10-
FlatObject,
11-
isObject,
12-
isSchema,
13-
pullExampleProps,
14-
} from "./common-helpers";
9+
import { FlatObject, isObject, isSchema } from "./common-helpers";
1510
import { contentTypes } from "./content-type";
1611
import { IOSchema } from "./io-schema";
1712
import { ActualLogger } from "./logger-helpers";
@@ -21,6 +16,7 @@ import {
2116
getPublicErrorMessage,
2217
logServerError,
2318
normalize,
19+
pullResponseExamples,
2420
ResultSchema,
2521
} from "./result-helpers";
2622

@@ -104,7 +100,7 @@ export const defaultResultHandler = new ResultHandler({
104100
positive: (output) => {
105101
const { examples = [] } = globalRegistry.get(output) || {};
106102
if (!examples.length && isSchema<$ZodObject>(output, "object"))
107-
examples.push(...pullExampleProps(output as $ZodObject));
103+
examples.push(...pullResponseExamples(output as $ZodObject));
108104
if (examples.length && !globalRegistry.has(output))
109105
globalRegistry.add(output, { examples });
110106
const responseSchema = z.object({

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { Request } from "express";
22
import createHttpError, { HttpError, isHttpError } from "http-errors";
3-
import { z } from "zod/v4";
3+
import * as R from "ramda";
4+
import { globalRegistry, z } from "zod/v4";
5+
import type { $ZodObject } from "zod/v4/core";
46
import { NormalizedResponse, ResponseVariant } from "./api-response";
57
import {
8+
combinations,
69
FlatObject,
710
getMessageFromError,
811
isProduction,
@@ -84,3 +87,16 @@ export const getPublicErrorMessage = (error: HttpError): string =>
8487
isProduction() && !error.expose
8588
? createHttpError(error.statusCode).message // default message for that code
8689
: error.message;
90+
91+
/** @see pullRequestExamples */
92+
export const pullResponseExamples = <T extends $ZodObject>(subject: T) =>
93+
Object.entries(subject._zod.def.shape).reduce<Partial<z.output<T>>[]>(
94+
(acc, [key, schema]) => {
95+
const { examples = [] } = globalRegistry.get(schema) || {};
96+
return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({
97+
...left,
98+
...right,
99+
}));
100+
},
101+
[],
102+
);

express-zod-api/tests/__snapshots__/documentation.spec.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3966,6 +3966,10 @@ paths:
39663966
type: string
39673967
required:
39683968
- strNum
3969+
examples:
3970+
example1:
3971+
value:
3972+
strNum: "123"
39693973
required: true
39703974
responses:
39713975
"200":

express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,41 @@ exports[`JSON Schema helpers > flattenIO() > should pass the object schema throu
5353
}
5454
`;
5555

56+
exports[`JSON Schema helpers > flattenIO() > should pull examples up from object schema props 1`] = `
57+
{
58+
"examples": [
59+
{
60+
"one": "test",
61+
"two": 123,
62+
},
63+
{
64+
"one": "jest",
65+
"two": 123,
66+
},
67+
],
68+
"properties": {
69+
"one": {
70+
"examples": [
71+
"test",
72+
"jest",
73+
],
74+
"type": "string",
75+
},
76+
"two": {
77+
"examples": [
78+
123,
79+
],
80+
"type": "number",
81+
},
82+
},
83+
"required": [
84+
"one",
85+
"two",
86+
],
87+
"type": "object",
88+
}
89+
`;
90+
5691
exports[`JSON Schema helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = `
5792
{
5893
"examples": [

express-zod-api/tests/common-helpers.spec.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
getMessageFromError,
77
makeCleanId,
88
ensureError,
9-
pullExampleProps,
109
getRoutePathParams,
1110
} from "../src/common-helpers";
1211
import { z } from "zod/v4";
@@ -199,24 +198,6 @@ describe("Common Helpers", () => {
199198
});
200199
});
201200

202-
describe("pullExampleProps()", () => {
203-
test("handles multiple examples per property", () => {
204-
const schema = z.object({
205-
a: z.string().example("one").example("two").example("three"),
206-
b: z.number().example(1).example(2),
207-
c: z.boolean().example(false),
208-
});
209-
expect(pullExampleProps(schema)).toEqual([
210-
{ a: "one", b: 1, c: false },
211-
{ a: "one", b: 2, c: false },
212-
{ a: "two", b: 1, c: false },
213-
{ a: "two", b: 2, c: false },
214-
{ a: "three", b: 1, c: false },
215-
{ a: "three", b: 2, c: false },
216-
]);
217-
});
218-
});
219-
220201
describe("combinations()", () => {
221202
test("should run callback on each combination of items from two arrays", () => {
222203
expect(combinations([1, 2], [4, 5, 6], ([a, b]) => a + b)).toEqual([

0 commit comments

Comments
 (0)