diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd559e20..2ecf6f9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,13 @@ - 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()`: +- 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; - The `numericRange` option removed from `Documentation` class constructor argument; @@ -26,6 +30,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 @@ -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 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 }); 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..7a1137c38 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,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 @@ -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: [] @@ -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 diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index e77f37007..9e58fb543 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -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 */ @@ -93,9 +92,9 @@ 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)?.[metaSymbol] || {}; + const { examples = [] } = globalRegistry.get(schema) || {}; return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ ...left, ...right, @@ -104,47 +103,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 927bac2cf..3b64a9afa 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -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, @@ -154,6 +153,7 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({ ...jsonSchema, }); +/** @todo might no longer be required */ export const depictObject: Depicter = ( { zodSchema, jsonSchema }, { isResponse }, @@ -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 = () => ({ @@ -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", }; } @@ -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))) || [], @@ -403,18 +408,6 @@ const depicters: Partial> = [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? @@ -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; @@ -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), ) 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/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") diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index ac8e2aa4b..39b6bf95f 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -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 = ( 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>( + destMeta?.examples || [], + srcExamples, ([destExample, srcExample]) => typeof destExample === "object" && typeof srcExample === "object" && @@ -27,10 +25,7 @@ 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 }); // @todo might not be required to spread since .meta() does it now }; export const getBrand = (subject: $ZodType) => { diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index 930308c3c..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 { z } from "zod/v4"; +import { globalRegistry, z } from "zod/v4"; +import type { $ZodObject } from "zod/v4/core"; import { ApiResponse, defaultStatusCodes, NormalizedResponse, } from "./api-response"; -import { FlatObject, getExamples, 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"; @@ -96,8 +102,11 @@ 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) || {}; + 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, @@ -141,8 +150,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/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 383cbfc49..77ea3a459 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -10,7 +10,7 @@ import * as R from "ramda"; import { z } from "zod/v4"; import { FlatObject } from "./common-helpers"; -import { Metadata, metaSymbol } from "./metadata"; +import { metaSymbol } from "./metadata"; import { Intact, Remap } from "./mapping-helpers"; import type { $ZodType, @@ -24,7 +24,6 @@ import type { declare module "zod/v4/core" { interface GlobalMeta { - [metaSymbol]?: Metadata; deprecated?: boolean; default?: unknown; // can be an actual value or a label like "Today" } @@ -32,8 +31,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 { @@ -89,14 +88,11 @@ const $EZBrandCheck = z.core.$constructor<$EZBrandCheck>( }, ); -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) { 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..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,7 +8,7 @@ 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", "externalDocs": { @@ -20,6 +20,33 @@ exports[`Documentation helpers > depictDateIn > should set type:string, pattern } `; +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", + }, + "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 throw when ZodDateIn in response 1`] = ` DocumentationError({ "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", @@ -27,7 +54,18 @@ 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", "externalDocs": { @@ -38,6 +76,20 @@ exports[`Documentation helpers > depictDateOut > should set type:string, descrip } `; +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", + }, + "format": "date-time", + "type": "string", +} +`; + exports[`Documentation helpers > depictDateOut > should throw when ZodDateOut in request 1`] = ` DocumentationError({ "cause": "Input schema of an Endpoint assigned to GET method of /v1/user/:id path.", diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 4e727a590..0b091ea35 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3298,6 +3298,11 @@ paths: type: string const: success data: + examples: + - a: first + b: prefix_first + - a: second + b: prefix_second type: object properties: a: @@ -3307,11 +3312,6 @@ paths: required: - a - b - examples: - - a: first - b: prefix_first - - a: second - b: prefix_second required: - status - data @@ -3496,14 +3496,14 @@ paths: type: string const: success data: + examples: + - num: 123 type: object properties: num: type: number required: - num - examples: - - num: 123 required: - status - data @@ -3586,14 +3586,14 @@ paths: type: string const: success data: + examples: + - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr - examples: - - numericStr: "123" required: - status - data @@ -3682,14 +3682,14 @@ paths: type: string const: success data: + examples: + - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr - examples: - - numericStr: "123" required: - status - data @@ -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/__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": { diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index deb00290c..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`] = ` @@ -77,7 +75,6 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "EndpointsFactory", "defaultEndpointsFactory", "arrayEndpointsFactory", - "getExamples", "getMessageFromError", "ensureHttpError", "BuiltinLogger", 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-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", () => { diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index f4dcdb575..16913228c 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({ @@ -1031,14 +1032,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("456"), // example for the output side of the transformation }), handler: async () => ({ numericStr: 123 }), }), @@ -1058,18 +1059,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 }), }), @@ -1090,18 +1088,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 output 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, 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/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(); }); }); diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index 47eb23995..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()", () => { @@ -13,23 +13,16 @@ 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(schema.meta()?.[metaSymbol]).toBeUndefined(); + expect(schemaWithExample.meta()?.examples).toEqual(["test"]); 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 +31,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",