Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6abe87b
Draft: rm Metadata::examples, changing ZodType::example to accept out…
RobinTail May 18, 2025
88fb367
Removing getExample() in order to replace it with another approach.
RobinTail May 18, 2025
b9721fe
Transforming examples when doing .transform() and pulling props examp…
RobinTail May 19, 2025
3b7af8c
merging from make-v24, reverting plugin in altering transformer - not…
RobinTail May 20, 2025
7f8747a
Merge branch 'make-v24' into exp-native-examples2
RobinTail May 20, 2025
d22936f
rm redundant statement in test.
RobinTail May 20, 2025
ca825e4
rm onEach and adjust result handler tests.
RobinTail May 20, 2025
6d3b713
Restoring noop case for mixExamples.
RobinTail May 20, 2025
db54c83
Changelog: addressing removed getExamples().
RobinTail May 20, 2025
7239cde
FIX: examples for transformations in response.
RobinTail May 20, 2025
386d8ec
FIX: avoid calling mixExamples() on itself.
RobinTail May 20, 2025
c0aa697
FIX: return type for pullExampleProps().
RobinTail May 20, 2025
e7b6260
Feat: pull examples to the level of output (and response) in defaultR…
RobinTail May 20, 2025
9552bf3
Fix examples for dateIn and dateOut, updating example docs.
RobinTail May 20, 2025
72bb20d
Updating snapshots.
RobinTail May 20, 2025
4bf6905
Rev: run mixExamples() on itself to pull from props, FIX: missing top…
RobinTail May 20, 2025
99989d9
Ref: avoiding empty examples on ez date schemas, more tests for that.
RobinTail May 20, 2025
4ed8596
Update express-zod-api/tests/documentation.spec.ts
RobinTail May 20, 2025
d0c8692
minor: check for examples before assigning in depictRequest.
RobinTail May 20, 2025
7d3a008
FIX: preserve mixed examples by flattenIO.
RobinTail May 20, 2025
d61860a
minor: comment.
RobinTail May 20, 2025
d19ed5b
minor: todos.
RobinTail May 20, 2025
9c0fecf
minor: todo.
RobinTail May 20, 2025
589b94c
Changelog: reflecting examples.
RobinTail May 20, 2025
3647233
Readme: reflecting changes to examples.
RobinTail May 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Copy link
Owner Author

@RobinTail RobinTail May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • describe how to set examples properly
    • in relation to transformations
    • in relation to proprietary types (branded)

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions example/endpoints/update-user.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 },
Expand Down
14 changes: 7 additions & 7 deletions example/example.documentation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ paths:
description: PATCH /v1/user/:id Parameter
schema:
type: string
minLength: 1
examples:
- "1234567890"
minLength: 1
examples:
example1:
value: "1234567890"
Expand All @@ -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
Expand All @@ -153,7 +153,7 @@ 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
Expand All @@ -163,7 +163,7 @@ paths:
value:
key: 1234-5678-90
name: John Doe
birthday: 1963-04-21
birthday: 1963-04-21T00:00:00.000Z
required: true
security:
- APIKEY_1: []
Expand All @@ -183,9 +183,9 @@ paths:
type: object
properties:
name:
type: string
examples:
- John Doe
type: string
createdAt:
description: YYYY-MM-DDTHH:mm:ss.sssZ
type: string
Expand Down
46 changes: 2 additions & 44 deletions express-zod-api/src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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 */
Expand Down Expand Up @@ -93,9 +92,9 @@ export const isSchema = <T extends $ZodType>(

/** Takes the original unvalidated examples from the properties of ZodObject schema shape */
export const pullExampleProps = <T extends $ZodObject>(subject: T) =>
Object.entries(subject._zod.def.shape).reduce<Partial<z.input<T>>[]>(
Object.entries(subject._zod.def.shape).reduce<Partial<z.output<T>>[]>(
(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,
Expand All @@ -104,47 +103,6 @@ export const pullExampleProps = <T extends $ZodObject>(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<V extends "parsed" ? z.output<T> : z.input<T>> => {
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.input<T> | z.output<T>> = [];
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 = <T>(
a: T[],
b: T[],
Expand Down
46 changes: 19 additions & 27 deletions express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ 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,
getExamples,
getRoutePathParams,
getTransformedType,
isObject,
Expand Down Expand Up @@ -154,6 +153,7 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({
...jsonSchema,
});

/** @todo might no longer be required */
export const depictObject: Depicter = (
{ zodSchema, jsonSchema },
{ isResponse },
Expand Down Expand Up @@ -195,31 +195,35 @@ 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 {
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,
},
externalDocs: { url: isoDateDocumentationUrl },
};
const examples = globalRegistry
.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;
return jsonSchema;
};

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 {
const jsonSchema: JSONSchema.StringSchema = {
description: "YYYY-MM-DDTHH:mm:ss.sssZ",
type: "string",
format: "date-time",
externalDocs: {
url: isoDateDocumentationUrl,
},
externalDocs: { url: isoDateDocumentationUrl },
};
if (examples?.length) jsonSchema.examples = examples;
return jsonSchema;
};

export const depictBigInt: Depicter = () => ({
Expand Down Expand Up @@ -282,6 +286,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => {
);
if (targetType && ["number", "string", "boolean"].includes(targetType)) {
return {
...jsonSchema,
type: targetType as "number" | "string" | "boolean",
};
}
Expand Down Expand Up @@ -373,7 +378,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))) || [],
Expand Down Expand Up @@ -403,18 +408,6 @@ const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =
[ezRawBrand]: depictRaw,
};

const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
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;
};

/**
* 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?
Expand Down Expand Up @@ -462,7 +455,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;
Expand Down Expand Up @@ -681,7 +673,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),
)
Expand Down
2 changes: 1 addition & 1 deletion express-zod-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 8 additions & 8 deletions express-zod-api/src/json-schema-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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")
Expand Down
25 changes: 10 additions & 15 deletions express-zod-api/src/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import type { $ZodType } from "zod/v4/core";
import { combinations } from "./common-helpers";
import type { $ZodType, $ZodObject } from "zod/v4/core";
import { combinations, isSchema, pullExampleProps } from "./common-helpers";
import { z } from "zod/v4";
import * as R from "ramda";

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

export interface Metadata {
examples: unknown[];
}

export const mixExamples = <A extends z.ZodType, B extends z.ZodType>(
src: A,
dest: B,
): B => {
const srcMeta = src.meta();
const srcExamples =
src.meta()?.examples ||
(isSchema<$ZodObject>(src, "object") ? pullExampleProps(src) : undefined);
if (!srcExamples?.length) return dest;
const destMeta = dest.meta();
if (!srcMeta?.[metaSymbol]) return dest; // ensures srcMeta[metaSymbol]
const examples = combinations(
destMeta?.[metaSymbol]?.examples || [],
srcMeta[metaSymbol].examples || [],
const examples = combinations<z.output<A> & z.output<B>>(
destMeta?.examples || [],
srcExamples,
([destExample, srcExample]) =>
typeof destExample === "object" &&
typeof srcExample === "object" &&
Expand All @@ -27,10 +25,7 @@ export const mixExamples = <A extends z.ZodType, B extends z.ZodType>(
? 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 }); // @todo might not be required to spread since .meta() does it now
};

export const getBrand = (subject: $ZodType) => {
Expand Down
Loading