From 6abe87be9968559672954b1b0d3af1502cd69f7d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 18 May 2025 08:53:17 +0200 Subject: [PATCH 01/23] Draft: rm Metadata::examples, changing ZodType::example to accept output schema. --- express-zod-api/src/metadata.ts | 14 +++++--------- express-zod-api/src/zod-plugin.ts | 20 +++++++------------- express-zod-api/tests/metadata.spec.ts | 21 ++++++++------------- express-zod-api/tests/zod-plugin.spec.ts | 16 +++++----------- 4 files changed, 25 insertions(+), 46 deletions(-) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 4df62780f..72c74798d 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -5,7 +5,6 @@ import * as R from "ramda"; export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { - examples: unknown[]; /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; @@ -17,10 +16,10 @@ export const mixExamples = ( ): B => { const srcMeta = src.meta(); const destMeta = dest.meta(); - if (!srcMeta?.[metaSymbol]) return dest; // ensures srcMeta[metaSymbol] - const examples = combinations( - destMeta?.[metaSymbol]?.examples || [], - srcMeta[metaSymbol].examples || [], + if (!srcMeta?.examples) return dest; // ensures srcMeta[metaSymbol] + const examples = combinations & z.output>( + destMeta?.examples || [], + srcMeta.examples || [], ([destExample, srcExample]) => typeof destExample === "object" && typeof srcExample === "object" && @@ -29,8 +28,5 @@ export const mixExamples = ( ? R.mergeDeepRight(destExample, srcExample) : srcExample, // not supposed to be called on non-object schemas ); - return dest.meta({ - ...destMeta, - [metaSymbol]: { ...destMeta?.[metaSymbol], examples }, - }); + return dest.meta({ ...destMeta, examples }); }; diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 43f6ca956..2b74b3c97 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -29,8 +29,8 @@ declare module "zod/v4/core" { declare module "zod/v4" { interface ZodType { - /** @desc Add an example value (before any transformations, can be called multiple times) */ - example(example: z.input): this; + /** @desc Shorthand for .meta({examples}), it can be called multiple times */ + example(example: z.output): this; deprecated(): this; } interface ZodDefault extends ZodType { @@ -60,14 +60,11 @@ declare module "zod/v4" { } } -const exampleSetter = function (this: z.ZodType, value: z.input) { - const { [metaSymbol]: internal, ...rest } = this.meta() || {}; - const copy = internal?.examples.slice() || []; +const exampleSetter = function (this: z.ZodType, value: z.output) { + const { examples = [], ...rest } = this.meta() || {}; + const copy = examples.slice(); copy.push(value); - return this.meta({ - ...rest, - [metaSymbol]: { ...internal, examples: copy }, - }); + return this.meta({ ...rest, examples: copy }); }; const deprecationSetter = function (this: z.ZodType) { @@ -156,10 +153,7 @@ if (!(metaSymbol in globalThis)) { ) { /** @link https://v4.zod.dev/metadata#register */ return originalCheck.apply(this, args).register(globalRegistry, { - [metaSymbol]: { - examples: [], - brand: this.meta()?.[metaSymbol]?.brand, - }, + [metaSymbol]: { brand: this.meta()?.[metaSymbol]?.brand }, }); }; }, diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts index f78820dd0..c3a74433b 100644 --- a/express-zod-api/tests/metadata.spec.ts +++ b/express-zod-api/tests/metadata.spec.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { mixExamples, metaSymbol } from "../src/metadata"; +import { mixExamples } from "../src/metadata"; describe("Metadata", () => { describe("mixExamples()", () => { @@ -8,19 +8,16 @@ describe("Metadata", () => { const dest = z.number(); const result = mixExamples(src, dest); expect(result).toEqual(dest); - expect(result.meta()?.[metaSymbol]).toBeFalsy(); - expect(dest.meta()?.[metaSymbol]).toBeFalsy(); + expect(result.meta()?.examples).toBeFalsy(); + expect(dest.meta()?.examples).toBeFalsy(); }); test("should copy meta from src to dest in case meta is defined", () => { const src = z.string().example("some").describe("test"); const dest = z.number().describe("another"); const result = mixExamples(src, dest); expect(result).not.toEqual(dest); // immutable - expect(result.meta()?.[metaSymbol]).toBeTruthy(); - expect(result.meta()?.[metaSymbol]?.examples).toEqual( - src.meta()?.[metaSymbol]?.examples, - ); - expect(result.meta()?.[metaSymbol]?.examples).toEqual(["some"]); + expect(result.meta()?.examples).toEqual(src.meta()?.examples); + expect(result.meta()?.examples).toEqual(["some"]); expect(result.description).toBe("another"); // preserves it }); @@ -35,8 +32,7 @@ describe("Metadata", () => { .example({ b: 456 }) .example({ b: 789 }); const result = mixExamples(src, dest); - expect(result.meta()?.[metaSymbol]).toBeTruthy(); - expect(result.meta()?.[metaSymbol]?.examples).toEqual([ + expect(result.meta()?.examples).toEqual([ { a: "some", b: 123 }, { a: "another", b: 123 }, { a: "some", b: 456 }, @@ -57,8 +53,7 @@ describe("Metadata", () => { .example({ a: { c: 456 } }) .example({ a: { c: 789 } }); const result = mixExamples(src, dest); - expect(result.meta()?.[metaSymbol]).toBeTruthy(); - expect(result.meta()?.[metaSymbol]?.examples).toEqual([ + expect(result.meta()?.examples).toEqual([ { a: { b: "some", c: 123 } }, { a: { b: "another", c: 123 } }, { a: { b: "some", c: 456 } }, @@ -74,7 +69,7 @@ describe("Metadata", () => { .object({ items: z.array(z.string()) }) .example({ items: ["e", "f", "g"] }); const result = mixExamples(src, dest); - expect(result.meta()?.[metaSymbol]?.examples).toEqual(["a", "b"]); + expect(result.meta()?.examples).toEqual(["a", "b"]); }); }); }); diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index fff0e3f07..3ebc5e7ce 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -13,23 +13,17 @@ describe("Zod Runtime Plugin", () => { test("should set the corresponding metadata in the schema definition", () => { const schema = z.string(); const schemaWithMeta = schema.example("test"); - expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty("examples", [ - "test", - ]); + expect(schemaWithMeta.meta()?.examples).toEqual(["test"]); }); test("Issue 827: should be immutable", () => { const schema = z.string(); const schemaWithExample = schema.example("test"); - expect(schemaWithExample.meta()?.[metaSymbol]?.examples).toEqual([ - "test", - ]); + expect(schemaWithExample.meta()?.examples).toEqual(["test"]); expect(schema.meta()?.[metaSymbol]).toBeUndefined(); const second = schemaWithExample.example("test2"); - expect(second.meta()?.[metaSymbol]?.examples).toEqual(["test", "test2"]); - expect(schemaWithExample.meta()?.[metaSymbol]?.examples).toEqual([ - "test", - ]); + expect(second.meta()?.examples).toEqual(["test", "test2"]); + expect(schemaWithExample.meta()?.examples).toEqual(["test"]); }); test("can be used multiple times", () => { @@ -38,7 +32,7 @@ describe("Zod Runtime Plugin", () => { .example("test1") .example("test2") .example("test3"); - expect(schemaWithMeta.meta()?.[metaSymbol]?.examples).toEqual([ + expect(schemaWithMeta.meta()?.examples).toEqual([ "test1", "test2", "test3", From 88fb367a67258b034e7517235b9cf343e0189fb8 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 18 May 2025 09:58:45 +0200 Subject: [PATCH 02/23] Removing getExample() in order to replace it with another approach. --- express-zod-api/src/common-helpers.ts | 44 +------- express-zod-api/src/documentation-helpers.ts | 7 +- express-zod-api/src/index.ts | 2 +- express-zod-api/src/result-handler.ts | 10 +- express-zod-api/tests/common-helpers.spec.ts | 109 ------------------- express-zod-api/tests/documentation.spec.ts | 29 ++--- express-zod-api/tests/index.spec.ts | 4 +- express-zod-api/tests/io-schema.spec.ts | 5 +- 8 files changed, 25 insertions(+), 185 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index d70c00569..79187545f 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -10,7 +10,6 @@ import { globalRegistry, z } from "zod/v4"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; import { OutputValidationError } from "./errors"; -import { metaSymbol } from "./metadata"; import { AuxMethod, Method } from "./method"; /** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */ @@ -100,7 +99,7 @@ export const isSchema = ( export const pullExampleProps = (subject: T) => Object.entries(subject._zod.def.shape).reduce>[]>( (acc, [key, schema]) => { - const { examples = [] } = globalRegistry.get(schema)?.[metaSymbol] || {}; + const { examples = [] } = globalRegistry.get(schema) || {}; return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ ...left, ...right, @@ -109,47 +108,6 @@ export const pullExampleProps = (subject: T) => [], ); -export const getExamples = < - T extends $ZodType, - V extends "original" | "parsed" | undefined, ->({ - schema, - variant = "original", - validate = variant === "parsed", - pullProps = false, -}: { - schema: T; - /** - * @desc examples variant: original or parsed - * @example "parsed" — for the case when possible schema transformations should be applied - * @default "original" - * @override validate: variant "parsed" activates validation as well - * */ - variant?: V; - /** - * @desc filters out the examples that do not match the schema - * @default variant === "parsed" - * */ - validate?: boolean; - /** - * @desc should pull examples from properties — applicable to ZodObject only - * @default false - * */ - pullProps?: boolean; -}): ReadonlyArray : z.input> => { - let examples = globalRegistry.get(schema)?.[metaSymbol]?.examples || []; - if (!examples.length && pullProps && isSchema<$ZodObject>(schema, "object")) - examples = pullExampleProps(schema); - if (!validate && variant === "original") return examples; - const result: Array | z.output> = []; - for (const example of examples) { - const parsedExample = z.safeParse(schema, example); - if (parsedExample.success) - result.push(variant === "parsed" ? parsedExample.data : example); - } - return result; -}; - export const combinations = ( a: T[], b: T[], diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 1848a5b11..e29a8dd2c 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -27,7 +27,6 @@ import { globalRegistry, z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { FlatObject, - getExamples, getRoutePathParams, getTransformedType, isObject, @@ -414,15 +413,17 @@ const depicters: Partial> = [ezRawBrand]: depictRaw, }; -const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { +// @todo might no longer be needed +const onEach: Depicter = ({ jsonSchema }) => { const result = { ...jsonSchema }; + /* const examples = getExamples({ schema: zodSchema, - variant: isResponse ? "parsed" : "original", validate: true, pullProps: true, }); if (examples.length) result.examples = examples.slice(); + */ return result; }; diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index 76db03494..0a5f1333c 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -6,7 +6,7 @@ export { defaultEndpointsFactory, arrayEndpointsFactory, } from "./endpoints-factory"; -export { getExamples, getMessageFromError } from "./common-helpers"; +export { getMessageFromError } from "./common-helpers"; export { ensureHttpError } from "./result-helpers"; export { BuiltinLogger } from "./builtin-logger"; export { Middleware } from "./middleware"; diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index 930308c3c..6d10cb8f6 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -1,11 +1,11 @@ import { Request, Response } from "express"; -import { z } from "zod/v4"; +import { globalRegistry, z } from "zod/v4"; import { ApiResponse, defaultStatusCodes, NormalizedResponse, } from "./api-response"; -import { FlatObject, getExamples, isObject } from "./common-helpers"; +import { FlatObject, isObject } from "./common-helpers"; import { contentTypes } from "./content-type"; import { IOSchema } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; @@ -96,8 +96,7 @@ export class ResultHandler< export const defaultResultHandler = new ResultHandler({ positive: (output) => { - // Examples are taken for proxying: no validation needed for this - const examples = getExamples({ schema: output, pullProps: true }); + const { examples = [] } = globalRegistry.get(output) || {}; const responseSchema = z.object({ status: z.literal("success"), data: output, @@ -141,8 +140,7 @@ export const defaultResultHandler = new ResultHandler({ * */ export const arrayResultHandler = new ResultHandler({ positive: (output) => { - // Examples are taken for pulling down: no validation needed for this, no pulling up - const examples = getExamples({ schema: output }); + const { examples = [] } = globalRegistry.get(output) || {}; const responseSchema = output instanceof z.ZodObject && "items" in output.shape && diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 48508be52..ba6cdccd4 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -2,7 +2,6 @@ import createHttpError from "http-errors"; import { combinations, defaultInputSources, - getExamples, getInput, getMessageFromError, makeCleanId, @@ -218,114 +217,6 @@ describe("Common Helpers", () => { }); }); - describe("getExamples()", () => { - test("should return an empty array in case examples are not set", () => { - expect(getExamples({ schema: z.string(), variant: "parsed" })).toEqual( - [], - ); - expect(getExamples({ schema: z.string() })).toEqual([]); - expect(getExamples({ schema: z.string(), variant: "parsed" })).toEqual( - [], - ); - expect(getExamples({ schema: z.string() })).toEqual([]); - }); - test("should return original examples by default", () => { - expect( - getExamples({ - schema: z.string().example("some").example("another"), - }), - ).toEqual(["some", "another"]); - }); - test("should return parsed examples on demand", () => { - expect( - getExamples({ - schema: z - .string() - .transform((v) => parseInt(v, 10)) - .example("123") - .example("456"), - variant: "parsed", - }), - ).toEqual([123, 456]); - }); - test("should not filter out invalid examples by default", () => { - expect( - getExamples({ - schema: z - .string() - .example("some") - .example(123 as unknown as string) - .example("another"), - }), - ).toEqual(["some", 123, "another"]); - }); - test("should filter out invalid examples on demand", () => { - expect( - getExamples({ - schema: z - .string() - .example("some") - .example(123 as unknown as string) - .example("another"), - validate: true, - }), - ).toEqual(["some", "another"]); - }); - test("should filter out invalid examples for the parsed variant", () => { - expect( - getExamples({ - schema: z - .string() - .transform((v) => parseInt(v, 10)) - .example("123") - .example(null as unknown as string) - .example("456"), - variant: "parsed", - }), - ).toEqual([123, 456]); - }); - test.each([z.array(z.int()), z.tuple([z.number(), z.number()])])( - "Issue #892: should handle examples of arrays and tuples %#", - (schema) => { - expect( - getExamples({ - schema: schema.example([1, 2]).example([3, 4]), - }), - ).toEqual([ - [1, 2], - [3, 4], - ]); - }, - ); - - describe("Feature #2324: pulling examples up from the object props", () => { - test("opt-in", () => { - expect( - getExamples({ - pullProps: true, - schema: z.object({ - a: z.string().example("one"), - b: z.number().example(1), - }), - }), - ).toEqual([{ a: "one", b: 1 }]); - }); - test("only when the object level is empty", () => { - expect( - getExamples({ - pullProps: true, - schema: z - .object({ - a: z.string().example("one"), - b: z.number().example(1), - }) - .example({ a: "two", b: 2 }), // higher priority - }), - ).toEqual([{ a: "two", b: 2 }]); - }); - }); - }); - describe("combinations()", () => { test("should run callback on each combination of items from two arrays", () => { expect(combinations([1, 2], [4, 5, 6], ([a, b]) => a + b)).toEqual([ diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 2241cb666..ce339dd87 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -14,6 +14,7 @@ import { import { contentTypes } from "../src/content-type"; import { z } from "zod/v4"; import { givePort } from "../../tools/ports"; +import * as R from "ramda"; describe("Documentation", () => { const sampleConfig = createConfig({ @@ -1027,14 +1028,14 @@ describe("Documentation", () => { input: z.object({ strNum: z .string() - .transform((v) => parseInt(v, 10)) - .example("123"), // example is for input side of the transformation + .example("123") // example for the input side of the transformation + .transform((v) => parseInt(v, 10)), }), output: z.object({ numericStr: z .number() .transform((v) => `${v}`) - .example(123), // example is for input side of the transformation + .example("123"), // example for the output side of the transformation }), handler: async () => ({ numericStr: 123 }), }), @@ -1054,18 +1055,15 @@ describe("Documentation", () => { v1: { getSomething: defaultEndpointsFactory.build({ input: z - .object({ - strNum: z.string().transform((v) => parseInt(v, 10)), - }) - .example({ - strNum: "123", // example is for input side of the transformation - }), + .object({ strNum: z.string() }) + .example({ strNum: "123" }) // example is for input side of the transformation + .transform(R.mapObjIndexed(Number)), output: z .object({ numericStr: z.number().transform((v) => `${v}`), }) .example({ - numericStr: 123, // example is for input side of the transformation + numericStr: "123", // example is for input side of the transformation }), handler: async () => ({ numericStr: 123 }), }), @@ -1086,18 +1084,15 @@ describe("Documentation", () => { getSomething: defaultEndpointsFactory.build({ method: "post", input: z - .object({ - strNum: z.string().transform((v) => parseInt(v, 10)), - }) - .example({ - strNum: "123", // example is for input side of the transformation - }), + .object({ strNum: z.string() }) + .example({ strNum: "123" }) // example is for input side of the transformation + .transform(R.mapObjIndexed(Number)), output: z .object({ numericStr: z.number().transform((v) => `${v}`), }) .example({ - numericStr: 123, // example is for input side of the transformation + numericStr: "123", // example is for input side of the transformation }), handler: async () => ({ numericStr: 123 }), }), diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index d835b7d07..925259e88 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -102,9 +102,7 @@ describe("Index Entrypoint", () => { .toEqualTypeOf<(value: any) => z.ZodAny>(); expectTypeOf>() .toHaveProperty("example") - .toEqualTypeOf< - (value: string | undefined) => z.ZodDefault - >(); + .toEqualTypeOf<(value: string) => z.ZodDefault>(); expectTypeOf>() .toHaveProperty("label") .toEqualTypeOf<(value: string) => z.ZodDefault>(); diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index b53ebb601..0e6ebc28b 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -2,7 +2,6 @@ import { expectTypeOf } from "vitest"; import { z } from "zod/v4"; import { IOSchema, Middleware, ez } from "../src"; import { getFinalEndpointInputSchema } from "../src/io-schema"; -import { metaSymbol } from "../src/metadata"; import { AbstractMiddleware } from "../src/middleware"; describe("I/O Schema and related helpers", () => { @@ -244,7 +243,7 @@ describe("I/O Schema and related helpers", () => { expect(result).toMatchSnapshot(); }); - test("Should merge examples in case of using withMeta()", () => { + test("Should merge examples", () => { const middlewares: AbstractMiddleware[] = [ new Middleware({ input: z @@ -265,7 +264,7 @@ describe("I/O Schema and related helpers", () => { .object({ five: z.string() }) .example({ five: "some" }); const result = getFinalEndpointInputSchema(middlewares, endpointInput); - expect(result.meta()?.[metaSymbol]?.examples).toEqual([ + expect(result.meta()?.examples).toEqual([ { one: "test", two: 123, From b9721feffcc530da59db80d1efb162df47c1cbf4 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 19 May 2025 07:57:30 +0200 Subject: [PATCH 03/23] Transforming examples when doing .transform() and pulling props examples when doing mixExamples(). --- express-zod-api/src/metadata.ts | 7 +++--- express-zod-api/src/zod-plugin.ts | 17 ++++++++++++++ .../__snapshots__/documentation.spec.ts.snap | 22 +++++++++---------- .../tests/__snapshots__/endpoint.spec.ts.snap | 8 +++++++ 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 72c74798d..7dceaa43d 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -1,4 +1,5 @@ -import { combinations } from "./common-helpers"; +import type { $ZodObject } from "zod/v4/core"; +import { combinations, isSchema, pullExampleProps } from "./common-helpers"; import { z } from "zod/v4"; import * as R from "ramda"; @@ -16,10 +17,10 @@ export const mixExamples = ( ): B => { const srcMeta = src.meta(); const destMeta = dest.meta(); - if (!srcMeta?.examples) return dest; // ensures srcMeta[metaSymbol] const examples = combinations & z.output>( destMeta?.examples || [], - srcMeta.examples || [], + srcMeta?.examples || + (isSchema<$ZodObject>(src, "object") ? pullExampleProps(src) : []), ([destExample, srcExample]) => typeof destExample === "object" && typeof srcExample === "object" && diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 2b74b3c97..af50ab26f 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -125,6 +125,7 @@ if (!(metaSymbol in globalThis)) { const Cls = z[entry as keyof typeof z]; if (typeof Cls !== "function") continue; let originalCheck: z.ZodType["check"]; + let originalTransform: z.ZodType["transform"]; Object.defineProperties(Cls.prototype, { ["example" satisfies keyof z.ZodType]: { get(): z.ZodType["example"] { @@ -158,6 +159,22 @@ if (!(metaSymbol in globalThis)) { }; }, }, + ["transform" satisfies keyof z.ZodType]: { + set(fn) { + originalTransform = fn; + }, + get(): z.ZodType["transform"] { + return function (this: z.ZodType, ...args) { + const { examples } = globalRegistry.get(this) || {}; + const result = originalTransform.apply(this, args); + return result.register(globalRegistry, { + examples: examples?.map( + (one) => result.parse(one) as ReturnType<(typeof args)[0]>, + ) as z.$output[], + }); + } as z.ZodType["transform"]; + }, + }, }); } diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 0e723377e..24ecc29cf 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3289,6 +3289,11 @@ paths: type: string const: success data: + examples: + - a: first + b: prefix_first + - a: second + b: prefix_second type: object properties: a: @@ -3298,11 +3303,6 @@ paths: required: - a - b - examples: - - a: first - b: prefix_first - - a: second - b: prefix_second required: - status - data @@ -3487,14 +3487,14 @@ paths: type: string const: success data: + examples: + - num: 123 type: object properties: num: type: number required: - num - examples: - - num: 123 required: - status - data @@ -3577,14 +3577,14 @@ paths: type: string const: success data: + examples: + - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr - examples: - - numericStr: "123" required: - status - data @@ -3673,14 +3673,14 @@ paths: type: string const: success data: + examples: + - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr - examples: - - numericStr: "123" required: - status - data diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 3d945f20c..675e6c8e1 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -22,6 +22,14 @@ exports[`Endpoint > .getResponses() > should return the negative responses (read ], "schema": { "$schema": "https://json-schema.org/draft-2020-12/schema", + "examples": [ + { + "error": { + "message": "Sample error message", + }, + "status": "error", + }, + ], "properties": { "error": { "properties": { From d22936f83c8ad88242bf940410756dce0c47b0d0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 10:09:15 +0200 Subject: [PATCH 04/23] rm redundant statement in test. --- express-zod-api/tests/zod-plugin.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index 1d7eef091..b42fc93dd 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -1,6 +1,6 @@ import camelize from "camelize-ts"; import { z } from "zod/v4"; -import { getBrand, metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; describe("Zod Runtime Plugin", () => { describe(".example()", () => { @@ -20,7 +20,6 @@ describe("Zod Runtime Plugin", () => { const schema = z.string(); const schemaWithExample = schema.example("test"); expect(schemaWithExample.meta()?.examples).toEqual(["test"]); - expect(schema.meta()?.[metaSymbol]).toBeUndefined(); const second = schemaWithExample.example("test2"); expect(second.meta()?.examples).toEqual(["test", "test2"]); expect(schemaWithExample.meta()?.examples).toEqual(["test"]); From ca825e44a7581a77e81436134babc76a96a5dfd6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 10:11:59 +0200 Subject: [PATCH 05/23] rm onEach and adjust result handler tests. --- express-zod-api/src/documentation-helpers.ts | 15 --------------- express-zod-api/tests/result-handler.spec.ts | 5 ++--- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ec9fccff3..75546deaa 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -402,20 +402,6 @@ const depicters: Partial> = [ezRawBrand]: depictRaw, }; -// @todo might no longer be needed -const onEach: Depicter = ({ jsonSchema }) => { - const result = { ...jsonSchema }; - /* - const examples = getExamples({ - schema: zodSchema, - validate: true, - pullProps: true, - }); - if (examples.length) result.examples = examples.slice(); - */ - return result; -}; - /** * postprocessing refs: specifying "uri" function and custom registries didn't allow to customize ref name * @todo is there a less hacky way to do that? @@ -463,7 +449,6 @@ const depict = ( for (const key in zodCtx.jsonSchema) delete zodCtx.jsonSchema[key]; Object.assign(zodCtx.jsonSchema, overrides); } - Object.assign(zodCtx.jsonSchema, onEach(zodCtx, ctx)); }, }, ) as JSONSchema.ObjectSchema; diff --git a/express-zod-api/tests/result-handler.spec.ts b/express-zod-api/tests/result-handler.spec.ts index 7a22c8acc..bc1603f5e 100644 --- a/express-zod-api/tests/result-handler.spec.ts +++ b/express-zod-api/tests/result-handler.spec.ts @@ -8,7 +8,6 @@ import { ResultHandler, } from "../src"; import { ResultHandlerError } from "../src/errors"; -import { metaSymbol } from "../src/metadata"; import { AbstractResultHandler, Result } from "../src/result-handler"; import { makeLoggerMock, @@ -197,13 +196,13 @@ describe("ResultHandler", () => { }), ); expect(apiResponse).toHaveLength(1); - expect(apiResponse[0].schema.meta()?.[metaSymbol]).toMatchSnapshot(); + expect(apiResponse[0].schema.meta()).toMatchSnapshot(); }); test("should generate negative response example", () => { const apiResponse = subject.getNegativeResponse(); expect(apiResponse).toHaveLength(1); - expect(apiResponse[0].schema.meta()?.[metaSymbol]).toMatchSnapshot(); + expect(apiResponse[0].schema.meta()).toMatchSnapshot(); }); }); From 6d3b713916715a17c4aa7e0824df29af7dd20c61 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 10:30:55 +0200 Subject: [PATCH 06/23] Restoring noop case for mixExamples. --- express-zod-api/src/metadata.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index fbb1acfd2..a2c0cdc70 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -9,11 +9,12 @@ export const mixExamples = ( src: A, dest: B, ): B => { - const srcMeta = src.meta(); + const srcExamples = src.meta()?.examples; + if (!srcExamples) return dest; const destMeta = dest.meta(); const examples = combinations & z.output>( destMeta?.examples || [], - srcMeta?.examples || + srcExamples || (isSchema<$ZodObject>(src, "object") ? pullExampleProps(src) : []), ([destExample, srcExample]) => typeof destExample === "object" && @@ -23,7 +24,7 @@ export const mixExamples = ( ? R.mergeDeepRight(destExample, srcExample) : srcExample, // not supposed to be called on non-object schemas ); - return dest.meta({ ...destMeta, examples }); + return dest.meta({ ...destMeta, examples }); // @todo might not be required to spread since .meta() does it now }; export const getBrand = (subject: $ZodType) => { From db54c83f4b34167ab3354721f13bb16ce51831fd Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 10:33:03 +0200 Subject: [PATCH 07/23] Changelog: addressing removed getExamples(). --- CHANGELOG.md | 4 ++-- express-zod-api/tests/__snapshots__/index.spec.ts.snap | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd559e20..b5c0e0f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,8 @@ - the temporary nature of this transition; - the advantages of Zod 4 that provide opportunities to simplifications and corrections of known issues. - `IOSchema` type had to be simplified down to a schema resulting to an `object`, but not an `array`; - - Despite supporting examples by the new Zod method `.meta()`, users should still use `.example()` to set them; - Refer to [Migration guide on Zod 4](https://v4.zod.dev/v4/changelog) for adjusting your schemas; -- Generating Documentation is partially delegated to Zod 4 `z.toJSONSchema()`: +- Generating Documentation is mostly delegated to Zod 4 `z.toJSONSchema()`: - The basic depiction of each schema is now natively performed by Zod 4; - Express Zod API implements some overrides and improvements to fit it into OpenAPI 3.1 that extends JSON Schema; - The `numericRange` option removed from `Documentation` class constructor argument; @@ -26,6 +25,7 @@ - Use `.or(z.undefined())` to add `undefined` to the type of the object property; - Reasoning: https://x.com/colinhacks/status/1919292504861491252; - `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality. +- The `getExamples()` public helper removed — use `.meta()?.examples` instead; - Consider the automated migration using the built-in ESLint rule. ```js diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index deb00290c..ade1ea230 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -77,7 +77,6 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "EndpointsFactory", "defaultEndpointsFactory", "arrayEndpointsFactory", - "getExamples", "getMessageFromError", "ensureHttpError", "BuiltinLogger", From 7239cde7ce5ce69295ecaeccd9296a7716bfae4a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 10:49:08 +0200 Subject: [PATCH 08/23] FIX: examples for transformations in response. --- express-zod-api/src/documentation-helpers.ts | 1 + .../tests/__snapshots__/documentation.spec.ts.snap | 8 ++++---- express-zod-api/tests/documentation.spec.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 75546deaa..16b29db57 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -281,6 +281,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { ); if (targetType && ["number", "string", "boolean"].includes(targetType)) { return { + ...jsonSchema, type: targetType as "number" | "string" | "boolean", }; } diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index c2603ea8e..0b091ea35 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3777,13 +3777,13 @@ paths: type: object properties: numericStr: - type: string examples: - - "123" + - "456" + type: string required: - numericStr examples: - - numericStr: "123" + - numericStr: "456" required: - status - data @@ -3792,7 +3792,7 @@ paths: value: status: success data: - numericStr: "123" + numericStr: "456" "400": description: GET /v1/getSomething Negative response content: diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index f285bdc8f..d4ebb432b 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1039,7 +1039,7 @@ describe("Documentation", () => { numericStr: z .number() .transform((v) => `${v}`) - .example("123"), // example for the output side of the transformation + .example("456"), // example for the output side of the transformation }), handler: async () => ({ numericStr: 123 }), }), From 386d8ec65b8b65a8e034098f5c72efa859402dc4 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 11:16:56 +0200 Subject: [PATCH 09/23] FIX: avoid calling mixExamples() on itself. --- express-zod-api/src/io-schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index e56e9ab89..ae756f7c1 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -13,6 +13,7 @@ export type IOSchema = z.ZodType; * @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas() * @since 05.03.2023 is immutable to metadata * @since 26.05.2024 uses the regular ZodIntersection + * @since 20.05.2025 avoids mixing schema examples with itself * @see mixExamples */ export const getFinalEndpointInputSchema = < @@ -26,7 +27,7 @@ export const getFinalEndpointInputSchema = < allSchemas.push(input); const finalSchema = allSchemas.reduce((acc, schema) => acc.and(schema)); return allSchemas.reduce( - (acc, schema) => mixExamples(schema, acc), + (acc, schema) => (schema === acc ? acc : mixExamples(schema, acc)), finalSchema, ) as z.ZodIntersection; }; From c0aa69776594970b32631cc2fde01ab61d12f386 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 11:30:18 +0200 Subject: [PATCH 10/23] FIX: return type for pullExampleProps(). --- express-zod-api/src/common-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index ffc7f08dd..9e58fb543 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -92,7 +92,7 @@ export const isSchema = ( /** Takes the original unvalidated examples from the properties of ZodObject schema shape */ export const pullExampleProps = (subject: T) => - Object.entries(subject._zod.def.shape).reduce>[]>( + Object.entries(subject._zod.def.shape).reduce>[]>( (acc, [key, schema]) => { const { examples = [] } = globalRegistry.get(schema) || {}; return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ From e7b62602d944957e38a4155f2db5cfb397b6cce5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 11:36:09 +0200 Subject: [PATCH 11/23] Feat: pull examples to the level of output (and response) in defaultResultHandler. --- express-zod-api/src/result-handler.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index 6d10cb8f6..4ea3d6e25 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -1,11 +1,17 @@ import { Request, Response } from "express"; import { globalRegistry, z } from "zod/v4"; +import type { $ZodObject } from "zod/v4/core"; import { ApiResponse, defaultStatusCodes, NormalizedResponse, } from "./api-response"; -import { FlatObject, isObject } from "./common-helpers"; +import { + FlatObject, + isObject, + isSchema, + pullExampleProps, +} from "./common-helpers"; import { contentTypes } from "./content-type"; import { IOSchema } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; @@ -97,6 +103,10 @@ export class ResultHandler< export const defaultResultHandler = new ResultHandler({ positive: (output) => { const { examples = [] } = globalRegistry.get(output) || {}; + if (!examples.length && isSchema<$ZodObject>(output, "object")) + examples.push(...pullExampleProps(output as $ZodObject)); + if (examples.length && !globalRegistry.has(output)) + globalRegistry.add(output, { examples }); const responseSchema = z.object({ status: z.literal("success"), data: output, From 9552bf383a9aa475f2aa6b9fec5a93c6ce119787 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 12:21:13 +0200 Subject: [PATCH 12/23] Fix examples for dateIn and dateOut, updating example docs. --- example/endpoints/update-user.ts | 12 +++++++----- example/example.documentation.yaml | 18 ++++++------------ express-zod-api/src/documentation-helpers.ts | 19 ++++++++++--------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index bcbc3b110..7ca0c5d51 100644 --- a/example/endpoints/update-user.ts +++ b/example/endpoints/update-user.ts @@ -1,6 +1,6 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; -import { z } from "zod/v4"; +import { $brand, z } from "zod/v4"; import { ez } from "express-zod-api"; import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories"; @@ -12,15 +12,17 @@ export const updateUserEndpoint = // id is the route path param of /v1/user/:id id: z .string() + .example("12") // before transformation .transform((value) => parseInt(value, 10)) - .refine((value) => value >= 0, "should be greater than or equal to 0") - .example("12"), + .refine((value) => value >= 0, "should be greater than or equal to 0"), name: z.string().nonempty().example("John Doe"), - birthday: ez.dateIn().example("1963-04-21"), + birthday: ez.dateIn().example(new Date("1963-04-21") as Date & $brand), }), output: z.object({ name: z.string().example("John Doe"), - createdAt: ez.dateOut().example(new Date("2021-12-31")), + createdAt: ez + .dateOut() + .example("2021-12-31T00:00:00.000Z" as string & $brand), }), handler: async ({ input: { id, name }, diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 31a78f557..29fbfc973 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -111,9 +111,9 @@ paths: description: PATCH /v1/user/:id Parameter schema: type: string - minLength: 1 examples: - "1234567890" + minLength: 1 examples: example1: value: "1234567890" @@ -136,15 +136,15 @@ paths: type: object properties: key: - type: string - minLength: 1 examples: - 1234-5678-90 - name: type: string minLength: 1 + name: examples: - John Doe + type: string + minLength: 1 birthday: description: YYYY-MM-DDTHH:mm:ss.sssZ type: string @@ -153,17 +153,11 @@ paths: externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString examples: - - 1963-04-21 + - 1963-04-21T00:00:00.000Z required: - key - name - birthday - examples: - example1: - value: - key: 1234-5678-90 - name: John Doe - birthday: 1963-04-21 required: true security: - APIKEY_1: [] @@ -183,9 +177,9 @@ paths: type: object properties: name: - type: string examples: - John Doe + type: string createdAt: description: YYYY-MM-DDTHH:mm:ss.sssZ type: string diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 16b29db57..1809a3eaa 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -23,7 +23,7 @@ import { TagObject, } from "openapi3-ts/oas31"; import * as R from "ramda"; -import { z } from "zod/v4"; +import { globalRegistry, z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { FlatObject, @@ -194,7 +194,7 @@ const ensureCompliance = ({ return valid; }; -export const depictDateIn: Depicter = ({}, ctx) => { +export const depictDateIn: Depicter = ({ zodSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); return { @@ -202,22 +202,23 @@ export const depictDateIn: Depicter = ({}, ctx) => { type: "string", format: "date-time", pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source, - externalDocs: { - url: isoDateDocumentationUrl, - }, + externalDocs: { url: isoDateDocumentationUrl }, + examples: globalRegistry + .get(zodSchema) + ?.examples?.filter((one) => one instanceof Date) + .map((one) => one.toISOString()), }; }; -export const depictDateOut: Depicter = ({}, ctx) => { +export const depictDateOut: Depicter = ({ jsonSchema: { examples } }, ctx) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); return { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", - externalDocs: { - url: isoDateDocumentationUrl, - }, + externalDocs: { url: isoDateDocumentationUrl }, + examples, }; }; From 72bb20d39ed0987e5565e6f1ad595663d2da3af2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 12:30:05 +0200 Subject: [PATCH 13/23] Updating snapshots. --- .../tests/__snapshots__/documentation-helpers.spec.ts.snap | 2 ++ express-zod-api/tests/__snapshots__/index.spec.ts.snap | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 2d4cc598f..69fcde672 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -11,6 +11,7 @@ exports[`Documentation helpers > depictBigInt() > should set type:string and for exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 1`] = ` { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", + "examples": undefined, "externalDocs": { "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", }, @@ -30,6 +31,7 @@ DocumentationError({ exports[`Documentation helpers > depictDateOut > should set type:string, description and format 1`] = ` { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", + "examples": undefined, "externalDocs": { "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", }, diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index ade1ea230..ed696e900 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -67,8 +67,6 @@ exports[`Index Entrypoint > exports > ez should have certain value 1`] = ` } `; -exports[`Index Entrypoint > exports > getExamples should have certain value 1`] = `[Function]`; - exports[`Index Entrypoint > exports > getMessageFromError should have certain value 1`] = `[Function]`; exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = ` From 4bf6905be6344236e9d49678505d232d35265dd0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 13:02:05 +0200 Subject: [PATCH 14/23] Rev: run mixExamples() on itself to pull from props, FIX: missing top level pulled examples on depicting body. --- example/example.documentation.yaml | 6 ++++++ express-zod-api/src/documentation-helpers.ts | 8 ++++++-- express-zod-api/src/io-schema.ts | 3 +-- express-zod-api/src/metadata.ts | 9 +++++---- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 29fbfc973..7a1137c38 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -158,6 +158,12 @@ paths: - key - name - birthday + examples: + example1: + value: + key: 1234-5678-90 + name: John Doe + birthday: 1963-04-21T00:00:00.000Z required: true security: - APIKEY_1: [] diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 1809a3eaa..b9508ab6c 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -628,11 +628,15 @@ export const depictRequest = ({ }: ReqResCommons & { schema: IOSchema; brandHandling?: BrandHandling; -}) => - depict(schema, { +}) => { + const request = depict(schema, { rules: { ...brandHandling, ...depicters }, ctx: { isResponse: false, makeRef, path, method }, }); + const { examples } = globalRegistry.get(schema) || {}; + if (examples) request.examples = examples; + return request; +}; export const depictBody = ({ method, diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index ae756f7c1..e56e9ab89 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -13,7 +13,6 @@ export type IOSchema = z.ZodType; * @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas() * @since 05.03.2023 is immutable to metadata * @since 26.05.2024 uses the regular ZodIntersection - * @since 20.05.2025 avoids mixing schema examples with itself * @see mixExamples */ export const getFinalEndpointInputSchema = < @@ -27,7 +26,7 @@ export const getFinalEndpointInputSchema = < allSchemas.push(input); const finalSchema = allSchemas.reduce((acc, schema) => acc.and(schema)); return allSchemas.reduce( - (acc, schema) => (schema === acc ? acc : mixExamples(schema, acc)), + (acc, schema) => mixExamples(schema, acc), finalSchema, ) as z.ZodIntersection; }; diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index a2c0cdc70..39b6bf95f 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -9,13 +9,14 @@ export const mixExamples = ( src: A, dest: B, ): B => { - const srcExamples = src.meta()?.examples; - if (!srcExamples) return dest; + const srcExamples = + src.meta()?.examples || + (isSchema<$ZodObject>(src, "object") ? pullExampleProps(src) : undefined); + if (!srcExamples?.length) return dest; const destMeta = dest.meta(); const examples = combinations & z.output>( destMeta?.examples || [], - srcExamples || - (isSchema<$ZodObject>(src, "object") ? pullExampleProps(src) : []), + srcExamples, ([destExample, srcExample]) => typeof destExample === "object" && typeof srcExample === "object" && From 99989d9ec27ac87bdccada73cf294c1e700940d7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 14:07:16 +0200 Subject: [PATCH 15/23] Ref: avoiding empty examples on ez date schemas, more tests for that. --- express-zod-api/src/documentation-helpers.ts | 17 +++--- .../documentation-helpers.spec.ts.snap | 58 +++++++++++++++++-- .../tests/documentation-helpers.spec.ts | 28 +++++++-- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index b9508ab6c..698be7d87 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -197,29 +197,32 @@ const ensureCompliance = ({ export const depictDateIn: Depicter = ({ zodSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); - return { + const jsonSchema: JSONSchema.StringSchema = { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source, externalDocs: { url: isoDateDocumentationUrl }, - examples: globalRegistry - .get(zodSchema) - ?.examples?.filter((one) => one instanceof Date) - .map((one) => one.toISOString()), }; + const examples = globalRegistry + .get(zodSchema) + ?.examples?.filter((one) => one instanceof Date) + .map((one) => one.toISOString()); + if (examples?.length) jsonSchema.examples = examples; + return jsonSchema; }; export const depictDateOut: Depicter = ({ jsonSchema: { examples } }, ctx) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); - return { + const jsonSchema: JSONSchema.StringSchema = { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", externalDocs: { url: isoDateDocumentationUrl }, - examples, }; + if (examples?.length) jsonSchema.examples = examples; + return jsonSchema; }; export const depictBigInt: Depicter = () => ({ diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 69fcde672..530bb1329 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -8,10 +8,36 @@ exports[`Documentation helpers > depictBigInt() > should set type:string and for } `; -exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 1`] = ` +exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 0 1`] = ` { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", - "examples": undefined, + "externalDocs": { + "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", + }, + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?)?Z?$", + "type": "string", +} +`; + +exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 1 1`] = ` +{ + "description": "YYYY-MM-DDTHH:mm:ss.sssZ", + "externalDocs": { + "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", + }, + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?)?Z?$", + "type": "string", +} +`; + +exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 2 1`] = ` +{ + "description": "YYYY-MM-DDTHH:mm:ss.sssZ", + "examples": [ + "2024-01-01T00:00:00.000Z", + ], "externalDocs": { "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", }, @@ -28,10 +54,34 @@ DocumentationError({ }) `; -exports[`Documentation helpers > depictDateOut > should set type:string, description and format 1`] = ` +exports[`Documentation helpers > depictDateOut > should set type:string, description and format 0 1`] = ` +{ + "description": "YYYY-MM-DDTHH:mm:ss.sssZ", + "externalDocs": { + "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", + }, + "format": "date-time", + "type": "string", +} +`; + +exports[`Documentation helpers > depictDateOut > should set type:string, description and format 1 1`] = ` { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", - "examples": undefined, + "externalDocs": { + "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", + }, + "format": "date-time", + "type": "string", +} +`; + +exports[`Documentation helpers > depictDateOut > should set type:string, description and format 2 1`] = ` +{ + "description": "YYYY-MM-DDTHH:mm:ss.sssZ", + "examples": [ + "2024-01-01", + ], "externalDocs": { "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString", }, diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index ff5301ea6..29ef1255f 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -1,4 +1,4 @@ -import { JSONSchema } from "zod/v4/core"; +import { $brand, JSONSchema } from "zod/v4/core"; import { SchemaObject } from "openapi3-ts/oas31"; import * as R from "ramda"; import { z } from "zod/v4"; @@ -531,10 +531,19 @@ describe("Documentation helpers", () => { }); describe("depictDateIn", () => { - test("should set type:string, pattern and format", () => { + test.each([ + { examples: undefined }, + { examples: [] }, + { examples: [new Date("2024-01-01")] }, + ])("should set type:string, pattern and format %#", ({ examples }) => { expect( depictDateIn( - { zodSchema: z.never(), jsonSchema: { anyOf: [] } }, + { + zodSchema: ez + .dateIn() + .meta({ examples: examples as Array }), + jsonSchema: { anyOf: [], examples }, + }, requestCtx, ), ).toMatchSnapshot(); @@ -547,9 +556,16 @@ describe("Documentation helpers", () => { }); describe("depictDateOut", () => { - test("should set type:string, description and format", () => { - expect( - depictDateOut({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), + test.each([ + { examples: undefined }, + { examples: [] }, + { examples: ["2024-01-01"] }, + ])("should set type:string, description and format %#", ({ examples }) => { + expect( + depictDateOut( + { zodSchema: z.never(), jsonSchema: { examples } }, + responseCtx, + ), ).toMatchSnapshot(); }); test("should throw when ZodDateOut in request", () => { From 4ed85961ce82febd594bc68e66d49ab7bd09b900 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 20 May 2025 14:14:42 +0200 Subject: [PATCH 16/23] Update express-zod-api/tests/documentation.spec.ts --- express-zod-api/tests/documentation.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index d4ebb432b..16913228c 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1096,7 +1096,7 @@ describe("Documentation", () => { numericStr: z.number().transform((v) => `${v}`), }) .example({ - numericStr: "123", // example is for input side of the transformation + numericStr: "123", // example is for output side of the transformation }), handler: async () => ({ numericStr: 123 }), }), From d0c869280f2ca2b3cc423eec966ee324f64d2a15 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 15:38:16 +0200 Subject: [PATCH 17/23] minor: check for examples before assigning in depictRequest. --- express-zod-api/src/documentation-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 698be7d87..a77f71d8b 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -637,7 +637,7 @@ export const depictRequest = ({ ctx: { isResponse: false, makeRef, path, method }, }); const { examples } = globalRegistry.get(schema) || {}; - if (examples) request.examples = examples; + if (examples?.length) request.examples ??= examples; return request; }; From 7d3a0084d52949fd8224f4b52e873c4947d3ee8c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 19:03:47 +0200 Subject: [PATCH 18/23] FIX: preserve mixed examples by flattenIO. --- express-zod-api/src/documentation-helpers.ts | 8 ++------ express-zod-api/src/json-schema-helpers.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index a77f71d8b..8881b5ea0 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -631,15 +631,11 @@ export const depictRequest = ({ }: ReqResCommons & { schema: IOSchema; brandHandling?: BrandHandling; -}) => { - const request = depict(schema, { +}) => + depict(schema, { rules: { ...brandHandling, ...depicters }, ctx: { isResponse: false, makeRef, path, method }, }); - const { examples } = globalRegistry.get(schema) || {}; - if (examples?.length) request.examples ??= examples; - return request; -}; export const depictBody = ({ method, diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 06dfda115..17379ef04 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -51,14 +51,6 @@ export const flattenIO = ( } if (entry.anyOf) stack.push(...R.map(nestOptional, entry.anyOf)); if (entry.oneOf) stack.push(...R.map(nestOptional, entry.oneOf)); - if (!isJsonObjectSchema(entry)) continue; - if (entry.properties) { - flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( - flat.properties, - entry.properties, - ); - if (!isOptional && entry.required) flatRequired.push(...entry.required); - } if (entry.examples?.length) { if (isOptional) { flat.examples = R.concat(flat.examples || [], entry.examples); @@ -70,6 +62,14 @@ export const flattenIO = ( ); } } + if (!isJsonObjectSchema(entry)) continue; + if (entry.properties) { + flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( + flat.properties, + entry.properties, + ); + if (!isOptional && entry.required) flatRequired.push(...entry.required); + } if (entry.propertyNames) { const keys: string[] = []; if (typeof entry.propertyNames.const === "string") From d61860a9e823e5f3a7d58161198404a62f062230 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 19:07:51 +0200 Subject: [PATCH 19/23] minor: comment. --- express-zod-api/src/documentation-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 8881b5ea0..fa20ac911 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -205,7 +205,7 @@ export const depictDateIn: Depicter = ({ zodSchema }, ctx) => { externalDocs: { url: isoDateDocumentationUrl }, }; const examples = globalRegistry - .get(zodSchema) + .get(zodSchema) // zod::toJSONSchema() does not provide examples for the input size of a pipe ?.examples?.filter((one) => one instanceof Date) .map((one) => one.toISOString()); if (examples?.length) jsonSchema.examples = examples; From d19ed5be54c000c0a502cc2ac5f879b88b56d3ba Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 19:44:23 +0200 Subject: [PATCH 20/23] minor: todos. --- express-zod-api/src/documentation-helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index fa20ac911..e32931aba 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -377,7 +377,7 @@ export const depictRequestParams = ({ schema: result, examples: enumerateExamples( isSchemaObject(depicted) && depicted.examples?.length - ? depicted.examples // own examples or from the flat: + ? depicted.examples // own examples or from the flat: // @todo check if both still needed : R.pluck( name, flat.examples?.filter(R.both(isObject, R.has(name))) || [], @@ -672,7 +672,7 @@ export const depictBody = ({ examples: enumerateExamples( examples.length ? examples - : flattenIO(request) + : flattenIO(request) // @todo this branch might no longer be required .examples?.filter( (one): one is FlatObject => isObject(one) && !Array.isArray(one), ) From 9c0fecf09206ae6d45b517d35314a2a24bf842d7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 19:57:33 +0200 Subject: [PATCH 21/23] minor: todo. --- express-zod-api/src/documentation-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index e32931aba..3b64a9afa 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -153,6 +153,7 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({ ...jsonSchema, }); +/** @todo might no longer be required */ export const depictObject: Depicter = ( { zodSchema, jsonSchema }, { isResponse }, From 589b94c9117c46a2d2ffb851d0c8492c5ed1689e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 21:02:37 +0200 Subject: [PATCH 22/23] Changelog: reflecting examples. --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c0e0f80..2ecf6f9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ - the advantages of Zod 4 that provide opportunities to simplifications and corrections of known issues. - `IOSchema` type had to be simplified down to a schema resulting to an `object`, but not an `array`; - Refer to [Migration guide on Zod 4](https://v4.zod.dev/v4/changelog) for adjusting your schemas; +- Changes to `ZodType::example()` (Zod plugin method): + - Now acts as an alias for `ZodType::meta({ examples })`; + - The argument has to be the output type of the schema (used to be the opposite): + - This change is only breaking for transforming schemas; + - In order to specify an input example for a transforming schema the `.example()` method must be called before it; - Generating Documentation is mostly delegated to Zod 4 `z.toJSONSchema()`: - The basic depiction of each schema is now natively performed by Zod 4; - Express Zod API implements some overrides and improvements to fit it into OpenAPI 3.1 that extends JSON Schema; @@ -44,6 +49,14 @@ export default [ + import { z } from "zod/v4"; ``` +```diff + z.string() ++ .example("123") + .transform(Number) +- .example("123") ++ .example(123) +``` + ## Version 23 ### v23.5.0 From 364723324b2f586998b23745402a5c5fb4335652 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 20 May 2025 21:08:20 +0200 Subject: [PATCH 23/23] Readme: reflecting changes to examples. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 597931339..e30c187cf 100644 --- a/README.md +++ b/README.md @@ -1271,7 +1271,11 @@ const exampleEndpoint = defaultEndpointsFactory.build({ shortDescription: "Retrieves the user.", // <—— this becomes the summary line description: "The detailed explanaition on what this endpoint does.", input: z.object({ - id: z.number().describe("the ID of the user").example(123), + id: z + .string() + .example("123") // input examples should be set before transformations + .transform(Number) + .describe("the ID of the user"), }), // ..., similarly for output and middlewares });