From 4cfc1026a2459c00026af879a7e39ffd6862d8dc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 17 Apr 2025 13:05:33 +0200 Subject: [PATCH 001/187] Adjusting CI triggers. --- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/node.js.yml | 4 ++-- .github/workflows/validations.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 520a1ffc0..c580fbdf2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v19, v20, v21, v22 ] + branches: [ master, v20, v21, v22, v23, make-v24 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v19, v20, v21, v22 ] + branches: [ master, v20, v21, v22, v23, make-v24 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index ea23c0501..e75c3af68 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,9 +5,9 @@ name: Node.js CI on: push: - branches: [ master, v19, v20, v21, v22 ] + branches: [ master, v20, v21, v22, v23, make-v24 ] pull_request: - branches: [ master, v19, v20, v21, v22 ] + branches: [ master, v20, v21, v22, v23, make-v24 ] jobs: build: diff --git a/.github/workflows/validations.yml b/.github/workflows/validations.yml index d8216701f..1a181c84b 100644 --- a/.github/workflows/validations.yml +++ b/.github/workflows/validations.yml @@ -2,9 +2,9 @@ name: Validations on: push: - branches: [ master, v19, v20, v21, v22 ] + branches: [ master, v20, v21, v22, v23, make-v24 ] pull_request: - branches: [ master, v19, v20, v21, v22 ] + branches: [ master, v20, v21, v22, v23, make-v24 ] jobs: From 0d9382b0db964e0d590852a4c379ce49689f840e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 17 Apr 2025 17:56:03 +0200 Subject: [PATCH 002/187] Migration to Zod 4 (#2537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is for migrating the framework onto Zod 4 as is and preserving its original behaviour and functionality as much as possible, except only the utilization of the new Metadata system for its purposes. All other improvements, taking advantages of new features, simplifications and prettifying should be separated into dedicated PRs. The current problems: - IOSchema ✅ - incompability of zod 4 schemas between each other, in particular Typescript does not like that certain schemas have different `brand()` and `_zod.values` - is inability of Typescript to handle circular nature of the `IOSchema` type when it's based on `ZodObject` from Zod 4. - the cause seems to be top level transformations chain - possible simplification could be any schema that leads to an object, but not array: `z.ZodType` - made a runtime validation - Plugin ✅ - ZodType is not patchable through prototypes - found a workaround by patching each `Zod*` class - however, since meta system is now detached, it won't withstand refinements - found a workaround by taking advantage of setter - Traversing schemas ✅ - Schema of `zod` seem to contain and refer to `@zod/core` as children, not other `zod` ones - Found a way to rewrite rules based on `@zod/core` - Iterating checks 🆗 - check type is `string`, not literal, have to introduce a lot of `satisfies` - they all have base-type `never` for some reason, that makes them unusable for `.find()` - checks can also contain other schemas `ZodISODate` for example which is not typed at all - `z.base64()` is not the same as `z.string().base64()`, but they both have `type: string`, so the check itself can act as a schema, and it should be handled accordingly - made handling for those cases - ⚠️ currently things like `z.int().max(100)` do not work due to external bug, so it's unclear what exactly such definition would look like - `ZodError` does not inherit `Error` anymore 🆗 - this should require an extra care to revisit all the code - ~~`issues` property marked as internal and deprecated, probably should use `z.treeifyError`~~ - that note was [removed in core 0.5.1](https://github.com/colinhacks/zod/pull/4074/commits/ee6b5f419594b3fecae68cabf23eaf3df822a3b6#diff-3c955f0f8d911dbf3b8523f50874b91753fecdf8d4c020087761e2807e895d56L178) - `ensureError()` will make an error out of `ZodError` with message - `ZodLazy` now has a `getter()` ✅ - that always returns a new schema instance, which is not deduplicatble by `Map` - fixed by mapping over the getter - Optionals are buggy ✅ - `_zod.qin` is not set on `ZodOptional` - `isOptional()` may throw despite using `safeParse` when it's dealing with `ZodPromise` - handled all that ![](https://media1.tenor.com/m/1RXoW6pyTXMAAAAd/jeff-bridges-what.gif) --- .github/workflows/validations.yml | 2 +- example/__snapshots__/index.spec.ts.snap | 2 +- example/endpoints/submit-feedback.ts | 2 +- example/endpoints/upload-avatar.ts | 10 +- example/factories.ts | 6 +- example/package.json | 1 + express-zod-api/package.json | 2 +- express-zod-api/src/common-helpers.ts | 33 +- express-zod-api/src/date-in-schema.ts | 3 +- express-zod-api/src/date-out-schema.ts | 2 - express-zod-api/src/deep-checks.ts | 147 ++--- express-zod-api/src/diagnostics.ts | 19 +- express-zod-api/src/documentation-helpers.ts | 514 +++++++++-------- express-zod-api/src/documentation.ts | 6 +- express-zod-api/src/endpoints-factory.ts | 10 +- express-zod-api/src/form-schema.ts | 3 +- express-zod-api/src/integration.ts | 11 +- express-zod-api/src/io-schema.ts | 64 +-- express-zod-api/src/metadata.ts | 38 +- express-zod-api/src/middleware.ts | 6 +- express-zod-api/src/raw-schema.ts | 14 +- express-zod-api/src/result-handler.ts | 8 +- express-zod-api/src/routing.ts | 2 +- express-zod-api/src/schema-helpers.ts | 1 - express-zod-api/src/schema-walker.ts | 18 +- express-zod-api/src/typescript-api.ts | 7 +- express-zod-api/src/upload-schema.ts | 8 +- express-zod-api/src/zod-plugin.ts | 158 ++++-- express-zod-api/src/zts-helpers.ts | 9 +- express-zod-api/src/zts.ts | 234 ++++---- .../__snapshots__/date-in-schema.spec.ts.snap | 135 +++-- .../documentation-helpers.spec.ts.snap | 257 ++++----- .../__snapshots__/documentation.spec.ts.snap | 12 +- .../tests/__snapshots__/endpoint.spec.ts.snap | 40 +- .../endpoints-factory.spec.ts.snap | 226 ++++---- .../tests/__snapshots__/env.spec.ts.snap | 160 ++++++ .../__snapshots__/file-schema.spec.ts.snap | 8 +- .../__snapshots__/form-schema.spec.ts.snap | 25 +- .../__snapshots__/io-schema.spec.ts.snap | 516 ++++++++++-------- .../__snapshots__/result-helpers.spec.ts.snap | 50 +- .../tests/__snapshots__/sse.spec.ts.snap | 104 ++-- .../tests/__snapshots__/system.spec.ts.snap | 53 +- .../tests/__snapshots__/zts.spec.ts.snap | 3 +- express-zod-api/tests/common-helpers.spec.ts | 19 +- express-zod-api/tests/date-in-schema.spec.ts | 4 +- express-zod-api/tests/date-out-schema.spec.ts | 13 +- express-zod-api/tests/deep-checks.spec.ts | 18 +- .../tests/documentation-helpers.spec.ts | 227 ++++---- express-zod-api/tests/documentation.spec.ts | 38 +- express-zod-api/tests/endpoint.spec.ts | 9 +- .../tests/endpoints-factory.spec.ts | 20 +- express-zod-api/tests/env.spec.ts | 72 +++ express-zod-api/tests/errors.spec.ts | 30 - express-zod-api/tests/file-schema.spec.ts | 24 +- express-zod-api/tests/form-schema.spec.ts | 9 +- express-zod-api/tests/index.spec.ts | 8 +- express-zod-api/tests/integration.spec.ts | 9 +- express-zod-api/tests/io-schema.spec.ts | 77 +-- express-zod-api/tests/metadata.spec.ts | 20 +- express-zod-api/tests/middleware.spec.ts | 11 +- express-zod-api/tests/raw-schema.spec.ts | 13 +- express-zod-api/tests/result-handler.spec.ts | 12 +- express-zod-api/tests/routing.spec.ts | 37 +- express-zod-api/tests/schema-helpers.spec.ts | 18 - express-zod-api/tests/system.spec.ts | 2 +- express-zod-api/tests/upload-schema.spec.ts | 5 +- express-zod-api/tests/zod-plugin.spec.ts | 49 +- express-zod-api/tests/zts.spec.ts | 54 +- express-zod-api/vitest.setup.ts | 60 +- package.json | 2 +- yarn.lock | 15 +- 71 files changed, 2168 insertions(+), 1636 deletions(-) delete mode 100644 express-zod-api/src/schema-helpers.ts create mode 100644 express-zod-api/tests/__snapshots__/env.spec.ts.snap create mode 100644 express-zod-api/tests/env.spec.ts delete mode 100644 express-zod-api/tests/schema-helpers.spec.ts diff --git a/.github/workflows/validations.yml b/.github/workflows/validations.yml index 1a181c84b..86de13a3f 100644 --- a/.github/workflows/validations.yml +++ b/.github/workflows/validations.yml @@ -49,7 +49,7 @@ jobs: name: dist - name: Add dependencies run: | - yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod + yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod@next yarn add -D eslint@9.0 typescript-eslint@8.0 vitest tsx yarn add express-zod-api@./dist.tgz - name: Run tests diff --git a/example/__snapshots__/index.spec.ts.snap b/example/__snapshots__/index.spec.ts.snap index a3c37ba61..7ca641c63 100644 --- a/example/__snapshots__/index.spec.ts.snap +++ b/example/__snapshots__/index.spec.ts.snap @@ -52,7 +52,7 @@ exports[`Example > Client > Should perform the request with a positive response exports[`Example > Negative > GET request should fail on missing input param 1`] = ` { "error": { - "message": "id: Required", + "message": "id: Invalid input: expected string, received undefined", }, "status": "error", } diff --git a/example/endpoints/submit-feedback.ts b/example/endpoints/submit-feedback.ts index 640cff359..9aca83648 100644 --- a/example/endpoints/submit-feedback.ts +++ b/example/endpoints/submit-feedback.ts @@ -6,7 +6,7 @@ export const submitFeedbackEndpoint = defaultEndpointsFactory.build({ tag: "forms", input: ez.form({ name: z.string().min(1), - email: z.string().email(), + email: z.email(), message: z.string().min(1), }), output: z.object({ diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index c4737e23a..bede9ae3c 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -6,17 +6,15 @@ export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ method: "post", tag: "files", description: "Handles a file upload.", - input: z - .object({ - avatar: ez.upload(), - }) - .passthrough(), + input: z.looseObject({ + avatar: ez.upload(), + }), output: z.object({ name: z.string(), size: z.number().int().nonnegative(), mime: z.string(), hash: z.string(), - otherInputs: z.record(z.any()), + otherInputs: z.record(z.string(), z.any()), }), handler: async ({ input: { avatar, ...rest } }) => { return { diff --git a/example/factories.ts b/example/factories.ts index 161ceb6b5..4282e568e 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -68,7 +68,7 @@ export const statusDependingFactory = new EndpointsFactory( negative: [ { statusCode: 409, - schema: z.object({ status: z.literal("exists"), id: z.number().int() }), + schema: z.object({ status: z.literal("exists"), id: z.int() }), }, { statusCode: [400, 500], @@ -76,8 +76,8 @@ export const statusDependingFactory = new EndpointsFactory( }, ], handler: ({ error, response, output }) => { - if (error) { - const httpError = ensureHttpError(error); + if (error || !output) { + const httpError = ensureHttpError(error || new Error("Missing output")); const doesExist = httpError.statusCode === 409 && "id" in httpError && diff --git a/example/package.json b/example/package.json index d515a8eb1..95e0e6a7b 100644 --- a/example/package.json +++ b/example/package.json @@ -8,6 +8,7 @@ "build": "yarn build:docs && yarn build:client", "build:docs": "tsx generate-documentation.ts", "build:client": "tsx generate-client.ts", + "pretest": "tsc --noEmit", "test": "vitest run --globals index.spec.ts", "validate": "vitest run --globals validate.spec.ts" }, diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 167b7c54b..c30eb31a4 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "^3.23.0" + "zod": "^4.0.0-beta.20250414T061543" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 9ba3255cf..4b344cfd3 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,3 +1,4 @@ +import type { $ZodType } from "@zod/core"; import { Request } from "express"; import * as R from "ramda"; import { z } from "zod"; @@ -9,7 +10,7 @@ import { AuxMethod, Method } from "./method"; /** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */ export type EmptyObject = Record; -export type EmptySchema = z.ZodObject; +export type EmptySchema = z.ZodObject; export type FlatObject = Record; /** @link https://stackoverflow.com/a/65492934 */ @@ -63,7 +64,11 @@ export const getInput = ( }; export const ensureError = (subject: unknown): Error => - subject instanceof Error ? subject : new Error(String(subject)); + subject instanceof Error + ? subject + : subject instanceof z.ZodError + ? new Error(subject.message) + : new Error(String(subject)); export const getMessageFromError = (error: Error): string => { if (error instanceof z.ZodError) { @@ -81,15 +86,15 @@ export const getMessageFromError = (error: Error): string => { }; /** Takes the original unvalidated examples from the properties of ZodObject schema shape */ -export const pullExampleProps = (subject: T) => +export const pullExampleProps = (subject: T) => Object.entries(subject.shape).reduce>[]>( (acc, [key, schema]) => { - const { _def } = schema as z.ZodType; - return combinations( - acc, - (_def[metaSymbol]?.examples || []).map(R.objOf(key)), - ([left, right]) => ({ ...left, ...right }), - ); + const examples = + (schema as z.ZodType).meta()?.[metaSymbol]?.examples || []; + return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ + ...left, + ...right, + })); }, [], ); @@ -122,7 +127,7 @@ export const getExamples = < * */ pullProps?: boolean; }): ReadonlyArray : z.input> => { - let examples = schema._def[metaSymbol]?.examples || []; + let examples = schema.meta()?.[metaSymbol]?.examples || []; if (!examples.length && pullProps && schema instanceof z.ZodObject) examples = pullExampleProps(schema); if (!validate && variant === "original") return examples; @@ -145,10 +150,8 @@ export const combinations = ( * @desc isNullable() and isOptional() validate the schema's input * @desc They always return true in case of coercion, which should be taken into account when depicting response */ -export const hasCoercion = (schema: z.ZodTypeAny): boolean => - "coerce" in schema._def && typeof schema._def.coerce === "boolean" - ? schema._def.coerce - : false; +export const hasCoercion = ({ _zod: { def } }: $ZodType): boolean => + "coerce" in def && def.coerce === true; export const ucFirst = (subject: string) => subject.charAt(0).toUpperCase() + subject.slice(1).toLowerCase(); @@ -164,7 +167,7 @@ export const makeCleanId = (...args: string[]) => { }; export const getTransformedType = R.tryCatch( - (schema: z.ZodEffects, sample: T) => + (schema: z.ZodTransform, sample: T) => typeof schema.parse(sample), R.always(undefined), ); diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index e12598599..0e29e4ad7 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { isValidDate } from "./schema-helpers"; export const ezDateInBrand = Symbol("DateIn"); @@ -12,7 +11,7 @@ export const dateIn = () => { return schema .transform((str) => new Date(str)) - .pipe(z.date().refine(isValidDate)) + .pipe(z.date()) .brand(ezDateInBrand as symbol); }; diff --git a/express-zod-api/src/date-out-schema.ts b/express-zod-api/src/date-out-schema.ts index 114037033..6ab5e3e69 100644 --- a/express-zod-api/src/date-out-schema.ts +++ b/express-zod-api/src/date-out-schema.ts @@ -1,12 +1,10 @@ import { z } from "zod"; -import { isValidDate } from "./schema-helpers"; export const ezDateOutBrand = Symbol("DateOut"); export const dateOut = () => z .date() - .refine(isValidDate) .transform((date) => date.toISOString()) .brand(ezDateOutBrand as symbol); diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index 58f8d63ea..db767cf53 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -1,63 +1,75 @@ +import type { + $ZodArray, + $ZodCatch, + $ZodDefault, + $ZodDiscriminatedUnion, + $ZodIntersection, + $ZodLazy, + $ZodNullable, + $ZodObject, + $ZodOptional, + $ZodPipe, + $ZodReadonly, + $ZodRecord, + $ZodTuple, + $ZodType, + $ZodUnion, +} from "@zod/core"; import { fail } from "node:assert/strict"; // eslint-disable-line no-restricted-syntax -- acceptable -import { z } from "zod"; +import { globalRegistry } from "zod"; import { EmptyObject } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { ezFileBrand } from "./file-schema"; -import { ezFormBrand, FormSchema } from "./form-schema"; +import { ezFormBrand } from "./form-schema"; import { IOSchema } from "./io-schema"; import { metaSymbol } from "./metadata"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand } from "./raw-schema"; -import { HandlingRules, NextHandlerInc, SchemaHandler } from "./schema-walker"; +import { + FirstPartyKind, + HandlingRules, + NextHandlerInc, + SchemaHandler, +} from "./schema-walker"; import { ezUploadBrand } from "./upload-schema"; /** @desc Check is a schema handling rule returning boolean */ type Check = SchemaHandler; const onSomeUnion: Check = ( - schema: - | z.ZodUnion - | z.ZodDiscriminatedUnion[]>, + { _zod }: $ZodUnion | $ZodDiscriminatedUnion, { next }, -) => schema.options.some(next); +) => _zod.def.options.some(next); -const onIntersection: Check = ( - { _def }: z.ZodIntersection, - { next }, -) => [_def.left, _def.right].some(next); +const onIntersection: Check = ({ _zod }: $ZodIntersection, { next }) => + [_zod.def.left, _zod.def.right].some(next); const onWrapped: Check = ( - schema: - | z.ZodOptional - | z.ZodNullable - | z.ZodReadonly - | z.ZodBranded, + { + _zod: { def }, + }: $ZodOptional | $ZodNullable | $ZodReadonly | $ZodDefault | $ZodCatch, { next }, -) => next(schema.unwrap()); +) => next(def.innerType); -const ioChecks: HandlingRules = { - ZodObject: ({ shape }: z.ZodObject, { next }) => - Object.values(shape).some(next), - ZodUnion: onSomeUnion, - ZodDiscriminatedUnion: onSomeUnion, - ZodIntersection: onIntersection, - ZodEffects: (schema: z.ZodEffects, { next }) => - next(schema.innerType()), - ZodOptional: onWrapped, - ZodNullable: onWrapped, - ZodRecord: ({ valueSchema }: z.ZodRecord, { next }) => next(valueSchema), - ZodArray: ({ element }: z.ZodArray, { next }) => next(element), - ZodDefault: ({ _def }: z.ZodDefault, { next }) => - next(_def.innerType), +const ioChecks: HandlingRules = { + object: ({ _zod }: $ZodObject, { next }) => + Object.values(_zod.def.shape).some(next), + union: onSomeUnion, + intersection: onIntersection, + optional: onWrapped, + nullable: onWrapped, + default: onWrapped, + record: ({ _zod }: $ZodRecord, { next }) => next(_zod.def.valueType), + array: ({ _zod }: $ZodArray, { next }) => next(_zod.def.element), }; interface NestedSchemaLookupProps { - condition?: (schema: z.ZodType) => boolean; + condition?: (schema: $ZodType) => boolean; rules?: HandlingRules< boolean, EmptyObject, - z.ZodFirstPartyTypeKind | ProprietaryBrand + FirstPartyKind | ProprietaryBrand >; maxDepth?: number; depth?: number; @@ -65,7 +77,7 @@ interface NestedSchemaLookupProps { /** @desc The optimized version of the schema walker for boolean checks */ export const hasNestedSchema = ( - subject: z.ZodType, + subject: $ZodType, { condition, rules = ioChecks, @@ -74,12 +86,12 @@ export const hasNestedSchema = ( }: NestedSchemaLookupProps, ): boolean => { if (condition?.(subject)) return true; + if (depth >= maxDepth) return false; + const brand = globalRegistry.get(subject)?.[metaSymbol]?.brand; const handler = - depth < maxDepth - ? rules[subject._def[metaSymbol]?.brand as keyof typeof rules] || - ("typeName" in subject._def && - rules[subject._def.typeName as keyof typeof rules]) - : undefined; + brand && brand in rules + ? rules[brand as keyof typeof rules] + : rules[subject._zod.def.type]; if (handler) { return handler(subject, { next: (schema) => @@ -96,56 +108,53 @@ export const hasNestedSchema = ( export const hasUpload = (subject: IOSchema) => hasNestedSchema(subject, { - condition: (schema) => schema._def[metaSymbol]?.brand === ezUploadBrand, + condition: (schema) => + globalRegistry.get(schema)?.[metaSymbol]?.brand === ezUploadBrand, rules: { ...ioChecks, - [ezFormBrand]: (schema: FormSchema, { next }) => - Object.values(schema.unwrap().shape).some(next), + [ezFormBrand]: ioChecks.object, }, }); export const hasRaw = (subject: IOSchema) => hasNestedSchema(subject, { - condition: (schema) => schema._def[metaSymbol]?.brand === ezRawBrand, + condition: (schema) => + globalRegistry.get(schema)?.[metaSymbol]?.brand === ezRawBrand, maxDepth: 3, }); export const hasForm = (subject: IOSchema) => hasNestedSchema(subject, { - condition: (schema) => schema._def[metaSymbol]?.brand === ezFormBrand, + condition: (schema) => + globalRegistry.get(schema)?.[metaSymbol]?.brand === ezFormBrand, maxDepth: 3, }); /** @throws AssertionError with incompatible schema constructor */ -export const assertJsonCompatible = (subject: IOSchema, dir: "in" | "out") => { - const lazies = new WeakSet>(); +export const assertJsonCompatible = (subject: $ZodType, dir: "in" | "out") => { + const lazies = new WeakSet<() => $ZodType>(); return hasNestedSchema(subject, { maxDepth: 300, rules: { ...ioChecks, - ZodBranded: onWrapped, - ZodReadonly: onWrapped, - ZodCatch: ({ _def: { innerType } }: z.ZodCatch, { next }) => - next(innerType), - ZodPipeline: ( - { _def }: z.ZodPipeline, - { next }, - ) => next(_def[dir]), - ZodLazy: (lazy: z.ZodLazy, { next }) => - lazies.has(lazy) ? false : lazies.add(lazy) && next(lazy.schema), - ZodTuple: ({ items, _def: { rest } }: z.AnyZodTuple, { next }) => - [...items].concat(rest ?? []).some(next), - ZodEffects: { out: undefined, in: ioChecks.ZodEffects }[dir], - ZodNaN: () => fail("z.nan()"), - ZodSymbol: () => fail("z.symbol()"), - ZodFunction: () => fail("z.function()"), - ZodMap: () => fail("z.map()"), - ZodSet: () => fail("z.set()"), - ZodBigInt: () => fail("z.bigint()"), - ZodVoid: () => fail("z.void()"), - ZodPromise: () => fail("z.promise()"), - ZodNever: () => fail("z.never()"), - ZodDate: () => dir === "in" && fail("z.date()"), + readonly: onWrapped, + catch: onWrapped, + pipe: ({ _zod }: $ZodPipe, { next }) => next(_zod.def[dir]), + lazy: ({ _zod: { def } }: $ZodLazy, { next }) => + lazies.has(def.getter) + ? false + : lazies.add(def.getter) && next(def.getter()), + tuple: ({ _zod: { def } }: $ZodTuple, { next }) => + [...def.items].concat(def.rest ?? []).some(next), + nan: () => fail("z.nan()"), + symbol: () => fail("z.symbol()"), + map: () => fail("z.map()"), + set: () => fail("z.set()"), + bigint: () => fail("z.bigint()"), + void: () => fail("z.void()"), + promise: () => fail("z.promise()"), + never: () => fail("z.never()"), + date: () => dir === "in" && fail("z.date()"), [ezDateOutBrand]: () => dir === "in" && fail("ez.dateOut()"), [ezDateInBrand]: () => dir === "out" && fail("ez.dateIn()"), [ezRawBrand]: () => dir === "out" && fail("ez.raw()"), diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 5458ade13..b7092997b 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -1,5 +1,6 @@ import * as R from "ramda"; -import type { ZodRawShape } from "zod"; +import type { $ZodShape } from "@zod/core"; +import { z } from "zod"; import { responseVariants } from "./api-response"; import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; @@ -14,13 +15,25 @@ export class Diagnostics { #verifiedEndpoints = new WeakSet(); #verifiedPaths = new WeakMap< AbstractEndpoint, - { shape: ZodRawShape; paths: string[] } + { shape: $ZodShape; paths: string[] } >(); constructor(protected logger: ActualLogger) {} - public checkJsonCompat(endpoint: AbstractEndpoint, ctx: FlatObject): void { + public checkSchema(endpoint: AbstractEndpoint, ctx: FlatObject): void { if (this.#verifiedEndpoints.has(endpoint)) return; + for (const dir of ["input", "output"] as const) { + const stack = [ + z.toJSONSchema(endpoint[`${dir}Schema`], { unrepresentable: "any" }), + ]; + while (stack.length > 0) { + const entry = stack.shift()!; + if (entry.type && entry.type !== "object") + this.logger.warn(`Endpoint ${dir} schema is not object-based`, ctx); + for (const prop of ["allOf", "oneOf", "anyOf"] as const) + if (entry[prop]) stack.push(...entry[prop]); + } + } if (endpoint.requestType === "json") { this.#trier((reason) => this.logger.warn( diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 957eb966b..86a49e853 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,3 +1,36 @@ +import type { + $ZodArray, + $ZodCatch, + $ZodDate, + $ZodDefault, + $ZodDiscriminatedUnion, + $ZodEnum, + $ZodIntersection, + $ZodLazy, + $ZodLiteral, + $ZodNullable, + $ZodObject, + $ZodOptional, + $ZodPipe, + $ZodRecord, + $ZodTuple, + $ZodType, + $ZodUnion, + $ZodChecks, + $ZodCheckMinLength, + $ZodCheckMaxLength, + $ZodString, + $ZodISODateTime, + $ZodCheckLengthEquals, + $ZodNumber, + $ZodCheckGreaterThan, + $ZodCheckLessThan, + $ZodReadonly, + $ZodStringFormat, + $ZodNumberFormat, + $ZodStringFormats, + $ZodNumberFormats, +} from "@zod/core"; import { ExamplesObject, MediaTypeObject, @@ -15,7 +48,7 @@ import { isSchemaObject, } from "openapi3-ts/oas31"; import * as R from "ramda"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { ResponseVariant } from "./api-response"; import { FlatObject, @@ -41,7 +74,12 @@ import { metaSymbol } from "./metadata"; import { Method } from "./method"; import { ProprietaryBrand } from "./proprietary-schemas"; import { RawSchema, ezRawBrand } from "./raw-schema"; -import { HandlingRules, SchemaHandler, walkSchema } from "./schema-walker"; +import { + FirstPartyKind, + HandlingRules, + SchemaHandler, + walkSchema, +} from "./schema-walker"; import { Security } from "./security"; import { UploadSchema, ezUploadBrand } from "./upload-schema"; import wellKnownHeaders from "./well-known-headers.json"; @@ -51,7 +89,7 @@ export type NumericRange = Record<"integer" | "float", [number, number]>; export interface OpenAPIContext extends FlatObject { isResponse: boolean; makeRef: ( - schema: z.ZodTypeAny, + schema: $ZodType | (() => $ZodType), subject: | SchemaObject | ReferenceObject @@ -108,19 +146,13 @@ const getTimestampRegex = (hasOffset?: boolean) => export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const depictDefault: Depicter = ( - { _def }: z.ZodDefault, - { next }, -) => ({ - ...next(_def.innerType), - default: _def[metaSymbol]?.defaultLabel || _def.defaultValue(), +export const depictDefault: Depicter = (schema: $ZodDefault, { next }) => ({ + ...next(schema._zod.def.innerType), + default: + globalRegistry.get(schema)?.[metaSymbol]?.defaultLabel || + schema._zod.def.defaultValue(), }); -export const depictCatch: Depicter = ( - { _def: { innerType } }: z.ZodCatch, - { next }, -) => next(innerType); - export const depictAny: Depicter = () => ({ format: "any" }); export const depictUpload: Depicter = ({}: UploadSchema, ctx) => { @@ -130,12 +162,15 @@ export const depictUpload: Depicter = ({}: UploadSchema, ctx) => { }; export const depictFile: Depicter = (schema: FileSchema) => { - const subject = schema.unwrap(); return { type: "string", format: - subject instanceof z.ZodString - ? subject._def.checks.find((check) => check.kind === "base64") + schema instanceof z.ZodString + ? schema._zod.def.checks?.find( + (entry) => + isCheck<$ZodStringFormat>(entry, "string_format") && + entry._zod.def.format === "base64", + ) ? "byte" : "file" : "binary", @@ -143,21 +178,16 @@ export const depictFile: Depicter = (schema: FileSchema) => { }; export const depictUnion: Depicter = ( - { options }: z.ZodUnion, - { next }, -) => ({ oneOf: options.map(next) }); - -export const depictDiscriminatedUnion: Depicter = ( - { - options, - discriminator, - }: z.ZodDiscriminatedUnion[]>, + { _zod }: $ZodUnion | $ZodDiscriminatedUnion, { next }, ) => { - return { - discriminator: { propertyName: discriminator }, - oneOf: options.map(next), - }; + const result: SchemaObject = { oneOf: _zod.def.options.map(next) }; + if (_zod.disc) { + const propertyName = Array.from(_zod.disc.keys()).pop(); + if (typeof propertyName === "string") + result.discriminator = { propertyName }; + } + return result; }; const propsMerger = (a: unknown, b: unknown) => { @@ -195,26 +225,21 @@ const intersect = R.tryCatch( ); export const depictIntersection: Depicter = ( - { _def: { left, right } }: z.ZodIntersection, + { _zod: { def } }: $ZodIntersection, { next }, -) => intersect([left, right].map(next)); +) => intersect([def.left, def.right].map(next)); -export const depictOptional: Depicter = ( - schema: z.ZodOptional, +export const depictWrapped: Depicter = ( + { _zod: { def } }: $ZodOptional | $ZodReadonly | $ZodCatch, { next }, -) => next(schema.unwrap()); - -export const depictReadonly: Depicter = ( - schema: z.ZodReadonly, - { next }, -) => next(schema.unwrap()); +) => next(def.innerType); /** @since OAS 3.1 nullable replaced with type array having null */ export const depictNullable: Depicter = ( - schema: z.ZodNullable, + { _zod: { def } }: $ZodNullable, { next }, ) => { - const nested = next(schema.unwrap()); + const nested = next(def.innerType); if (isSchemaObject(nested)) nested.type = makeNullableType(nested); return nested; }; @@ -235,28 +260,33 @@ const getSupportedType = (value: unknown): SchemaObjectType | undefined => { : undefined; }; -export const depictEnum: Depicter = ( - schema: z.ZodEnum<[string, ...string[]]> | z.ZodNativeEnum, -) => ({ - type: getSupportedType(Object.values(schema.enum)[0]), - enum: Object.values(schema.enum), +export const depictEnum: Depicter = ({ _zod: { def } }: $ZodEnum) => ({ + type: getSupportedType(Object.values(def.entries)[0]), + enum: Object.values(def.entries), }); -export const depictLiteral: Depicter = ({ value }: z.ZodLiteral) => ({ - type: getSupportedType(value), // constructor allows z.Primitive only, but ZodLiteral does not have that constraint - const: value, -}); +export const depictLiteral: Depicter = ({ _zod: { def } }: $ZodLiteral) => { + const values = Object.values(def.values); + const result: SchemaObject = { type: getSupportedType(values[0]) }; + if (values.length === 1) result.const = values[0]; + else result.enum = Object.values(def.values); + return result; +}; export const depictObject: Depicter = ( - schema: z.ZodObject, + schema: $ZodObject, { isResponse, next }, ) => { - const keys = Object.keys(schema.shape); - const isOptionalProp = (prop: z.ZodTypeAny) => + const keys = Object.keys(schema._zod.def.shape); + const isOptionalProp = (prop: $ZodType) => isResponse && hasCoercion(prop) ? prop instanceof z.ZodOptional - : prop.isOptional(); - const required = keys.filter((key) => !isOptionalProp(schema.shape[key])); + : prop instanceof z.ZodPromise + ? false + : (prop as z.ZodType).isOptional(); + const required = keys.filter( + (key) => !isOptionalProp(schema._zod.def.shape[key]), + ); const result: SchemaObject = { type: "object" }; if (keys.length) result.properties = depictObjectProperties(schema, next); if (required.length) result.required = required; @@ -297,7 +327,7 @@ export const depictDateOut: Depicter = ({}: DateOutSchema, ctx) => { }; /** @throws DocumentationError */ -export const depictDate: Depicter = ({}: z.ZodDate, ctx) => { +export const depictDate: Depicter = ({}: $ZodDate, ctx) => { throw new DocumentationError( `Using z.date() within ${ ctx.isResponse ? "output" : "input" @@ -316,64 +346,75 @@ export const depictBigInt: Depicter = () => ({ }); const areOptionsLiteral = ( - subject: z.ZodTypeAny[], -): subject is z.ZodLiteral[] => - subject.every((option) => option instanceof z.ZodLiteral); + subject: ReadonlyArray<$ZodType>, +): subject is $ZodLiteral[] => + subject.every((option) => option._zod.def.type === "literal"); export const depictRecord: Depicter = ( - { keySchema, valueSchema }: z.ZodRecord, + { _zod: { def } }: $ZodRecord, { next }, ) => { - if (keySchema instanceof z.ZodEnum || keySchema instanceof z.ZodNativeEnum) { - const keys = Object.values(keySchema.enum) as string[]; + if (def.keyType instanceof z.ZodEnum) { + const keys = Object.values(def.keyType._zod.def.entries).map(String); const result: SchemaObject = { type: "object" }; if (keys.length) { result.properties = depictObjectProperties( - z.object(R.fromPairs(R.xprod(keys, [valueSchema]))), + z.looseObject(R.fromPairs(R.xprod(keys, [def.valueType]))), next, ); result.required = keys; } return result; } - if (keySchema instanceof z.ZodLiteral) { + if (def.keyType instanceof z.ZodLiteral) { + const keys = def.keyType._zod.def.values.map(String); return { type: "object", properties: depictObjectProperties( - z.object({ [keySchema.value]: valueSchema }), + z.looseObject(R.fromPairs(R.xprod(keys, [def.valueType]))), next, ), - required: [keySchema.value], + required: keys, }; } - if (keySchema instanceof z.ZodUnion && areOptionsLiteral(keySchema.options)) { - const required = R.map((opt) => `${opt.value}`, keySchema.options); - const shape = R.fromPairs(R.xprod(required, [valueSchema])); + if ( + def.keyType instanceof z.ZodUnion && + areOptionsLiteral(def.keyType._zod.def.options) + ) { + const required = R.map( + (opt: $ZodLiteral) => `${opt._zod.def.values[0]}`, + def.keyType._zod.def.options as $ZodLiteral[], // ensured above + ); + const shape = R.fromPairs(R.xprod(required, [def.valueType])); return { type: "object", - properties: depictObjectProperties(z.object(shape), next), + properties: depictObjectProperties(z.looseObject(shape), next), required, }; } return { type: "object", - propertyNames: next(keySchema), - additionalProperties: next(valueSchema), + propertyNames: next(def.keyType), + additionalProperties: next(def.valueType), }; }; export const depictArray: Depicter = ( - { - _def: { minLength, maxLength, exactLength }, - element, - }: z.ZodArray, + { _zod: { def } }: $ZodArray, { next }, ) => { - const result: SchemaObject = { type: "array", items: next(element) }; - if (exactLength) - [result.minItems, result.maxItems] = Array(2).fill(exactLength.value); - if (minLength) result.minItems = minLength.value; - if (maxLength) result.maxItems = maxLength.value; + const result: SchemaObject = { + type: "array", + items: next(def.element), + }; + for (const check of def.checks || []) { + if (isCheck<$ZodCheckLengthEquals>(check, "length_equals")) + [result.minItems, result.maxItems] = Array(2).fill(check._zod.def.length); + if (isCheck<$ZodCheckMinLength>(check, "min_length")) + result.minItems = check._zod.def.minimum; + if (isCheck<$ZodCheckMaxLength>(check, "max_length")) + result.maxItems = check._zod.def.maximum; + } return result; }; @@ -382,82 +423,71 @@ export const depictArray: Depicter = ( * @since 17.5.0 added rest handling, fixed tuple type * */ export const depictTuple: Depicter = ( - { items, _def: { rest } }: z.AnyZodTuple, + { _zod: { def } }: $ZodTuple, { next }, ) => ({ type: "array", - prefixItems: items.map(next), + prefixItems: def.items.map(next), // does not appear to support items:false, so not:{} is a recommended alias - items: rest === null ? { not: {} } : next(rest), + items: def.rest === null ? { not: {} } : next(def.rest), }); -export const depictString: Depicter = ({ - isEmail, - isURL, - minLength, - maxLength, - isUUID, - isCUID, - isCUID2, - isULID, - isIP, - isEmoji, - isDatetime, - isCIDR, - isDate, - isTime, - isBase64, - isNANOID, - isBase64url, - isDuration, - _def: { checks }, -}: z.ZodString) => { - const regexCheck = checks.find((check) => check.kind === "regex"); - const datetimeCheck = checks.find((check) => check.kind === "datetime"); - const isJWT = checks.some((check) => check.kind === "jwt"); - const lenCheck = checks.find((check) => check.kind === "length"); +const isCheck = ( + check: unknown, + name: T["_zod"]["def"]["check"], +): check is T => R.pathEq(name, ["_zod", "def", "check"], check); + +export const depictString: Depicter = ( + schema: $ZodString | $ZodStringFormat, +) => { const result: SchemaObject = { type: "string" }; - const formats: Record, boolean> = { - "date-time": isDatetime, - byte: isBase64, - base64url: isBase64url, - date: isDate, - time: isTime, - duration: isDuration, - email: isEmail, - url: isURL, - uuid: isUUID, - cuid: isCUID, - cuid2: isCUID2, - ulid: isULID, - nanoid: isNANOID, - jwt: isJWT, - ip: isIP, - cidr: isCIDR, - emoji: isEmoji, - }; - for (const format in formats) { - if (formats[format]) { - result.format = format; - break; + const formatCast: Partial> = + { + datetime: "date-time", + base64: "byte", + ipv4: "ip", + ipv6: "ip", + cidrv4: "cidr", + cidrv6: "cidr", + regex: undefined, + }; + const { checks = [] } = schema._zod.def; + if (isCheck<$ZodStringFormat>(schema, "string_format")) checks.push(schema); + for (const check of checks) { + if (isCheck<$ZodCheckLengthEquals>(check, "length_equals")) { + [result.minLength, result.maxLength] = Array(2).fill( + check._zod.def.length, + ); + } + if (isCheck<$ZodCheckMinLength>(check, "min_length")) + result.minLength = check._zod.def.minimum; + if (isCheck<$ZodCheckMaxLength>(check, "max_length")) + result.maxLength = check._zod.def.maximum; + if (isCheck<$ZodStringFormat>(check, "string_format")) { + if (check._zod.def.format === "regex") + result.pattern = check._zod.def.pattern?.source; + if (check._zod.def.format === "date") result.pattern = dateRegex.source; + if (check._zod.def.format === "time") result.pattern = timeRegex.source; + if (check._zod.def.format === "datetime") { + result.pattern = getTimestampRegex( + (check as $ZodISODateTime)._zod.def.offset, + ).source; + } + const format = + check._zod.def.format in formatCast + ? formatCast[check._zod.def.format] + : check._zod.def.format; + if (format) result.format = format; } } - if (lenCheck) - [result.minLength, result.maxLength] = [lenCheck.value, lenCheck.value]; - if (minLength !== null) result.minLength = minLength; - if (maxLength !== null) result.maxLength = maxLength; - if (isDate) result.pattern = dateRegex.source; - if (isTime) result.pattern = timeRegex.source; - if (isDatetime) - result.pattern = getTimestampRegex(datetimeCheck?.offset).source; - if (regexCheck) result.pattern = regexCheck.regex.source; return result; }; /** @since OAS 3.1: exclusive min/max are numbers */ export const depictNumber: Depicter = ( - { isInt, maxValue, minValue, _def: { checks } }: z.ZodNumber, + schema: $ZodNumber, { + // @todo consider using computed values provided by Zod instead numericRange = { integer: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], float: [-Number.MAX_VALUE, Number.MAX_VALUE], @@ -468,29 +498,55 @@ export const depictNumber: Depicter = ( integer: null, float: null, }; - const minCheck = checks.find((check) => check.kind === "min"); - const minimum = - minValue === null ? (isInt ? intRange?.[0] : floatRange?.[0]) : minValue; - const isMinInclusive = minCheck ? minCheck.inclusive : true; - const maxCheck = checks.find((check) => check.kind === "max"); - const maximum = - maxValue === null ? (isInt ? intRange?.[1] : floatRange?.[1]) : maxValue; - const isMaxInclusive = maxCheck ? maxCheck.inclusive : true; + let min = floatRange?.[0]; + let inclMin = true; + let max = floatRange?.[1]; + let inclMax = true; const result: SchemaObject = { - type: isInt ? "integer" : "number", - format: isInt ? "int64" : "double", + type: "number", + format: "double", }; - if (isMinInclusive) result.minimum = minimum; - else result.exclusiveMinimum = minimum; - if (isMaxInclusive) result.maximum = maximum; - else result.exclusiveMaximum = maximum; + const formatCast: Partial> = + { + safeint: "int64", + uint32: "int32", + float32: "float", + float64: "double", + }; + const { checks = [] } = schema._zod.def; + if (isCheck<$ZodNumberFormat>(schema, "number_format")) checks.push(schema); + for (const check of checks) { + if (isCheck<$ZodNumberFormat>(check, "number_format")) { + if (check._zod.def.format.includes("int")) { + result.type = "integer"; + min = intRange?.[0]; + max = intRange?.[1]; + inclMin = true; + inclMax = true; + } + result.format = + check._zod.def.format in formatCast + ? formatCast[check._zod.def.format] + : check._zod.def.format; + } + if (isCheck<$ZodCheckGreaterThan>(check, "greater_than")) { + min = Number(check._zod.def.value); + inclMin = check._zod.def.inclusive; + } + if (isCheck<$ZodCheckLessThan>(check, "less_than")) { + max = Number(check._zod.def.value); + inclMax = check._zod.def.inclusive; + } + } + result[inclMin ? "minimum" : "exclusiveMinimum"] = min; + result[inclMax ? "maximum" : "exclusiveMaximum"] = max; return result; }; export const depictObjectProperties = ( - { shape }: z.ZodObject, + { _zod: { def } }: $ZodObject, next: Parameters[1]["next"], -) => R.map(next, shape); +) => R.map(next, def.shape); const makeSample = (depicted: SchemaObject) => { const firstType = ( @@ -507,42 +563,42 @@ const makeNullableType = ({ return type ? [...new Set(type).add("null")] : "null"; }; -export const depictEffect: Depicter = ( - schema: z.ZodEffects, +export const depictPipeline: Depicter = ( + { _zod: { def } }: $ZodPipe, { isResponse, next }, ) => { - const input = next(schema.innerType()); - const { effect } = schema._def; - if (isResponse && effect.type === "transform" && isSchemaObject(input)) { - const outputType = getTransformedType(schema, makeSample(input)); - if (outputType && ["number", "string", "boolean"].includes(outputType)) - return { type: outputType as "number" | "string" | "boolean" }; - else return next(z.any()); - } - if (!isResponse && effect.type === "preprocess" && isSchemaObject(input)) { - const { type: inputType, ...rest } = input; - return { ...rest, format: `${rest.format || inputType} (preprocessed)` }; + const target = def[isResponse ? "out" : "in"]; + const opposite = def[isResponse ? "in" : "out"]; + if (target instanceof z.ZodTransform) { + const opposingDepiction = next(opposite); + if (isSchemaObject(opposingDepiction)) { + if (!isResponse) { + const { type: opposingType, ...rest } = opposingDepiction; + return { + ...rest, + format: `${rest.format || opposingType} (preprocessed)`, + }; + } else { + const targetType = getTransformedType( + target, + makeSample(opposingDepiction), + ); + if (targetType && ["number", "string", "boolean"].includes(targetType)) + return { type: targetType as "number" | "string" | "boolean" }; + else return next(z.any()); + } + } } - return input; + return next(target); }; -export const depictPipeline: Depicter = ( - { _def }: z.ZodPipeline, - { isResponse, next }, -) => next(_def[isResponse ? "out" : "in"]); - -export const depictBranded: Depicter = ( - schema: z.ZodBranded, - { next }, -) => next(schema.unwrap()); - export const depictLazy: Depicter = ( - lazy: z.ZodLazy, + { _zod: { def } }: $ZodLazy, { next, makeRef }, -): ReferenceObject => makeRef(lazy, () => next(lazy.schema)); +): ReferenceObject => makeRef(def.getter, () => next(def.getter())); -export const depictRaw: Depicter = (schema: RawSchema, { next }) => - next(schema.unwrap().shape.raw); +export const depictRaw: Depicter = ({ _zod: { def } }: RawSchema, { next }) => + next(def.shape.raw); const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => examples.length @@ -555,13 +611,15 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => : undefined; export const depictExamples = ( - schema: z.ZodTypeAny, + schema: z.ZodType, isResponse: boolean, omitProps: string[] = [], -): ExamplesObject | undefined => - R.pipe( +): ExamplesObject | undefined => { + const isObject = (subj: unknown): subj is FlatObject => + R.type(subj) === "Object"; + return R.pipe( getExamples, - R.map(R.when((subj) => R.type(subj) === "Object", R.omit(omitProps))), + R.map(R.when(isObject, R.omit(omitProps))), enumerateExamples, )({ schema, @@ -569,17 +627,21 @@ export const depictExamples = ( validate: true, pullProps: true, }); +}; export const depictParamExamples = ( - schema: z.ZodTypeAny, + schema: z.ZodType, param: string, -): ExamplesObject | undefined => - R.pipe( +): ExamplesObject | undefined => { + const isObject = (subj: unknown): subj is FlatObject => + R.type(subj) === "Object"; + return R.pipe( getExamples, - R.filter(R.has(param)), + R.filter(R.both(isObject, R.has(param))), R.pluck(param), enumerateExamples, )({ schema, variant: "original", validate: true, pullProps: true }); +}; export const defaultIsHeader = ( name: string, @@ -641,12 +703,11 @@ export const depictRequestParams = ({ composition === "components" ? makeRef(paramSchema, depicted, makeCleanId(description, name)) : depicted; - const { _def } = paramSchema as z.ZodType; return acc.concat({ name, in: location, - deprecated: _def[metaSymbol]?.isDeprecated, - required: !paramSchema.isOptional(), + deprecated: globalRegistry.get(paramSchema)?.[metaSymbol]?.isDeprecated, + required: !(paramSchema as z.ZodType).isOptional(), description: depicted.description || description, schema: result, examples: depictParamExamples(objectSchema, name), @@ -659,34 +720,30 @@ export const depictRequestParams = ({ export const depicters: HandlingRules< SchemaObject | ReferenceObject, OpenAPIContext, - z.ZodFirstPartyTypeKind | ProprietaryBrand + FirstPartyKind | ProprietaryBrand > = { - ZodString: depictString, - ZodNumber: depictNumber, - ZodBigInt: depictBigInt, - ZodBoolean: depictBoolean, - ZodNull: depictNull, - ZodArray: depictArray, - ZodTuple: depictTuple, - ZodRecord: depictRecord, - ZodObject: depictObject, - ZodLiteral: depictLiteral, - ZodIntersection: depictIntersection, - ZodUnion: depictUnion, - ZodAny: depictAny, - ZodDefault: depictDefault, - ZodEnum: depictEnum, - ZodNativeEnum: depictEnum, - ZodEffects: depictEffect, - ZodOptional: depictOptional, - ZodNullable: depictNullable, - ZodDiscriminatedUnion: depictDiscriminatedUnion, - ZodBranded: depictBranded, - ZodDate: depictDate, - ZodCatch: depictCatch, - ZodPipeline: depictPipeline, - ZodLazy: depictLazy, - ZodReadonly: depictReadonly, + string: depictString, + number: depictNumber, + bigint: depictBigInt, + boolean: depictBoolean, + null: depictNull, + array: depictArray, + tuple: depictTuple, + record: depictRecord, + object: depictObject, + literal: depictLiteral, + intersection: depictIntersection, + union: depictUnion, + any: depictAny, + default: depictDefault, + enum: depictEnum, + optional: depictWrapped, + nullable: depictNullable, + date: depictDate, + catch: depictWrapped, + pipe: depictPipeline, + lazy: depictLazy, + readonly: depictWrapped, [ezFileBrand]: depictFile, [ezUploadBrand]: depictUpload, [ezDateOutBrand]: depictDateOut, @@ -700,8 +757,9 @@ export const onEach: SchemaHandler< "each" > = (schema: z.ZodType, { isResponse, prev }) => { if (isReferenceObject(prev)) return {}; - const { description, _def } = schema; - const shouldAvoidParsing = schema instanceof z.ZodLazy; + const { description } = schema; + const shouldAvoidParsing = + schema instanceof z.ZodLazy || schema instanceof z.ZodPromise; const hasTypePropertyInDepiction = prev.type !== undefined; const isResponseHavingCoercion = isResponse && hasCoercion(schema); const isActuallyNullable = @@ -711,7 +769,7 @@ export const onEach: SchemaHandler< schema.isNullable(); const result: SchemaObject = {}; if (description) result.description = description; - if (_def[metaSymbol]?.isDeprecated) result.deprecated = true; + if (schema.meta()?.[metaSymbol]?.isDeprecated) result.deprecated = true; if (isActuallyNullable) result.type = makeNullableType(prev); if (!shouldAvoidParsing) { const examples = getExamples({ diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 5581f2fe3..448bdc43b 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -1,3 +1,4 @@ +import type { $ZodType } from "@zod/core"; import { OpenApiBuilder, OperationObject, @@ -8,7 +9,6 @@ import { SecuritySchemeType, } from "openapi3-ts/oas31"; import * as R from "ramda"; -import { z } from "zod"; import { responseVariants } from "./api-response"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; @@ -94,10 +94,10 @@ interface DocumentationParams { export class Documentation extends OpenApiBuilder { readonly #lastSecuritySchemaIds = new Map(); readonly #lastOperationIdSuffixes = new Map(); - readonly #references = new Map(); + readonly #references = new Map<$ZodType | (() => $ZodType), string>(); #makeRef( - schema: z.ZodTypeAny, + schema: $ZodType | (() => $ZodType), subject: | SchemaObject | ReferenceObject diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index 0c792c2e1..2de4458d8 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -18,7 +18,7 @@ import { interface BuildProps< IN extends IOSchema, OUT extends IOSchema | z.ZodVoid, - MIN extends IOSchema<"strip">, + MIN extends IOSchema, OPT extends FlatObject, SCO extends string, > { @@ -59,7 +59,7 @@ interface BuildProps< } export class EndpointsFactory< - IN extends IOSchema<"strip"> = EmptySchema, + IN extends IOSchema = EmptySchema, OUT extends FlatObject = EmptyObject, SCO extends string = string, > { @@ -67,7 +67,7 @@ export class EndpointsFactory< constructor(protected resultHandler: AbstractResultHandler) {} static #create< - CIN extends IOSchema<"strip">, + CIN extends IOSchema, COUT extends FlatObject, CSCO extends string, >(middlewares: AbstractMiddleware[], resultHandler: AbstractResultHandler) { @@ -79,7 +79,7 @@ export class EndpointsFactory< public addMiddleware< AOUT extends FlatObject, ASCO extends string, - AIN extends IOSchema<"strip"> = EmptySchema, + AIN extends IOSchema = EmptySchema, >( subject: | Middleware @@ -118,7 +118,7 @@ export class EndpointsFactory< } public build({ - input = z.object({}) as BIN, + input = z.object({}) as unknown as BIN, output: outputSchema, operationId, scope, diff --git a/express-zod-api/src/form-schema.ts b/express-zod-api/src/form-schema.ts index 33c45132e..ba19a9cbb 100644 --- a/express-zod-api/src/form-schema.ts +++ b/express-zod-api/src/form-schema.ts @@ -1,9 +1,10 @@ import { z } from "zod"; +import type { $ZodShape } from "@zod/core"; export const ezFormBrand = Symbol("Form"); /** @desc Accepts an object shape or a custom object schema */ -export const form = (base: S | z.ZodObject) => +export const form = (base: S | z.ZodObject) => (base instanceof z.ZodObject ? base : z.object(base)).brand( ezFormBrand as symbol, ); diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 2542cdca6..7ee6f5a8d 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -1,3 +1,4 @@ +import type { $ZodType } from "@zod/core"; import * as R from "ramda"; import ts from "typescript"; import { z } from "zod"; @@ -82,10 +83,16 @@ interface FormattedPrintingOptions { export class Integration extends IntegrationBase { readonly #program: ts.Node[] = [this.someOfType]; - readonly #aliases = new Map(); + readonly #aliases = new Map< + $ZodType | (() => $ZodType), + ts.TypeAliasDeclaration + >(); #usage: Array = []; - #makeAlias(schema: z.ZodTypeAny, produce: () => ts.TypeNode): ts.TypeNode { + #makeAlias( + schema: $ZodType | (() => $ZodType), + produce: () => ts.TypeNode, + ): ts.TypeNode { let name = this.#aliases.get(schema)?.name?.text; if (!name) { name = `Type${this.#aliases.size + 1}`; diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index c99bdf558..53bd80a92 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,34 +1,13 @@ import * as R from "ramda"; import { z } from "zod"; -import { FlatObject } from "./common-helpers"; -import { FormSchema } from "./form-schema"; +import { IOSchemaError } from "./errors"; import { copyMeta } from "./metadata"; import { AbstractMiddleware } from "./middleware"; -import { RawSchema } from "./raw-schema"; -type BaseObject = z.ZodObject; +type Base = object & { [Symbol.iterator]?: never }; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- workaround for TS2456, circular reference -interface ObjectBasedEffect - extends z.ZodEffects {} - -type EffectsChain = ObjectBasedEffect< - BaseObject | EffectsChain ->; - -/** - * @desc The type allowed on the top level of Middlewares and Endpoints - * @param U — only "strip" is allowed for Middlewares due to intersection issue (Zod) #600 - * */ -export type IOSchema = - | BaseObject // z.object() - | EffectsChain // z.object().refine(), z.object().transform(), z.object().preprocess() - | RawSchema // ez.raw() - | FormSchema // ez.form() - | z.ZodUnion<[IOSchema, ...IOSchema[]]> // z.object().or() - | z.ZodIntersection, IOSchema> // z.object().and() - | z.ZodDiscriminatedUnion[]> // z.discriminatedUnion() - | z.ZodPipeline>, BaseObject>; // z.object().remap() +/** @desc The type allowed on the top level of Middlewares and Endpoints */ +export type IOSchema = z.ZodType; /** * @description intersects input schemas of middlewares and the endpoint @@ -38,7 +17,7 @@ export type IOSchema = * @see copyMeta */ export const getFinalEndpointInputSchema = < - MIN extends IOSchema<"strip">, + MIN extends IOSchema, IN extends IOSchema, >( middlewares: AbstractMiddleware[], @@ -46,32 +25,31 @@ export const getFinalEndpointInputSchema = < ): z.ZodIntersection => { const allSchemas: IOSchema[] = R.pluck("schema", middlewares); allSchemas.push(input); - const finalSchema = allSchemas.reduce((acc, schema) => acc.and(schema)); + const finalSchema = allSchemas.reduce((acc, schema) => + z.intersection(acc, schema), + ); return allSchemas.reduce( (acc, schema) => copyMeta(schema, acc), finalSchema, ) as z.ZodIntersection; }; -export const extractObjectSchema = ( - subject: IOSchema, -): z.ZodObject => { +export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { if (subject instanceof z.ZodObject) return subject; - if (subject instanceof z.ZodBranded) - return extractObjectSchema(subject.unwrap()); if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion ) { - return subject.options - .map((option) => extractObjectSchema(option)) - .reduce((acc, option) => acc.merge(option.partial()), z.object({})); - } else if (subject instanceof z.ZodEffects) { - return extractObjectSchema(subject._def.schema); - } else if (subject instanceof z.ZodPipeline) { - return extractObjectSchema(subject._def.in); - } // intersection left: - return extractObjectSchema(subject._def.left).merge( - extractObjectSchema(subject._def.right), - ); + return subject._zod.def.options + .map((option) => extractObjectSchema(option as IOSchema)) + .reduce((acc, option) => acc.extend(option.partial()), z.object({})); + } + if (subject instanceof z.ZodPipe) + return extractObjectSchema(subject.in as IOSchema); + if (subject instanceof z.ZodIntersection) { + return extractObjectSchema(subject._zod.def.left as IOSchema).extend( + extractObjectSchema(subject._zod.def.right as IOSchema), + ); + } + throw new IOSchemaError("Can not flatten IOSchema", { cause: subject }); }; diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index bc37a6e0a..a0a67db2c 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -6,33 +6,31 @@ export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { examples: unknown[]; - /** @override ZodDefault::_def.defaultValue() in depictDefault */ + /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; isDeprecated?: boolean; } -/** @link https://github.com/colinhacks/zod/blob/3e4f71e857e75da722bd7e735b6d657a70682df2/src/types.ts#L485 */ -export const cloneSchema = (schema: T) => { - const copy = schema.describe(schema.description as string); - copy._def[metaSymbol] = // clone for deep copy, issue #827 - R.clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata); - return copy; -}; - export const copyMeta = ( src: A, dest: B, ): B => { - if (!(metaSymbol in src._def)) return dest; // ensure metadata in src below - const result = cloneSchema(dest); // ensures metadata in result below - result._def[metaSymbol]!.examples = combinations( - result._def[metaSymbol]!.examples, - src._def[metaSymbol]!.examples, - ([destExample, srcExample]) => - typeof destExample === "object" && typeof srcExample === "object" - ? R.mergeDeepRight({ ...destExample }, { ...srcExample }) - : srcExample, // not supposed to be called on non-object schemas - ); - return result; + const srcMeta = src.meta()?.[metaSymbol]; + const destMeta = dest.meta()?.[metaSymbol]; + if (!srcMeta) return dest; // ensure metadata in src below + return dest.meta({ + description: dest.description, + [metaSymbol]: { + ...destMeta, + examples: combinations( + destMeta?.examples || [], + srcMeta.examples || [], + ([destExample, srcExample]) => + typeof destExample === "object" && typeof srcExample === "object" + ? R.mergeDeepRight({ ...destExample }, { ...srcExample }) + : srcExample, // not supposed to be called on non-object schemas + ), + }, + }); }; diff --git a/express-zod-api/src/middleware.ts b/express-zod-api/src/middleware.ts index 796b66aef..866b2a4f0 100644 --- a/express-zod-api/src/middleware.ts +++ b/express-zod-api/src/middleware.ts @@ -27,7 +27,7 @@ export abstract class AbstractMiddleware { /** @internal */ public abstract get security(): LogicalContainer | undefined; /** @internal */ - public abstract get schema(): IOSchema<"strip">; + public abstract get schema(): IOSchema; public abstract execute(params: { input: unknown; options: FlatObject; @@ -41,7 +41,7 @@ export class Middleware< OPT extends FlatObject, OUT extends FlatObject, SCO extends string, - IN extends IOSchema<"strip"> = EmptySchema, + IN extends IOSchema = EmptySchema, > extends AbstractMiddleware { readonly #schema: IN; readonly #security?: LogicalContainer< @@ -50,7 +50,7 @@ export class Middleware< readonly #handler: Handler, OPT, OUT>; constructor({ - input = z.object({}) as IN, + input = z.object({}) as unknown as IN, security, handler, }: { diff --git a/express-zod-api/src/raw-schema.ts b/express-zod-api/src/raw-schema.ts index 4cf652256..5becaf798 100644 --- a/express-zod-api/src/raw-schema.ts +++ b/express-zod-api/src/raw-schema.ts @@ -1,17 +1,21 @@ import { z } from "zod"; +import type { $ZodShape } from "@zod/core"; import { file } from "./file-schema"; export const ezRawBrand = Symbol("Raw"); const base = z.object({ raw: file("buffer") }); +const extended = (extra: S) => + base.extend(extra).brand(ezRawBrand as symbol); + /** Shorthand for z.object({ raw: ez.file("buffer") }) */ -export function raw(): z.ZodBranded; -export function raw( +export function raw(): ReturnType>; +export function raw( extra: S, -): z.ZodBranded>, symbol>; -export function raw(extra?: z.ZodRawShape) { - return (extra ? base.extend(extra) : base).brand(ezRawBrand); +): ReturnType>; +export function raw(extra?: $ZodShape) { + return extra ? extended(extra) : base.brand(ezRawBrand as symbol); } export type RawSchema = ReturnType; diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index aaff7a64c..3cfe0c987 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -117,8 +117,8 @@ export const defaultResultHandler = new ResultHandler({ error: { message: "Sample error message" }, }), handler: ({ error, input, output, request, response, logger }) => { - if (error) { - const httpError = ensureHttpError(error); + if (error || !output) { + const httpError = ensureHttpError(error || new Error("Missing output")); logServerError(httpError, logger, request, input); return void response .status(httpError.statusCode) @@ -144,10 +144,10 @@ export const arrayResultHandler = new ResultHandler({ // Examples are taken for pulling down: no validation needed for this, no pulling up const examples = getExamples({ schema: output }); const responseSchema = - "shape" in output && + output instanceof z.ZodObject && "items" in output.shape && output.shape.items instanceof z.ZodArray - ? (output.shape.items as z.ZodArray) + ? output.shape.items : z.array(z.any()); return examples.reduce( (acc, example) => diff --git a/express-zod-api/src/routing.ts b/express-zod-api/src/routing.ts index 5694a8ac3..bc0b683c8 100644 --- a/express-zod-api/src/routing.ts +++ b/express-zod-api/src/routing.ts @@ -46,7 +46,7 @@ export const initRouting = ({ const familiar = new Map>(); const onEndpoint: OnEndpoint = (endpoint, path, method, siblingMethods) => { if (!isProduction()) { - doc?.checkJsonCompat(endpoint, { path, method }); + doc?.checkSchema(endpoint, { path, method }); doc?.checkPathParams(path, endpoint, { method }); } const matchingParsers = parsers?.[endpoint.requestType] || []; diff --git a/express-zod-api/src/schema-helpers.ts b/express-zod-api/src/schema-helpers.ts deleted file mode 100644 index 9d576ace7..000000000 --- a/express-zod-api/src/schema-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const isValidDate = (date: Date): boolean => !isNaN(date.getTime()); diff --git a/express-zod-api/src/schema-walker.ts b/express-zod-api/src/schema-walker.ts index b803f12c5..3ba4535fe 100644 --- a/express-zod-api/src/schema-walker.ts +++ b/express-zod-api/src/schema-walker.ts @@ -1,9 +1,12 @@ -import { z } from "zod"; +import type { $ZodType, $ZodTypeDef } from "@zod/core"; +import { globalRegistry } from "zod"; import type { EmptyObject, FlatObject } from "./common-helpers"; import { metaSymbol } from "./metadata"; +export type FirstPartyKind = $ZodTypeDef["type"]; + export interface NextHandlerInc { - next: (schema: z.ZodTypeAny) => U; + next: (schema: $ZodType) => U; } interface PrevInc { @@ -35,7 +38,7 @@ export const walkSchema = < U extends object, Context extends FlatObject = EmptyObject, >( - schema: z.ZodType, + schema: $ZodType, { onEach, rules, @@ -48,11 +51,12 @@ export const walkSchema = < onMissing: SchemaHandler; }, ): U => { + const brand = globalRegistry.get(schema)?.[metaSymbol]?.brand; const handler = - rules[schema._def[metaSymbol]?.brand as keyof typeof rules] || - ("typeName" in schema._def && - rules[schema._def.typeName as keyof typeof rules]); - const next = (subject: z.ZodTypeAny) => + brand && brand in rules + ? rules[brand as keyof typeof rules] + : rules[schema._zod.def.type]; + const next = (subject: $ZodType) => walkSchema(subject, { ctx, onEach, rules, onMissing }); const result = handler ? handler(schema, { ...ctx, next }) diff --git a/express-zod-api/src/typescript-api.ts b/express-zod-api/src/typescript-api.ts index 93159a818..5b45ef980 100644 --- a/express-zod-api/src/typescript-api.ts +++ b/express-zod-api/src/typescript-api.ts @@ -381,9 +381,10 @@ export const makeFnType = ( ); /* eslint-disable prettier/prettier -- shorter and works better this way than overrides */ -export const literally = (subj: T) => ( - typeof subj === "number" ? f.createNumericLiteral(subj) : typeof subj === "boolean" - ? subj ? f.createTrue() : f.createFalse() +export const literally = (subj: T) => ( + typeof subj === "number" ? f.createNumericLiteral(subj) + : typeof subj === "bigint" ? f.createBigIntLiteral(subj.toString()) + : typeof subj === "boolean" ? subj ? f.createTrue() : f.createFalse() : subj === null ? f.createNull() : f.createStringLiteral(subj) ) as T extends string ? ts.StringLiteral : T extends number ? ts.NumericLiteral : T extends boolean ? ts.BooleanLiteral : ts.NullLiteral; diff --git a/express-zod-api/src/upload-schema.ts b/express-zod-api/src/upload-schema.ts index aa94ccb7b..e6e5d634d 100644 --- a/express-zod-api/src/upload-schema.ts +++ b/express-zod-api/src/upload-schema.ts @@ -27,9 +27,11 @@ export const upload = () => typeof subject.size === "number" && typeof subject.md5 === "string" && typeof subject.mv === "function", - (input) => ({ - message: `Expected file upload, received ${typeof input}`, - }), + { + error: ({ input }) => ({ + message: `Expected file upload, received ${typeof input}`, + }), + }, ) .brand(ezUploadBrand as symbol); diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 3b9887916..5ea7901a4 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -8,79 +8,94 @@ * @desc Stores the argument supplied to .brand() on all schema (runtime distinguishable branded types) * */ import * as R from "ramda"; -import { z } from "zod"; +import { z, globalRegistry } from "zod"; import { FlatObject } from "./common-helpers"; -import { cloneSchema, Metadata, metaSymbol } from "./metadata"; +import { Metadata, metaSymbol } from "./metadata"; import { Intact, Remap } from "./mapping-helpers"; +import type { $ZodType, $ZodShape } from "@zod/core"; -declare module "zod" { - interface ZodTypeDef { +declare module "@zod/core" { + interface GlobalMeta { [metaSymbol]?: Metadata; } +} + +declare module "zod" { interface ZodType { /** @desc Add an example value (before any transformations, can be called multiple times) */ - example(example: this["_input"]): this; + example(example: z.input): this; deprecated(): this; } - interface ZodDefault { + interface ZodDefault extends ZodType { /** @desc Change the default value in the generated Documentation to a label */ label(label: string): this; } interface ZodObject< - T extends z.ZodRawShape, - UnknownKeys extends z.UnknownKeysParam = z.UnknownKeysParam, - Catchall extends z.ZodTypeAny = z.ZodTypeAny, - Output = z.objectOutputType, - Input = z.objectInputType, - > { - remap( + // @ts-expect-error -- external issue + out Shape extends $ZodShape = $ZodShape, + Extra extends Record = Record, + > extends ZodType { + remap( mapping: U, - ): z.ZodPipeline< - z.ZodEffects, // internal type simplified - z.ZodObject & Intact, UnknownKeys> + ): z.ZodPipe< + z.ZodPipe< + this, + z.ZodTransform // internal type simplified + >, + z.ZodObject & Intact, Extra> + >; + remap( + mapper: (subject: Shape) => U, + ): z.ZodPipe< + z.ZodPipe>, // internal type simplified + z.ZodObject >; - remap( - mapper: (subject: T) => U, - ): z.ZodPipeline, z.ZodObject>; // internal type simplified } } -const exampleSetter = function ( - this: z.ZodType, - value: (typeof this)["_input"], -) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.examples.push(value); - return copy; +const exampleSetter = function (this: z.ZodType, value: z.input) { + const { examples, ...rest } = this.meta()?.[metaSymbol] || { examples: [] }; + const copy = examples.slice(); + copy.push(value); + return this.meta({ + description: this.description, + [metaSymbol]: { ...rest, examples: copy }, + }); }; const deprecationSetter = function (this: z.ZodType) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.isDeprecated = true; - return copy; + return this.meta({ + description: this.description, + [metaSymbol]: { + examples: [], + ...this.meta()?.[metaSymbol], + isDeprecated: true, + }, + }); }; -const labelSetter = function (this: z.ZodDefault, label: string) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.defaultLabel = label; - return copy; +const labelSetter = function ( + this: z.ZodDefault, + defaultLabel: string, +) { + return this.meta({ + description: this.description, + [metaSymbol]: { examples: [], ...this.meta()?.[metaSymbol], defaultLabel }, + }); }; const brandSetter = function ( this: z.ZodType, brand?: string | number | symbol, ) { - return new z.ZodBranded({ - typeName: z.ZodFirstPartyTypeKind.ZodBranded, - type: this, - description: this._def.description, - errorMap: this._def.errorMap, - [metaSymbol]: { examples: [], ...R.clone(this._def[metaSymbol]), brand }, + return this.meta({ + description: this.description, + [metaSymbol]: { examples: [], ...this.meta()?.[metaSymbol], brand }, }); }; const objectMapper = function ( - this: z.ZodObject, + this: z.ZodObject, tool: | Record | ((subject: T) => { [P in string | keyof T]: T[keyof T] }), @@ -93,31 +108,56 @@ const objectMapper = function ( R.map(([key, value]) => R.pair(tool[String(key)] || key, value)), R.fromPairs, ); - const nextShape = transformer(R.clone(this.shape)); // immutable - const output = z.object(nextShape)[this._def.unknownKeys](); // proxies unknown keys when set to "passthrough" + const nextShape = transformer(R.clone(this._zod.def.shape)); // immutable + const hasPassThrough = this._zod.def.catchall instanceof z.ZodUnknown; + const output = (hasPassThrough ? z.looseObject : z.object)(nextShape); // proxies unknown keys when set to "passthrough" + // @ts-expect-error -- ignoring inconsistency of Extra type return this.transform(transformer).pipe(output); }; if (!(metaSymbol in globalThis)) { (globalThis as Record)[metaSymbol] = true; - Object.defineProperties(z.ZodType.prototype, { - ["example" satisfies keyof z.ZodType]: { - get(): z.ZodType["example"] { - return exampleSetter.bind(this); + for (const entry of Object.keys(z)) { + if (!entry.startsWith("Zod")) continue; + const Cls = z[entry as keyof typeof z]; + if (typeof Cls !== "function") continue; + let originalCheck: z.ZodType["check"]; + Object.defineProperties(Cls.prototype, { + ["example" satisfies keyof z.ZodType]: { + get(): z.ZodType["example"] { + return exampleSetter.bind(this); + }, }, - }, - ["deprecated" satisfies keyof z.ZodType]: { - get(): z.ZodType["deprecated"] { - return deprecationSetter.bind(this); + ["deprecated" satisfies keyof z.ZodType]: { + get(): z.ZodType["deprecated"] { + return deprecationSetter.bind(this); + }, }, - }, - ["brand" satisfies keyof z.ZodType]: { - set() {}, // this is required to override the existing method - get() { - return brandSetter.bind(this) as z.ZodType["brand"]; + ["brand" satisfies keyof z.ZodType]: { + set() {}, // this is required to override the existing method + get() { + return brandSetter.bind(this) as z.ZodType["brand"]; + }, }, - }, - }); + ["check" satisfies keyof z.ZodType]: { + set(fn) { + originalCheck = fn; + }, + get(): z.ZodType["check"] { + return function ( + this: z.ZodType, + ...args: Parameters + ) { + /** @link https://v4.zod.dev/metadata#register */ + return originalCheck.apply(this, args).register(globalRegistry, { + [metaSymbol]: this.meta()?.[metaSymbol], + }); + }; + }, + }, + }); + } + Object.defineProperty( z.ZodDefault.prototype, "label" satisfies keyof z.ZodDefault, @@ -129,10 +169,10 @@ if (!(metaSymbol in globalThis)) { ); Object.defineProperty( z.ZodObject.prototype, - "remap" satisfies keyof z.ZodObject, + "remap" satisfies keyof z.ZodObject, { get() { - return objectMapper.bind(this) as z.ZodObject["remap"]; + return objectMapper.bind(this) as unknown as z.ZodObject["remap"]; }, }, ); diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index d5fc624ea..4faba4ebf 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -1,13 +1,14 @@ +import type { $ZodType } from "@zod/core"; import type ts from "typescript"; -import { z } from "zod"; import { FlatObject } from "./common-helpers"; import { SchemaHandler } from "./schema-walker"; -export type LiteralType = string | number | boolean; - export interface ZTSContext extends FlatObject { isResponse: boolean; - makeAlias: (schema: z.ZodTypeAny, produce: () => ts.TypeNode) => ts.TypeNode; + makeAlias: ( + schema: $ZodType | (() => $ZodType), + produce: () => ts.TypeNode, + ) => ts.TypeNode; optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index d86bfd360..867e889d1 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -1,6 +1,23 @@ +import type { + $ZodArray, + $ZodCatch, + $ZodDefault, + $ZodDiscriminatedUnion, + $ZodEnum, + $ZodIntersection, + $ZodLazy, + $ZodLiteral, + $ZodNullable, + $ZodOptional, + $ZodPipe, + $ZodReadonly, + $ZodRecord, + $ZodTuple, + $ZodUnion, +} from "@zod/core"; import * as R from "ramda"; import ts from "typescript"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { hasCoercion, getTransformedType } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; @@ -8,14 +25,14 @@ import { ezFileBrand, FileSchema } from "./file-schema"; import { metaSymbol } from "./metadata"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand, RawSchema } from "./raw-schema"; -import { HandlingRules, walkSchema } from "./schema-walker"; +import { FirstPartyKind, HandlingRules, walkSchema } from "./schema-walker"; import { ensureTypeNode, isPrimitive, makeInterfaceProp, makeLiteralType, } from "./typescript-api"; -import { LiteralType, Producer, ZTSContext } from "./zts-helpers"; +import { Producer, ZTSContext } from "./zts-helpers"; const { factory: f } = ts; @@ -41,48 +58,54 @@ const nodePath = { optional: R.path(["questionToken" satisfies keyof ts.TypeElement]), }; -const onLiteral: Producer = ({ value }: z.ZodLiteral) => - makeLiteralType(value); +const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => { + const values = def.values.map((entry) => + entry === undefined + ? ensureTypeNode(ts.SyntaxKind.UndefinedKeyword) + : makeLiteralType(entry), + ); + return values.length === 1 ? values[0] : f.createUnionTypeNode(values); +}; const onObject: Producer = ( - { shape }: z.ZodObject, + { _zod: { def } }: z.ZodObject, { isResponse, next, optionalPropStyle: { withQuestionMark: hasQuestionMark }, }, ) => { - const members = Object.entries(shape).map(([key, value]) => { - const { description: comment, _def } = value as z.ZodType; - const isOptional = - isResponse && hasCoercion(value) - ? value instanceof z.ZodOptional - : value.isOptional(); - return makeInterfaceProp(key, next(value), { - comment, - isOptional: isOptional && hasQuestionMark, - isDeprecated: _def[metaSymbol]?.isDeprecated, - }); - }); + const members = Object.entries(def.shape).map( + ([key, value]) => { + const isOptional = + isResponse && hasCoercion(value) + ? value instanceof z.ZodOptional + : value instanceof z.ZodPromise + ? false + : (value as z.ZodType).isOptional(); + const { description: comment, ...meta } = globalRegistry.get(value) || {}; + return makeInterfaceProp(key, next(value), { + comment, + isOptional: isOptional && hasQuestionMark, + isDeprecated: meta[metaSymbol]?.isDeprecated, + }); + }, + ); return f.createTypeLiteralNode(members); }; -const onArray: Producer = ({ element }: z.ZodArray, { next }) => - f.createArrayTypeNode(next(element)); +const onArray: Producer = ({ _zod: { def } }: $ZodArray, { next }) => + f.createArrayTypeNode(next(def.element)); -const onEnum: Producer = ({ options }: z.ZodEnum<[string, ...string[]]>) => - f.createUnionTypeNode(options.map(makeLiteralType)); +const onEnum: Producer = ({ _zod: { def } }: $ZodEnum) => + f.createUnionTypeNode(Object.values(def.entries).map(makeLiteralType)); const onSomeUnion: Producer = ( - { - options, - }: - | z.ZodUnion - | z.ZodDiscriminatedUnion[]>, + { _zod: { def } }: $ZodUnion | $ZodDiscriminatedUnion, { next }, ) => { const nodes = new Map(); - for (const option of options) { + for (const option of def.options) { const node = next(option); nodes.set(isPrimitive(node) ? node.kind : node, node); } @@ -92,38 +115,11 @@ const onSomeUnion: Producer = ( const makeSample = (produced: ts.TypeNode) => samples?.[produced.kind as keyof typeof samples]; -const onEffects: Producer = ( - schema: z.ZodEffects, - { next, isResponse }, -) => { - const input = next(schema.innerType()); - if (isResponse && schema._def.effect.type === "transform") { - const outputType = getTransformedType(schema, makeSample(input)); - const resolutions: Partial< - Record, ts.KeywordTypeSyntaxKind> - > = { - number: ts.SyntaxKind.NumberKeyword, - bigint: ts.SyntaxKind.BigIntKeyword, - boolean: ts.SyntaxKind.BooleanKeyword, - string: ts.SyntaxKind.StringKeyword, - undefined: ts.SyntaxKind.UndefinedKeyword, - object: ts.SyntaxKind.ObjectKeyword, - }; - return ensureTypeNode( - (outputType && resolutions[outputType]) || ts.SyntaxKind.AnyKeyword, - ); - } - return input; -}; - -const onNativeEnum: Producer = (schema: z.ZodNativeEnum) => - f.createUnionTypeNode(Object.values(schema.enum).map(makeLiteralType)); - const onOptional: Producer = ( - schema: z.ZodOptional, + { _zod: { def } }: $ZodOptional, { next, optionalPropStyle: { withUndefined: hasUndefined } }, ) => { - const actualTypeNode = next(schema.unwrap()); + const actualTypeNode = next(def.innerType); return hasUndefined ? f.createUnionTypeNode([ actualTypeNode, @@ -132,23 +128,18 @@ const onOptional: Producer = ( : actualTypeNode; }; -const onNullable: Producer = (schema: z.ZodNullable, { next }) => - f.createUnionTypeNode([next(schema.unwrap()), makeLiteralType(null)]); +const onNullable: Producer = ({ _zod: { def } }: $ZodNullable, { next }) => + f.createUnionTypeNode([next(def.innerType), makeLiteralType(null)]); -const onTuple: Producer = ( - { items, _def: { rest } }: z.AnyZodTuple, - { next }, -) => +const onTuple: Producer = ({ _zod: { def } }: $ZodTuple, { next }) => f.createTupleTypeNode( - items + def.items .map(next) - .concat(rest === null ? [] : f.createRestTypeNode(next(rest))), + .concat(def.rest === null ? [] : f.createRestTypeNode(next(def.rest))), ); -const onRecord: Producer = ( - { keySchema, valueSchema }: z.ZodRecord, - { next }, -) => ensureTypeNode("Record", [keySchema, valueSchema].map(next)); +const onRecord: Producer = ({ _zod: { def } }: $ZodRecord, { next }) => + ensureTypeNode("Record", [def.keyType, def.valueType].map(next)); const intersect = R.tryCatch( (nodes: ts.TypeNode[]) => { @@ -166,87 +157,94 @@ const intersect = R.tryCatch( ); const onIntersection: Producer = ( - { _def: { left, right } }: z.ZodIntersection, + { _zod: { def } }: $ZodIntersection, { next }, -) => intersect([left, right].map(next)); - -const onDefault: Producer = ({ _def }: z.ZodDefault, { next }) => - next(_def.innerType); +) => intersect([def.left, def.right].map(next)); const onPrimitive = (syntaxKind: ts.KeywordTypeSyntaxKind): Producer => () => ensureTypeNode(syntaxKind); -const onBranded: Producer = ( - schema: z.ZodBranded, +const onWrapped: Producer = ( + { _zod: { def } }: $ZodReadonly | $ZodCatch | $ZodDefault, { next }, -) => next(schema.unwrap()); - -const onReadonly: Producer = (schema: z.ZodReadonly, { next }) => - next(schema.unwrap()); - -const onCatch: Producer = ({ _def }: z.ZodCatch, { next }) => - next(_def.innerType); +) => next(def.innerType); const onPipeline: Producer = ( - { _def }: z.ZodPipeline, + { _zod: { def } }: $ZodPipe, { next, isResponse }, -) => next(_def[isResponse ? "out" : "in"]); +) => { + const target = def[isResponse ? "out" : "in"]; + const opposite = def[isResponse ? "in" : "out"]; + if (target instanceof z.ZodTransform) { + const opposingType = next(opposite); + const targetType = getTransformedType(target, makeSample(opposingType)); + const resolutions: Partial< + Record, ts.KeywordTypeSyntaxKind> + > = { + number: ts.SyntaxKind.NumberKeyword, + bigint: ts.SyntaxKind.BigIntKeyword, + boolean: ts.SyntaxKind.BooleanKeyword, + string: ts.SyntaxKind.StringKeyword, + undefined: ts.SyntaxKind.UndefinedKeyword, + object: ts.SyntaxKind.ObjectKeyword, + }; + return ensureTypeNode( + (targetType && resolutions[targetType]) || ts.SyntaxKind.AnyKeyword, + ); + } + return next(target); +}; const onNull: Producer = () => makeLiteralType(null); -const onLazy: Producer = (lazy: z.ZodLazy, { makeAlias, next }) => - makeAlias(lazy, () => next(lazy.schema)); +const onLazy: Producer = ({ _zod: { def } }: $ZodLazy, { makeAlias, next }) => + makeAlias(def.getter, () => next(def.getter())); const onFile: Producer = (schema: FileSchema) => { - const subject = schema.unwrap(); const stringType = ensureTypeNode(ts.SyntaxKind.StringKeyword); const bufferType = ensureTypeNode("Buffer"); const unionType = f.createUnionTypeNode([stringType, bufferType]); - return subject instanceof z.ZodString + return schema instanceof z.ZodString ? stringType - : subject instanceof z.ZodUnion + : schema instanceof z.ZodUnion ? unionType : bufferType; }; const onRaw: Producer = (schema: RawSchema, { next }) => - next(schema.unwrap().shape.raw); + next(schema._zod.def.shape.raw); const producers: HandlingRules< ts.TypeNode, ZTSContext, - z.ZodFirstPartyTypeKind | ProprietaryBrand + FirstPartyKind | ProprietaryBrand > = { - ZodString: onPrimitive(ts.SyntaxKind.StringKeyword), - ZodNumber: onPrimitive(ts.SyntaxKind.NumberKeyword), - ZodBigInt: onPrimitive(ts.SyntaxKind.BigIntKeyword), - ZodBoolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), - ZodAny: onPrimitive(ts.SyntaxKind.AnyKeyword), - ZodUndefined: onPrimitive(ts.SyntaxKind.UndefinedKeyword), + string: onPrimitive(ts.SyntaxKind.StringKeyword), + number: onPrimitive(ts.SyntaxKind.NumberKeyword), + bigint: onPrimitive(ts.SyntaxKind.BigIntKeyword), + boolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), + any: onPrimitive(ts.SyntaxKind.AnyKeyword), + undefined: onPrimitive(ts.SyntaxKind.UndefinedKeyword), [ezDateInBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), [ezDateOutBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), - ZodNull: onNull, - ZodArray: onArray, - ZodTuple: onTuple, - ZodRecord: onRecord, - ZodObject: onObject, - ZodLiteral: onLiteral, - ZodIntersection: onIntersection, - ZodUnion: onSomeUnion, - ZodDefault: onDefault, - ZodEnum: onEnum, - ZodNativeEnum: onNativeEnum, - ZodEffects: onEffects, - ZodOptional: onOptional, - ZodNullable: onNullable, - ZodDiscriminatedUnion: onSomeUnion, - ZodBranded: onBranded, - ZodCatch: onCatch, - ZodPipeline: onPipeline, - ZodLazy: onLazy, - ZodReadonly: onReadonly, + null: onNull, + array: onArray, + tuple: onTuple, + record: onRecord, + object: onObject, + literal: onLiteral, + intersection: onIntersection, + union: onSomeUnion, + default: onWrapped, + enum: onEnum, + optional: onOptional, + nullable: onNullable, + catch: onWrapped, + pipe: onPipeline, + lazy: onLazy, + readonly: onWrapped, [ezFileBrand]: onFile, [ezRawBrand]: onRaw, }; diff --git a/express-zod-api/tests/__snapshots__/date-in-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/date-in-schema.spec.ts.snap index 21437c034..3f4cc71b0 100644 --- a/express-zod-api/tests/__snapshots__/date-in-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/date-in-schema.spec.ts.snap @@ -3,10 +3,41 @@ exports[`ez.dateIn() > parsing > should handle invalid date 1`] = ` [ { - "code": "invalid_string", - "message": "Invalid date", + "code": "invalid_union", + "errors": [ + [ + { + "code": "invalid_format", + "format": "date", + "message": "Invalid ISO date", + "origin": "string", + "path": [], + "pattern": "/^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))$/", + }, + ], + [ + { + "code": "invalid_format", + "format": "datetime", + "message": "Invalid ISO datetime", + "origin": "string", + "path": [], + "pattern": "/^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$/", + }, + ], + [ + { + "code": "invalid_format", + "format": "datetime", + "message": "Invalid ISO datetime", + "origin": "string", + "path": [], + "pattern": "/^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z?)$/", + }, + ], + ], + "message": "Invalid input", "path": [], - "validation": "date", }, ] `; @@ -14,10 +45,41 @@ exports[`ez.dateIn() > parsing > should handle invalid date 1`] = ` exports[`ez.dateIn() > parsing > should handle invalid format 1`] = ` [ { - "code": "invalid_string", - "message": "Invalid date", + "code": "invalid_union", + "errors": [ + [ + { + "code": "invalid_format", + "format": "date", + "message": "Invalid ISO date", + "origin": "string", + "path": [], + "pattern": "/^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))$/", + }, + ], + [ + { + "code": "invalid_format", + "format": "datetime", + "message": "Invalid ISO datetime", + "origin": "string", + "path": [], + "pattern": "/^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$/", + }, + ], + [ + { + "code": "invalid_format", + "format": "datetime", + "message": "Invalid ISO datetime", + "origin": "string", + "path": [], + "pattern": "/^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z?)$/", + }, + ], + ], + "message": "Invalid input", "path": [], - "validation": "date", }, ] `; @@ -26,43 +88,34 @@ exports[`ez.dateIn() > parsing > should handle wrong parsed type 1`] = ` [ { "code": "invalid_union", + "errors": [ + [ + { + "code": "invalid_type", + "expected": "string", + "message": "Invalid input: expected string, received number", + "path": [], + }, + ], + [ + { + "code": "invalid_type", + "expected": "string", + "message": "Invalid input: expected string, received number", + "path": [], + }, + ], + [ + { + "code": "invalid_type", + "expected": "string", + "message": "Invalid input: expected string, received number", + "path": [], + }, + ], + ], "message": "Invalid input", "path": [], - "unionErrors": [ - ZodError({ - "message": "[ - { - "code": "invalid_type", - "expected": "string", - "received": "number", - "path": [], - "message": "Expected string, received number" - } -]", - }), - ZodError({ - "message": "[ - { - "code": "invalid_type", - "expected": "string", - "received": "number", - "path": [], - "message": "Expected string, received number" - } -]", - }), - ZodError({ - "message": "[ - { - "code": "invalid_type", - "expected": "string", - "received": "number", - "path": [], - "message": "Expected string, received number" - } -]", - }), - ], }, ] `; 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 4780a828d..7342060b1 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -80,19 +80,6 @@ exports[`Documentation helpers > depictBoolean() > should set type:boolean 1`] = } `; -exports[`Documentation helpers > depictBranded > should pass the next depicter 1`] = ` -{ - "minLength": 2, - "type": "string", -} -`; - -exports[`Documentation helpers > depictCatch() > should pass next depicter 1`] = ` -{ - "type": "boolean", -} -`; - exports[`Documentation helpers > depictDate > should throw clear error 0 1`] = ` DocumentationError({ "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", @@ -160,99 +147,6 @@ exports[`Documentation helpers > depictDefault() > should set default property 1 } `; -exports[`Documentation helpers > depictDiscriminatedUnion() > should wrap next depicters in oneOf prop and set discriminator prop 1`] = ` -{ - "discriminator": { - "propertyName": "status", - }, - "oneOf": [ - { - "properties": { - "data": { - "format": "any", - }, - "status": { - "const": "success", - "type": "string", - }, - }, - "required": [ - "status", - ], - "type": "object", - }, - { - "properties": { - "error": { - "properties": { - "message": { - "type": "string", - }, - }, - "required": [ - "message", - ], - "type": "object", - }, - "status": { - "const": "error", - "type": "string", - }, - }, - "required": [ - "status", - "error", - ], - "type": "object", - }, - ], -} -`; - -exports[`Documentation helpers > depictEffect() > should depict as 'number (out)' 1`] = ` -{ - "type": "number", -} -`; - -exports[`Documentation helpers > depictEffect() > should depict as 'object (refinement)' 1`] = ` -{ - "properties": { - "s": { - "type": "string", - }, - }, - "required": [ - "s", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictEffect() > should depict as 'string (in)' 1`] = ` -{ - "type": "string", -} -`; - -exports[`Documentation helpers > depictEffect() > should depict as 'string (preprocess)' 1`] = ` -{ - "format": "string (preprocessed)", -} -`; - -exports[`Documentation helpers > depictEffect() > should handle edge cases 1`] = ` -{ - "format": "any", -} -`; - -exports[`Documentation helpers > depictEffect() > should handle edge cases 2`] = ` -{ - "format": "any", -} -`; - exports[`Documentation helpers > depictEnum() > should set type and enum properties 1`] = ` { "enum": [ @@ -579,6 +473,17 @@ exports[`Documentation helpers > depictLazy > should handle circular references } `; +exports[`Documentation helpers > depictLiteral() > should handle multiple values 1`] = ` +{ + "enum": [ + 1, + 2, + 3, + ], + "type": "number", +} +`; + exports[`Documentation helpers > depictLiteral() > should set type and involve const property 0 1`] = ` { "const": "testng", @@ -602,7 +507,7 @@ exports[`Documentation helpers > depictLiteral() > should set type and involve c exports[`Documentation helpers > depictLiteral() > should set type and involve const property 3 1`] = ` { - "const": Symbol(test), + "const": undefined, "type": undefined, } `; @@ -682,6 +587,24 @@ exports[`Documentation helpers > depictNumber() > should set min/max values acco } `; +exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 2 1`] = ` +{ + "format": "double", + "maximum": 1.7976931348623157e+308, + "minimum": -1.7976931348623157e+308, + "type": "number", +} +`; + +exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 3 1`] = ` +{ + "format": "hacked", + "maximum": 1.7976931348623157e+308, + "minimum": -1.7976931348623157e+308, + "type": "number", +} +`; + exports[`Documentation helpers > depictNumber() > should use numericRange when set 0 1`] = ` { "format": "double", @@ -874,18 +797,6 @@ exports[`Documentation helpers > depictObjectProperties() > should wrap next dep } `; -exports[`Documentation helpers > depictOptional() > should pass the next depicter 0 1`] = ` -{ - "type": "string", -} -`; - -exports[`Documentation helpers > depictOptional() > should pass the next depicter 1 1`] = ` -{ - "type": "string", -} -`; - exports[`Documentation helpers > depictParamExamples() > should pass examples for the given parameter 1`] = ` { "example1": { @@ -903,21 +814,45 @@ exports[`Documentation helpers > depictPipeline > should depict as 'boolean (out } `; +exports[`Documentation helpers > depictPipeline > should depict as 'number (out)' 1`] = ` +{ + "type": "number", +} +`; + exports[`Documentation helpers > depictPipeline > should depict as 'string (in)' 1`] = ` { "type": "string", } `; -exports[`Documentation helpers > depictRaw() > should depict the raw property 1`] = ` +exports[`Documentation helpers > depictPipeline > should depict as 'string (in)' 2`] = ` { - "format": "binary", "type": "string", } `; -exports[`Documentation helpers > depictReadonly > should pass the next depicter 1`] = ` +exports[`Documentation helpers > depictPipeline > should depict as 'string (preprocess)' 1`] = ` +{ + "format": "string (preprocessed)", +} +`; + +exports[`Documentation helpers > depictPipeline > should handle edge cases 1`] = ` { + "format": "any", +} +`; + +exports[`Documentation helpers > depictPipeline > should handle edge cases 2`] = ` +{ + "format": "any", +} +`; + +exports[`Documentation helpers > depictRaw() > should depict the raw property 1`] = ` +{ + "format": "binary", "type": "string", } `; @@ -928,7 +863,10 @@ exports[`Documentation helpers > depictRecord() > should set properties+required "type": "boolean", }, "propertyNames": { - "type": "string", + "format": "int64", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", }, "type": "object", } @@ -1537,6 +1475,55 @@ exports[`Documentation helpers > depictTuple() > should utilize prefixItems and } `; +exports[`Documentation helpers > depictUnion() > should wrap next depicters in oneOf prop and set discriminator prop 1`] = ` +{ + "discriminator": { + "propertyName": "status", + }, + "oneOf": [ + { + "properties": { + "data": { + "format": "any", + }, + "status": { + "const": "success", + "type": "string", + }, + }, + "required": [ + "status", + ], + "type": "object", + }, + { + "properties": { + "error": { + "properties": { + "message": { + "type": "string", + }, + }, + "required": [ + "message", + ], + "type": "object", + }, + "status": { + "const": "error", + "type": "string", + }, + }, + "required": [ + "status", + "error", + ], + "type": "object", + }, + ], +} +`; + exports[`Documentation helpers > depictUnion() > should wrap next depicters into oneOf property 1`] = ` { "oneOf": [ @@ -1567,6 +1554,30 @@ DocumentationError({ }) `; +exports[`Documentation helpers > depictWrapped() > handle readonly 1`] = ` +{ + "type": "string", +} +`; + +exports[`Documentation helpers > depictWrapped() > should handle catch 1`] = ` +{ + "type": "boolean", +} +`; + +exports[`Documentation helpers > depictWrapped() > should handle optional 0 1`] = ` +{ + "type": "string", +} +`; + +exports[`Documentation helpers > depictWrapped() > should handle optional 1 1`] = ` +{ + "type": "string", +} +`; + exports[`Documentation helpers > excludeExamplesFromDepiction() > should remove example property of supplied object 1`] = ` { "description": "test", diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index b1d151483..0d466c27a 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -718,8 +718,6 @@ paths: content: application/json: schema: - discriminator: - propertyName: type oneOf: - type: object properties: @@ -741,6 +739,8 @@ paths: required: - type - b + discriminator: + propertyName: type required: true responses: "200": @@ -754,8 +754,6 @@ paths: type: string const: success data: - discriminator: - propertyName: status oneOf: - type: object properties: @@ -781,6 +779,8 @@ paths: required: - status - error + discriminator: + propertyName: status required: - status - data @@ -1603,10 +1603,10 @@ paths: pattern: \\d+ combined: type: string - format: email minLength: 1 - maxLength: 90 + format: email pattern: .*@example\\.com + maxLength: 90 required: - regular - min diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 597fda663..667dbd891 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -21,21 +21,27 @@ exports[`Endpoint > .getResponses() > should return the negative responses (read "application/json", ], "schema": { - "_type": "ZodObject", - "shape": { + "properties": { "error": { - "_type": "ZodObject", - "shape": { + "properties": { "message": { - "_type": "ZodString", + "type": "string", }, }, + "required": [ + "message", + ], + "type": "object", }, "status": { - "_type": "ZodLiteral", - "value": "error", + "const": "error", }, }, + "required": [ + "status", + "error", + ], + "type": "object", }, "statusCodes": [ 400, @@ -51,21 +57,27 @@ exports[`Endpoint > .getResponses() > should return the positive responses (read "application/json", ], "schema": { - "_type": "ZodObject", - "shape": { + "properties": { "data": { - "_type": "ZodObject", - "shape": { + "properties": { "something": { - "_type": "ZodNumber", + "type": "number", }, }, + "required": [ + "something", + ], + "type": "object", }, "status": { - "_type": "ZodLiteral", - "value": "success", + "const": "success", }, }, + "required": [ + "status", + "data", + ], + "type": "object", }, "statusCodes": [ 200, diff --git a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap index b330c5b54..b5c8f845f 100644 --- a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap @@ -2,175 +2,211 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "n1": { - "_type": "ZodNumber", + "allOf": [ + { + "allOf": [ + { + "properties": { + "n1": { + "type": "number", + }, + }, + "required": [ + "n1", + ], + "type": "object", }, - }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "n2": { - "_type": "ZodNumber", + { + "properties": { + "n2": { + "type": "number", + }, + }, + "required": [ + "n2", + ], + "type": "object", }, - }, + ], }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "s": { - "_type": "ZodString", + { + "properties": { + "s": { + "type": "string", + }, }, + "required": [ + "s", + ], + "type": "object", }, - }, + ], } `; exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 2`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "b": { - "_type": "ZodBoolean", + "type": "boolean", }, }, + "required": [ + "b", + ], + "type": "object", } `; exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodEffects", - "value": { - "_type": "ZodObject", - "shape": { + "allOf": [ + { + "properties": { "a": { - "_type": "ZodOptional", - "value": { - "_type": "ZodNumber", - }, + "type": "number", }, "b": { - "_type": "ZodOptional", - "value": { - "_type": "ZodString", - }, + "type": "string", }, }, + "required": [], + "type": "object", }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "i": { - "_type": "ZodString", + { + "properties": { + "i": { + "type": "string", + }, }, + "required": [ + "i", + ], + "type": "object", }, - }, + ], } `; exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 2`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "o": { - "_type": "ZodBoolean", + "type": "boolean", }, }, + "required": [ + "o", + ], + "type": "object", } `; exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "n": { - "_type": "ZodNumber", + "allOf": [ + { + "properties": { + "n": { + "type": "number", + }, }, + "required": [ + "n", + ], + "type": "object", }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "s": { - "_type": "ZodString", + { + "properties": { + "s": { + "type": "string", + }, }, + "required": [ + "s", + ], + "type": "object", }, - }, + ], } `; exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 2`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "b": { - "_type": "ZodBoolean", + "type": "boolean", }, }, + "required": [ + "b", + ], + "type": "object", } `; exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodUnion", - "options": [ - { - "_type": "ZodObject", - "shape": { - "n1": { - "_type": "ZodNumber", + "allOf": [ + { + "oneOf": [ + { + "properties": { + "n1": { + "type": "number", + }, }, + "required": [ + "n1", + ], + "type": "object", }, - }, - { - "_type": "ZodObject", - "shape": { - "n2": { - "_type": "ZodNumber", + { + "properties": { + "n2": { + "type": "number", + }, }, + "required": [ + "n2", + ], + "type": "object", + }, + ], + }, + { + "properties": { + "s": { + "type": "string", }, }, - ], - }, - "right": { - "_type": "ZodObject", - "shape": { - "s": { - "_type": "ZodString", - }, + "required": [ + "s", + ], + "type": "object", }, - }, + ], } `; exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 2`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "b": { - "_type": "ZodBoolean", + "type": "boolean", }, }, + "required": [ + "b", + ], + "type": "object", } `; exports[`EndpointsFactory > .buildVoid() > Should be a shorthand for empty object output 1`] = ` { - "_type": "ZodObject", - "shape": {}, + "properties": {}, + "required": [], + "type": "object", } `; diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap new file mode 100644 index 000000000..1f9fda211 --- /dev/null +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -0,0 +1,160 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmail' definition 1`] = ` +{ + "check": [Function], + "computed": { + "format": "email", + "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + }, + "constr": [Function], + "def": { + "abort": false, + "check": "string_format", + "format": "email", + "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + "type": "string", + }, + "deferred": [], + "onattach": [Function], + "parse": [Function], + "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + "run": [Function], + "traits": Set { + "ZodEmail", + "$ZodEmail", + "$ZodStringFormat", + "$ZodCheckStringFormat", + "$ZodCheck", + "$ZodString", + "$ZodType", + "ZodType", + }, +} +`; + +exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumber' definition 1`] = ` +{ + "computed": { + "format": "safeint", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "pattern": /\\^\\\\d\\+\\$/, + }, + "constr": [Function], + "def": { + "checks": [ + { + "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": -9007199254740991, + "type": "integer", + }, + ], + "type": "number", + }, + "deferred": [], + "parse": [Function], + "pattern": /\\^\\\\d\\+\\$/, + "run": [Function], + "traits": Set { + "ZodNumber", + "$ZodNumber", + "$ZodType", + "ZodType", + }, +} +`; + +exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumberFormat' definition 1`] = ` +{ + "check": [Function], + "computed": { + "format": "safeint", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "pattern": /\\^\\\\d\\+\\$/, + }, + "constr": [Function], + "def": { + "abort": false, + "check": "number_format", + "format": "safeint", + "type": "number", + }, + "deferred": [], + "onattach": [Function], + "parse": [Function], + "pattern": /\\^\\\\d\\+\\$/, + "run": [Function], + "traits": Set { + "ZodNumberFormat", + "$ZodNumber", + "$ZodCheckNumberFormat", + "$ZodCheck", + "$ZodType", + "ZodType", + }, +} +`; + +exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumberFormat' definition 2`] = ` +{ + "check": [Function], + "computed": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "pattern": /\\^\\\\d\\+\\$/, + }, + "constr": [Function], + "def": { + "abort": false, + "check": "number_format", + "format": "int32", + "type": "number", + }, + "deferred": [], + "onattach": [Function], + "parse": [Function], + "pattern": /\\^\\\\d\\+\\$/, + "run": [Function], + "traits": Set { + "ZodNumberFormat", + "$ZodNumber", + "$ZodCheckNumberFormat", + "$ZodCheck", + "$ZodType", + "ZodType", + }, +} +`; + +exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodString' definition 1`] = ` +{ + "computed": { + "format": "email", + "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + }, + "constr": [Function], + "def": { + "checks": [ + { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string", + }, + ], + "type": "string", + }, + "deferred": [], + "parse": [Function], + "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + "run": [Function], + "traits": Set { + "ZodString", + "$ZodString", + "$ZodType", + "ZodType", + }, +} +`; diff --git a/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap index dc3971287..af5811b91 100644 --- a/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap @@ -3,10 +3,12 @@ exports[`ez.file() > parsing > should perform additional check for base64 file 1`] = ` [ { - "code": "invalid_string", - "message": "Invalid base64", + "code": "invalid_format", + "format": "base64", + "message": "Invalid base64-encoded string", + "origin": "string", "path": [], - "validation": "base64", + "pattern": "/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/", }, ] `; diff --git a/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap index 2e74fd4a3..3a0d6a9dd 100644 --- a/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap @@ -1,17 +1,16 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ez.form() > parsing > should throw for missing props as a regular object schema 1`] = ` -ZodError({ - "message": "[ - { - "code": "invalid_type", - "expected": "string", - "received": "undefined", - "path": [ - "name" - ], - "message": "Required" - } -]", -}) +ZodError { + "issues": [ + { + "code": "invalid_type", + "expected": "string", + "message": "Invalid input: expected string, received undefined", + "path": [ + "name", + ], + }, + ], +} `; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 44f991794..6569d4c39 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -2,341 +2,429 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #600: Top level refinements > should handle refined object schema 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "one": { - "_type": "ZodString", + "type": "string", }, }, + "required": [ + "one", + ], + "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869: Top level transformations > should handle transformations to another object 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "one": { - "_type": "ZodString", + "type": "string", }, }, + "required": [ + "one", + ], + "type": "object", } `; +exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = ` +IOSchemaError({ + "cause": { + "type": "string", + }, + "message": "Can not flatten IOSchema", +}) +`; + exports[`I/O Schema and related helpers > extractObjectSchema() > should pass the object schema through 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "one": { - "_type": "ZodString", + "type": "string", }, }, + "required": [ + "one", + ], + "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the intersection of object schemas 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "one": { - "_type": "ZodString", + "type": "string", }, "two": { - "_type": "ZodNumber", + "type": "number", }, }, + "required": [ + "one", + "two", + ], + "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the union of object schemas 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "one": { - "_type": "ZodOptional", - "value": { - "_type": "ZodString", - }, + "type": "string", }, "two": { - "_type": "ZodOptional", - "value": { - "_type": "ZodNumber", - }, + "type": "number", }, }, + "required": [], + "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should support ez.raw() 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "raw": { - "_type": "ZodBranded", - "brand": Symbol(File), + "x-brand": "Symbol(File)", }, }, + "required": [ + "raw", + ], + "type": "object", } `; exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should handle no middlewares 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "four": { - "_type": "ZodBoolean", + "type": "boolean", }, }, + "required": [ + "four", + ], + "type": "object", } `; exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge input object schemas 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "one": { - "_type": "ZodString", - }, + "allOf": [ + { + "allOf": [ + { + "allOf": [ + { + "properties": { + "one": { + "type": "string", + }, + }, + "required": [ + "one", + ], + "type": "object", + }, + { + "properties": { + "two": { + "type": "number", + }, + }, + "required": [ + "two", + ], + "type": "object", + }, + ], }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "two": { - "_type": "ZodNumber", + { + "properties": { + "three": { + "type": "null", + }, }, + "required": [ + "three", + ], + "type": "object", }, - }, + ], }, - "right": { - "_type": "ZodObject", - "shape": { - "three": { - "_type": "ZodNull", + { + "properties": { + "four": { + "type": "boolean", }, }, + "required": [ + "four", + ], + "type": "object", }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "four": { - "_type": "ZodBoolean", - }, - }, - }, + ], } `; exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge intersection object schemas 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "one": { - "_type": "ZodString", - }, + "allOf": [ + { + "allOf": [ + { + "allOf": [ + { + "properties": { + "one": { + "type": "string", + }, + }, + "required": [ + "one", + ], + "type": "object", + }, + { + "properties": { + "two": { + "type": "number", + }, + }, + "required": [ + "two", + ], + "type": "object", + }, + ], }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "two": { - "_type": "ZodNumber", - }, + { + "allOf": [ + { + "properties": { + "three": { + "type": "null", + }, + }, + "required": [ + "three", + ], + "type": "object", + }, + { + "properties": { + "four": { + "type": "boolean", + }, + }, + "required": [ + "four", + ], + "type": "object", + }, + ], }, - }, + ], }, - "right": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "three": { - "_type": "ZodNull", + { + "allOf": [ + { + "properties": { + "five": { + "type": "string", + }, }, + "required": [ + "five", + ], + "type": "object", }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "four": { - "_type": "ZodBoolean", + { + "properties": { + "six": { + "type": "number", + }, }, + "required": [ + "six", + ], + "type": "object", }, - }, - }, - }, - "right": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "five": { - "_type": "ZodString", - }, - }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "six": { - "_type": "ZodNumber", - }, - }, + ], }, - }, + ], } `; exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge mixed object schemas 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodObject", - "shape": { - "one": { - "_type": "ZodString", - }, - }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "two": { - "_type": "ZodNumber", - }, - }, - }, - }, - "right": { - "_type": "ZodUnion", - "options": [ + "allOf": [ + { + "allOf": [ { - "_type": "ZodObject", - "shape": { - "three": { - "_type": "ZodNull", + "allOf": [ + { + "properties": { + "one": { + "type": "string", + }, + }, + "required": [ + "one", + ], + "type": "object", }, - }, + { + "properties": { + "two": { + "type": "number", + }, + }, + "required": [ + "two", + ], + "type": "object", + }, + ], }, { - "_type": "ZodObject", - "shape": { - "four": { - "_type": "ZodBoolean", + "oneOf": [ + { + "properties": { + "three": { + "type": "null", + }, + }, + "required": [ + "three", + ], + "type": "object", }, - }, + { + "properties": { + "four": { + "type": "boolean", + }, + }, + "required": [ + "four", + ], + "type": "object", + }, + ], }, ], }, - }, - "right": { - "_type": "ZodObject", - "shape": { - "five": { - "_type": "ZodString", + { + "properties": { + "five": { + "type": "string", + }, }, + "required": [ + "five", + ], + "type": "object", }, - }, + ], } `; exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge union object schemas 1`] = ` { - "_type": "ZodIntersection", - "left": { - "_type": "ZodIntersection", - "left": { - "_type": "ZodUnion", - "options": [ + "allOf": [ + { + "allOf": [ { - "_type": "ZodObject", - "shape": { - "one": { - "_type": "ZodString", + "oneOf": [ + { + "properties": { + "one": { + "type": "string", + }, + }, + "required": [ + "one", + ], + "type": "object", }, - }, + { + "properties": { + "two": { + "type": "number", + }, + }, + "required": [ + "two", + ], + "type": "object", + }, + ], }, { - "_type": "ZodObject", - "shape": { - "two": { - "_type": "ZodNumber", + "oneOf": [ + { + "properties": { + "three": { + "type": "null", + }, + }, + "required": [ + "three", + ], + "type": "object", }, - }, + { + "properties": { + "four": { + "type": "boolean", + }, + }, + "required": [ + "four", + ], + "type": "object", + }, + ], }, ], }, - "right": { - "_type": "ZodUnion", - "options": [ + { + "oneOf": [ { - "_type": "ZodObject", - "shape": { - "three": { - "_type": "ZodNull", + "properties": { + "five": { + "type": "string", }, }, + "required": [ + "five", + ], + "type": "object", }, { - "_type": "ZodObject", - "shape": { - "four": { - "_type": "ZodBoolean", + "properties": { + "six": { + "type": "number", }, }, + "required": [ + "six", + ], + "type": "object", }, ], }, - }, - "right": { - "_type": "ZodUnion", - "options": [ - { - "_type": "ZodObject", - "shape": { - "five": { - "_type": "ZodString", - }, - }, - }, - { - "_type": "ZodObject", - "shape": { - "six": { - "_type": "ZodNumber", - }, - }, - }, - ], - }, + ], } `; exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Zod Issue #600: can not intersect object schema with passthrough and transformation 1`] = ` -ZodError({ - "message": "[ - { - "code": "invalid_intersection_types", - "path": [], - "message": "Intersection results could not be merged" - } -]", +Error({ + "message": "Unmergable intersection. Error path: ["id"]", }) `; diff --git a/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap index 555325dd8..cf4247105 100644 --- a/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap @@ -9,20 +9,19 @@ InternalServerError({ }) `; -exports[`Result helpers > ensureHttpError() > should handle InputValidationError: Expected string, received number 1`] = ` +exports[`Result helpers > ensureHttpError() > should handle InputValidationError: Invalid input: expected string, received number 1`] = ` BadRequestError({ - "cause": ZodError({ - "message": "[ - { - "code": "invalid_type", - "expected": "string", - "received": "number", - "path": [], - "message": "Expected string, received number" - } -]", - }), - "message": "Expected string, received number", + "cause": ZodError { + "issues": [ + { + "code": "invalid_type", + "expected": "string", + "message": "Invalid input: expected string, received number", + "path": [], + }, + ], + }, + "message": "Invalid input: expected string, received number", }) `; @@ -32,19 +31,18 @@ NotFoundError({ }) `; -exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: Expected string, received number 1`] = ` +exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: Invalid input: expected string, received number 1`] = ` InternalServerError({ - "cause": ZodError({ - "message": "[ - { - "code": "invalid_type", - "expected": "string", - "received": "number", - "path": [], - "message": "Expected string, received number" - } -]", - }), - "message": "output: Expected string, received number", + "cause": ZodError { + "issues": [ + { + "code": "invalid_type", + "expected": "string", + "message": "Invalid input: expected string, received number", + "path": [], + }, + ], + }, + "message": "output: Invalid input: expected string, received number", }) `; diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index c183aed34..d647b9e85 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -2,28 +2,26 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = ` { - "_type": "ZodObject", - "shape": { + "properties": { "data": { - "_type": "ZodString", + "type": "string", }, "event": { - "_type": "ZodLiteral", - "value": "test", + "const": "test", }, "id": { - "_type": "ZodOptional", - "value": { - "_type": "ZodString", - }, + "type": "string", }, "retry": { - "_type": "ZodOptional", - "value": { - "_type": "ZodNumber", - }, + "exclusiveMaximum": 9007199254740991, + "type": "integer", }, }, + "required": [ + "data", + "event", + ], + "type": "object", } `; @@ -34,56 +32,50 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { - "_type": "ZodDiscriminatedUnion", - "discriminator": "event", - "options": [ + "oneOf": [ { - "_type": "ZodObject", - "shape": { + "properties": { "data": { - "_type": "ZodString", + "type": "string", }, "event": { - "_type": "ZodLiteral", - "value": "test", + "const": "test", }, "id": { - "_type": "ZodOptional", - "value": { - "_type": "ZodString", - }, + "type": "string", }, "retry": { - "_type": "ZodOptional", - "value": { - "_type": "ZodNumber", - }, + "exclusiveMaximum": 9007199254740991, + "type": "integer", }, }, + "required": [ + "data", + "event", + ], + "type": "object", }, { - "_type": "ZodObject", - "shape": { + "properties": { "data": { - "_type": "ZodNumber", + "type": "number", }, "event": { - "_type": "ZodLiteral", - "value": "another", + "const": "another", }, "id": { - "_type": "ZodOptional", - "value": { - "_type": "ZodString", - }, + "type": "string", }, "retry": { - "_type": "ZodOptional", - "value": { - "_type": "ZodNumber", - }, + "exclusiveMaximum": 9007199254740991, + "type": "integer", }, }, + "required": [ + "data", + "event", + ], + "type": "object", }, ], }, @@ -101,7 +93,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/plain", ], "schema": { - "_type": "ZodString", + "type": "string", }, "statusCodes": [ 400, @@ -117,28 +109,26 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { - "_type": "ZodObject", - "shape": { + "properties": { "data": { - "_type": "ZodString", + "type": "string", }, "event": { - "_type": "ZodLiteral", - "value": "single", + "const": "single", }, "id": { - "_type": "ZodOptional", - "value": { - "_type": "ZodString", - }, + "type": "string", }, "retry": { - "_type": "ZodOptional", - "value": { - "_type": "ZodNumber", - }, + "exclusiveMaximum": 9007199254740991, + "type": "integer", }, }, + "required": [ + "data", + "event", + ], + "type": "object", }, "statusCodes": [ 200, @@ -154,7 +144,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/plain", ], "schema": { - "_type": "ZodString", + "type": "string", }, "statusCodes": [ 400, diff --git a/express-zod-api/tests/__snapshots__/system.spec.ts.snap b/express-zod-api/tests/__snapshots__/system.spec.ts.snap index 5f282c500..e63625583 100644 --- a/express-zod-api/tests/__snapshots__/system.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/system.spec.ts.snap @@ -93,7 +93,7 @@ exports[`App in production mode > Protocol > Should fail on invalid path 1`] = ` exports[`App in production mode > Protocol > Should fail when missing content type header 1`] = ` { "error": { - "message": "key: Required", + "message": "key: Invalid input: expected string, received undefined", }, "status": "error", } @@ -122,18 +122,24 @@ exports[`App in production mode > Validation > Problem 787: Should NOT treat Zod "Server side error", { "error": InternalServerError({ - "cause": ZodError({ + "cause": Error({ "message": "[ { - "code": "invalid_type", "expected": "number", - "received": "string", + "code": "invalid_type", "path": [], - "message": "Expected number, received string" + "message": "Invalid input: expected number, received string" } ]", }), - "message": "Expected number, received string", + "message": "[ + { + "expected": "number", + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected number, received string" + } +]", }), "payload": { "key": "123", @@ -147,7 +153,7 @@ exports[`App in production mode > Validation > Problem 787: Should NOT treat Zod exports[`App in production mode > Validation > Should fail on handler input type mismatch 1`] = ` { "error": { - "message": "something: Expected string, received number", + "message": "something: Invalid input: expected string, received number", }, "status": "error", } @@ -167,22 +173,21 @@ exports[`App in production mode > Validation > Should fail on handler output typ "Server side error", { "error": InternalServerError({ - "cause": ZodError({ - "message": "[ - { - "code": "too_small", - "minimum": 0, - "type": "number", - "inclusive": false, - "exact": false, - "message": "Number must be greater than 0", - "path": [ - "anything" - ] - } -]", - }), - "message": "output/anything: Number must be greater than 0", + "cause": ZodError { + "issues": [ + { + "code": "too_small", + "inclusive": false, + "message": "Too small: expected number to be >0", + "minimum": 0, + "origin": "number", + "path": [ + "anything", + ], + }, + ], + }, + "message": "output/anything: Too small: expected number to be >0", }), "payload": { "key": "123", @@ -196,7 +201,7 @@ exports[`App in production mode > Validation > Should fail on handler output typ exports[`App in production mode > Validation > Should fail on middleware input type mismatch 1`] = ` { "error": { - "message": "key: Expected string, received number", + "message": "key: Invalid input: expected string, received number", }, "status": "error", } diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 7260dd0e2..18a29ea51 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -56,7 +56,6 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` set: any; intersection: (string & number) | bigint; promise: any; - function: any; optDefaultString?: string | undefined; refinedStringWithSomeBullshit: (string | number) & ((bigint | null) | undefined); nativeEnum: "A" | "apple" | "banana" | "cantaloupe" | 5; @@ -207,6 +206,8 @@ exports[`zod-to-ts > z.literal() > Should produce the correct typescript 2 1`] = exports[`zod-to-ts > z.literal() > Should produce the correct typescript 3 1`] = `"123"`; +exports[`zod-to-ts > z.literal() > Should produce the correct typescript 4 1`] = `"undefined"`; + exports[`zod-to-ts > z.nullable() > outputs correct typescript 1`] = ` "{ username: string | null; diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index ad353ae3f..94ea8f0b3 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -166,14 +166,14 @@ describe("Common Helpers", () => { path: ["user", "id"], message: "expected number, got string", expected: "number", - received: "string", + input: "test", }, { code: "invalid_type", path: ["user", "name"], message: "expected string, got number", expected: "string", - received: "number", + input: 123, }, ]); expect(getMessageFromError(error)).toMatchSnapshot(); @@ -181,7 +181,12 @@ describe("Common Helpers", () => { test("should handle empty path in ZodIssue", () => { const error = new z.ZodError([ - { code: "custom", path: [], message: "Top level refinement issue" }, + { + code: "custom", + path: [], + message: "Top level refinement issue", + input: "test", + }, ]); expect(getMessageFromError(error)).toMatchSnapshot(); }); @@ -280,7 +285,7 @@ describe("Common Helpers", () => { }), ).toEqual([123, 456]); }); - test.each([z.array(z.number().int()), z.tuple([z.number(), z.number()])])( + test.each([z.array(z.int()), z.tuple([z.number(), z.number()])])( "Issue #892: should handle examples of arrays and tuples %#", (schema) => { expect( @@ -344,13 +349,13 @@ describe("Common Helpers", () => { { code: "invalid_type", expected: "string", - received: "number", + input: 123, path: [""], message: "invalid type", }, ]), `[\n {\n "code": "invalid_type",\n "expected": "string",\n` + - ` "received": "number",\n "path": [\n ""\n` + + ` "input": 123,\n "path": [\n ""\n` + ` ],\n "message": "invalid type"\n }\n]`, ], [createHttpError(500, "Internal Server Error"), "Internal Server Error"], @@ -386,7 +391,7 @@ describe("Common Helpers", () => { test.each([ { schema: z.string(), coercion: false }, { schema: z.coerce.string(), coercion: true }, - { schema: z.boolean({ coerce: true }), coercion: true }, + { schema: z.coerce.boolean(), coercion: true }, { schema: z.custom(), coercion: false }, ])( "should check the presence and value of coerce prop %#", diff --git a/express-zod-api/tests/date-in-schema.spec.ts b/express-zod-api/tests/date-in-schema.spec.ts index b9a697602..60384c1ae 100644 --- a/express-zod-api/tests/date-in-schema.spec.ts +++ b/express-zod-api/tests/date-in-schema.spec.ts @@ -7,8 +7,8 @@ describe("ez.dateIn()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateIn(); - expect(schema).toBeInstanceOf(z.ZodBranded); - expect(schema._def[metaSymbol]?.brand).toEqual(ezDateInBrand); + expect(schema).toBeInstanceOf(z.ZodPipe); + expect(schema.meta()?.[metaSymbol]?.brand).toEqual(ezDateInBrand); }); }); diff --git a/express-zod-api/tests/date-out-schema.spec.ts b/express-zod-api/tests/date-out-schema.spec.ts index 039deb0d0..25dd350a4 100644 --- a/express-zod-api/tests/date-out-schema.spec.ts +++ b/express-zod-api/tests/date-out-schema.spec.ts @@ -7,8 +7,8 @@ describe("ez.dateOut()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateOut(); - expect(schema).toBeInstanceOf(z.ZodBranded); - expect(schema._def[metaSymbol]?.brand).toEqual(ezDateOutBrand); + expect(schema).toBeInstanceOf(z.ZodPipe); + expect(schema.meta()?.[metaSymbol]?.brand).toEqual(ezDateOutBrand); }); }); @@ -22,9 +22,8 @@ describe("ez.dateOut()", () => { { code: "invalid_type", expected: "date", - message: "Expected date, received string", + message: "Invalid input: expected date, received string", path: [], - received: "string", }, ]); } @@ -46,9 +45,11 @@ describe("ez.dateOut()", () => { if (!result.success) { expect(result.error.issues).toEqual([ { - code: "invalid_date", - message: "Invalid date", + code: "invalid_type", + expected: "date", + message: "Invalid input: expected date, received Date", path: [], + received: "Invalid Date", }, ]); } diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index 73821f57b..ccff858f1 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -1,5 +1,6 @@ import { UploadedFile } from "express-fileupload"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; +import type { $brand, $ZodType } from "@zod/core"; import { ez } from "../src"; import { hasNestedSchema } from "../src/deep-checks"; import { metaSymbol } from "../src/metadata"; @@ -7,8 +8,8 @@ import { ezUploadBrand } from "../src/upload-schema"; describe("Checks", () => { describe("hasNestedSchema()", () => { - const condition = (subject: z.ZodTypeAny) => - subject._def[metaSymbol]?.brand === ezUploadBrand; + const condition = (subject: $ZodType) => + globalRegistry.get(subject)?.[metaSymbol]?.brand === ezUploadBrand; test("should return true for given argument satisfying condition", () => { expect(hasNestedSchema(ez.upload(), { condition })).toBeTruthy(); @@ -17,11 +18,14 @@ describe("Checks", () => { test.each([ z.object({ test: ez.upload() }), ez.upload().or(z.boolean()), - z.object({ test: z.boolean() }).and(z.object({ test2: ez.upload() })), + z.intersection( + z.object({ test: z.boolean() }), + z.object({ test2: ez.upload() }), + ), z.optional(ez.upload()), ez.upload().nullable(), - ez.upload().default({} as UploadedFile), - z.record(ez.upload()), + ez.upload().default({} as UploadedFile & $brand), + z.record(z.string(), ez.upload()), ez.upload().refine(() => true), z.array(ez.upload()), ])("should return true for wrapped needle %#", (subject) => { @@ -32,7 +36,7 @@ describe("Checks", () => { z.object({}), z.any(), z.literal("test"), - z.boolean().and(z.literal(true)), + z.intersection(z.boolean(), z.literal(true)), z.number().or(z.string()), ])("should return false in other cases %#", (subject) => { expect(hasNestedSchema(subject, { condition })).toBeFalsy(); diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 1be0ca2a5..ec1807adc 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -1,3 +1,4 @@ +import type { $ZodType } from "@zod/core"; import { ReferenceObject } from "openapi3-ts/oas31"; import * as R from "ramda"; import { z } from "zod"; @@ -8,14 +9,11 @@ import { depictArray, depictBigInt, depictBoolean, - depictBranded, - depictCatch, + depictWrapped, depictDate, depictDateIn, depictDateOut, depictDefault, - depictDiscriminatedUnion, - depictEffect, depictEnum, depictExamples, depictFile, @@ -27,10 +25,8 @@ import { depictNumber, depictObject, depictObjectProperties, - depictOptional, depictParamExamples, depictPipeline, - depictReadonly, depictRecord, depictRequestParams, depictSecurity, @@ -59,7 +55,7 @@ describe("Documentation helpers", () => { method: "get", isResponse: false, makeRef: makeRefMock, - next: (schema: z.ZodTypeAny) => + next: (schema: $ZodType) => walkSchema(schema, { rules: depicters, onEach, @@ -72,7 +68,7 @@ describe("Documentation helpers", () => { method: "get", isResponse: true, makeRef: makeRefMock, - next: (schema: z.ZodTypeAny) => + next: (schema: $ZodType) => walkSchema(schema, { rules: depicters, onEach, @@ -89,10 +85,11 @@ describe("Documentation helpers", () => { test.each([ z.object({ a: z.string(), b: z.string() }), z.object({ a: z.string() }).or(z.object({ b: z.string() })), - z.object({ a: z.string() }).and(z.object({ b: z.string() })), // flattened - z - .record(z.literal("a"), z.string()) - .and(z.record(z.string(), z.string())), + z.intersection(z.object({ a: z.string() }), z.object({ b: z.string() })), // flattened + z.intersection( + z.record(z.literal("a"), z.string()), + z.record(z.string(), z.string()), + ), ])("should omit specified params %#", (schema) => { const depicted = walkSchema(schema, { ctx: requestCtx, @@ -137,8 +134,7 @@ describe("Documentation helpers", () => { test("Feature #1706: should override the default value by a label from metadata", () => { expect( depictDefault( - z - .string() + z.iso .datetime() .default(() => new Date().toISOString()) .label("Today"), @@ -148,10 +144,20 @@ describe("Documentation helpers", () => { }); }); - describe("depictCatch()", () => { - test("should pass next depicter", () => { + describe("depictWrapped()", () => { + test("should handle catch", () => { expect( - depictCatch(z.boolean().catch(true), requestCtx), + depictWrapped(z.boolean().catch(true), requestCtx), + ).toMatchSnapshot(); + }); + + test.each([requestCtx, responseCtx])("should handle optional %#", (ctx) => { + expect(depictWrapped(z.string().optional(), ctx)).toMatchSnapshot(); + }); + + test("handle readonly", () => { + expect( + depictWrapped(z.string().readonly(), responseCtx), ).toMatchSnapshot(); }); }); @@ -199,12 +205,10 @@ describe("Documentation helpers", () => { depictUnion(z.string().or(z.number()), requestCtx), ).toMatchSnapshot(); }); - }); - describe("depictDiscriminatedUnion()", () => { test("should wrap next depicters in oneOf prop and set discriminator prop", () => { expect( - depictDiscriminatedUnion( + depictUnion( z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.any() }), z.object({ @@ -222,7 +226,10 @@ describe("Documentation helpers", () => { test("should flatten two object schemas", () => { expect( depictIntersection( - z.object({ one: z.number() }).and(z.object({ two: z.number() })), + z.intersection( + z.object({ one: z.number() }), + z.object({ two: z.number() }), + ), requestCtx, ), ).toMatchSnapshot(); @@ -231,7 +238,10 @@ describe("Documentation helpers", () => { test("should flatten objects with same prop of same type", () => { expect( depictIntersection( - z.object({ one: z.number() }).and(z.object({ one: z.number() })), + z.intersection( + z.object({ one: z.number() }), + z.object({ one: z.number() }), + ), requestCtx, ), ).toMatchSnapshot(); @@ -240,7 +250,10 @@ describe("Documentation helpers", () => { test("should NOT flatten object schemas having conflicting props", () => { expect( depictIntersection( - z.object({ one: z.number() }).and(z.object({ one: z.string() })), + z.intersection( + z.object({ one: z.number() }), + z.object({ one: z.string() }), + ), requestCtx, ), ).toMatchSnapshot(); @@ -249,14 +262,14 @@ describe("Documentation helpers", () => { test("should merge examples deeply", () => { expect( depictIntersection( - z - .object({ test: z.object({ a: z.number() }) }) - .example({ test: { a: 123 } }) - .and( - z - .object({ test: z.object({ b: z.number() }) }) - .example({ test: { b: 456 } }), - ), + z.intersection( + z + .object({ test: z.object({ a: z.number() }) }) + .example({ test: { a: 123 } }), + z + .object({ test: z.object({ b: z.number() }) }) + .example({ test: { b: 456 } }), + ), requestCtx, ), ).toMatchSnapshot(); @@ -265,11 +278,13 @@ describe("Documentation helpers", () => { test("should flatten three object schemas with examples", () => { expect( depictIntersection( - z - .object({ one: z.number() }) - .example({ one: 123 }) - .and(z.object({ two: z.number() }).example({ two: 456 })) - .and(z.object({ three: z.number() }).example({ three: 789 })), + z.intersection( + z.intersection( + z.object({ one: z.number() }).example({ one: 123 }), + z.object({ two: z.number() }).example({ two: 456 }), + ), + z.object({ three: z.number() }).example({ three: 789 }), + ), requestCtx, ), ).toMatchSnapshot(); @@ -278,31 +293,26 @@ describe("Documentation helpers", () => { test("should maintain uniqueness in the array of required props", () => { expect( depictIntersection( - z - .record(z.literal("test"), z.number()) - .and(z.object({ test: z.literal(5) })), + z.intersection( + z.record(z.literal("test"), z.number()), + z.object({ test: z.literal(5) }), + ), requestCtx, ), ).toMatchSnapshot(); }); test.each([ - z.record(z.string(), z.number()).and(z.object({ test: z.number() })), // has additionalProperties - z.number().and(z.literal(5)), // not objects + z.intersection( + z.record(z.string(), z.number()), // has additionalProperties + z.object({ test: z.number() }), + ), + z.intersection(z.number(), z.literal(5)), // not objects ])("should fall back to allOf in other cases %#", (schema) => { expect(depictIntersection(schema, requestCtx)).toMatchSnapshot(); }); }); - describe("depictOptional()", () => { - test.each([requestCtx, responseCtx])( - "should pass the next depicter %#", - (ctx) => { - expect(depictOptional(z.string().optional(), ctx)).toMatchSnapshot(); - }, - ); - }); - describe("depictNullable()", () => { test.each([requestCtx, responseCtx])( "should add null to the type %#", @@ -324,7 +334,7 @@ describe("Documentation helpers", () => { one = "ONE", two = "TWO", } - test.each([z.enum(["one", "two"]), z.nativeEnum(Test)])( + test.each([z.enum(["one", "two"]), z.enum(Test)])( "should set type and enum properties", (schema) => { expect(depictEnum(schema, requestCtx)).toMatchSnapshot(); @@ -333,12 +343,16 @@ describe("Documentation helpers", () => { }); describe("depictLiteral()", () => { - test.each(["testng", null, BigInt(123), Symbol("test")])( + test.each(["testng", null, BigInt(123), undefined])( "should set type and involve const property %#", (value) => { expect(depictLiteral(z.literal(value), requestCtx)).toMatchSnapshot(); }, ); + + test("should handle multiple values", () => { + expect(depictLiteral(z.literal([1, 2, 3]), requestCtx)).toMatchSnapshot(); + }); }); describe("depictObject()", () => { @@ -347,7 +361,7 @@ describe("Documentation helpers", () => { { ctx: responseCtx, shape: { a: z.number(), b: z.string() } }, { ctx: responseCtx, - shape: { a: z.coerce.number(), b: z.string({ coerce: true }) }, + shape: { a: z.coerce.number(), b: z.coerce.string() }, }, { ctx: responseCtx, shape: { a: z.number(), b: z.string().optional() } }, { @@ -391,12 +405,12 @@ describe("Documentation helpers", () => { describe("depictRecord()", () => { test.each([ - z.record(z.boolean()), + z.record(z.int(), z.boolean()), z.record(z.string(), z.boolean()), z.record(z.enum(["one", "two"]), z.boolean()), z.record(z.literal("testing"), z.boolean()), z.record(z.literal("one").or(z.literal("two")), z.boolean()), - z.record(z.any()), // Issue #900 + z.record(z.string(), z.any()), // Issue #900 z.record(z.string().regex(/x-\w+/), z.boolean()), ])( "should set properties+required or additionalProperties props %#", @@ -449,35 +463,40 @@ describe("Documentation helpers", () => { test.each([ z.string().email().min(10).max(20), z.string().url().length(15), - z.string().uuid(), - z.string().cuid(), - z.string().datetime(), - z.string().datetime({ offset: true }), + z.uuid(), + z.cuid(), + z.iso.datetime(), + z.iso.datetime({ offset: true }), z.string().regex(/^\d+.\d+.\d+$/), - z.string().date(), - z.string().time(), - z.string().duration(), - z.string().cidr(), - z.string().ip(), - z.string().jwt(), - z.string().base64(), - z.string().base64url(), - z.string().cuid2(), - z.string().ulid(), + z.iso.date(), + z.iso.time(), + z.iso.duration(), + z.cidrv4(), + z.ipv4(), + z.jwt(), + z.base64(), + z.base64url(), + z.cuid2(), + z.ulid(), ])("should set format, pattern and min/maxLength props %#", (schema) => { expect(depictString(schema, requestCtx)).toMatchSnapshot(); }); }); describe("depictNumber()", () => { - test.each([z.number(), z.number().int()])( + test.each([ + z.number(), + z.int(), + z.float64(), + R.assocPath(["_zod", "def", "format"], "hacked", z.int()), + ])( "should set min/max values according to JS capabilities %#", (schema) => { expect(depictNumber(schema, requestCtx)).toMatchSnapshot(); }, ); - test.each([z.number(), z.number().int()])( + test.each([z.number(), z.int()])( "should use numericRange when set %#", (schema) => { expect( @@ -492,7 +511,7 @@ describe("Documentation helpers", () => { }, ); - test.each([z.number(), z.number().int()])( + test.each([z.number(), z.int()])( "should not use numericRange when it is null %#", (schema) => { expect( @@ -537,7 +556,16 @@ describe("Documentation helpers", () => { }); }); - describe("depictEffect()", () => { + describe("depictPipeline", () => { + test.each([ + { ctx: responseCtx, expected: "boolean (out)" }, + { ctx: requestCtx, expected: "string (in)" }, + ])("should depict as $expected", ({ ctx }) => { + expect( + depictPipeline(z.string().transform(Boolean).pipe(z.boolean()), ctx), + ).toMatchSnapshot(); + }); + test.each([ { schema: z.string().transform((v) => parseInt(v, 10)), @@ -554,33 +582,15 @@ describe("Documentation helpers", () => { ctx: requestCtx, expected: "string (preprocess)", }, - { - schema: z - .object({ s: z.string() }) - .refine(() => false, { message: "test" }), - ctx: requestCtx, - expected: "object (refinement)", - }, ])("should depict as $expected", ({ schema, ctx }) => { - expect(depictEffect(schema, ctx)).toMatchSnapshot(); + expect(depictPipeline(schema, ctx)).toMatchSnapshot(); }); test.each([ z.number().transform((num) => () => num), z.number().transform(() => assert.fail("this should be handled")), ])("should handle edge cases", (schema) => { - expect(depictEffect(schema, responseCtx)).toMatchSnapshot(); - }); - }); - - describe("depictPipeline", () => { - test.each([ - { ctx: responseCtx, expected: "boolean (out)" }, - { ctx: requestCtx, expected: "string (in)" }, - ])("should depict as $expected", ({ ctx }) => { - expect( - depictPipeline(z.string().pipe(z.coerce.boolean()), ctx), - ).toMatchSnapshot(); + expect(depictPipeline(schema, responseCtx)).toMatchSnapshot(); }); }); @@ -762,30 +772,10 @@ describe("Documentation helpers", () => { ); }); - describe("depictBranded", () => { - test("should pass the next depicter", () => { - expect( - depictBranded(z.string().min(2).brand("Test"), responseCtx), - ).toMatchSnapshot(); - }); - }); - - describe("depictReadonly", () => { - test("should pass the next depicter", () => { - expect( - depictReadonly(z.string().readonly(), responseCtx), - ).toMatchSnapshot(); - }); - }); - describe("depictLazy", () => { - const recursiveArray: z.ZodLazy> = z.lazy(() => - recursiveArray.array(), - ); - const directlyRecursive: z.ZodLazy = z.lazy( - () => directlyRecursive, - ); - const recursiveObject: z.ZodLazy> = z.lazy(() => + const recursiveArray: z.ZodLazy = z.lazy(() => recursiveArray.array()); + const directlyRecursive: z.ZodLazy = z.lazy(() => directlyRecursive); + const recursiveObject: z.ZodLazy = z.lazy(() => z.object({ prop: recursiveObject }), ); @@ -800,7 +790,10 @@ describe("Documentation helpers", () => { expect(makeRefMock).not.toHaveBeenCalled(); expect(depictLazy(schema, responseCtx)).toMatchSnapshot(); expect(makeRefMock).toHaveBeenCalledTimes(1); - expect(makeRefMock).toHaveBeenCalledWith(schema, expect.any(Function)); + expect(makeRefMock).toHaveBeenCalledWith( + schema._zod.def.getter, + expect.any(Function), + ); }, ); }); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index d62c2d239..2968cc880 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -12,7 +12,7 @@ import { ResultHandler, } from "../src"; import { contentTypes } from "../src/content-type"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { givePort } from "../../tools/ports"; describe("Documentation", () => { @@ -281,9 +281,9 @@ describe("Documentation", () => { getSomething: defaultEndpointsFactory.build({ method: "post", output: z.object({ - simple: z.record(z.number().int()), + simple: z.record(z.string(), z.int()), stringy: z.record(z.string().regex(/[A-Z]+/), z.boolean()), - numeric: z.record(z.number().int(), z.boolean()), + numeric: z.record(z.int(), z.boolean()), literal: z.record(z.literal("only"), z.boolean()), union: z.record( z.literal("option1").or(z.literal("option2")), @@ -337,7 +337,7 @@ describe("Documentation", () => { doublePositive: z.number().positive(), doubleNegative: z.number().negative(), doubleLimited: z.number().min(-0.5).max(0.5), - int: z.number().int(), + int: z.int(), intPositive: z.number().int().positive(), intNegative: z.number().int().negative(), intLimited: z.number().int().min(-100).max(100), @@ -369,14 +369,14 @@ describe("Documentation", () => { min: z.string().nonempty(), max: z.string().max(15), range: z.string().min(2).max(3), - email: z.string().email(), - uuid: z.string().uuid(), - cuid: z.string().cuid(), - cuid2: z.string().cuid2(), - ulid: z.string().ulid(), - ip: z.string().ip(), - emoji: z.string().emoji(), - url: z.string().url(), + email: z.email(), + uuid: z.uuid(), + cuid: z.cuid(), + cuid2: z.cuid2(), + ulid: z.ulid(), + ip: z.ipv4(), + emoji: z.emoji(), + url: z.url(), numeric: z.string().regex(/\d+/), combined: z .string() @@ -438,10 +438,10 @@ describe("Documentation", () => { regularEnum: z.enum(["ABC", "DEF"]), }), output: z.object({ - nativeEnum: z.nativeEnum({ FEG: 1, XYZ: 2 }), + nativeEnum: z.enum({ FEG: 1, XYZ: 2 }), }), handler: async () => ({ - nativeEnum: 1, + nativeEnum: 1 as const, }), }), }, @@ -530,7 +530,6 @@ describe("Documentation", () => { z.undefined(), z.map(z.any(), z.any()), z.set(z.any()), - z.function(), z.promise(z.any()), z.nan(), z.symbol(), @@ -560,7 +559,7 @@ describe("Documentation", () => { }), ).toThrow( new DocumentationError( - `Zod type ${zodType._def.typeName} is unsupported.`, + `Zod type ${zodType.constructor.name} is unsupported.`, { method: "post", path: "/v1/getSomething", @@ -1235,9 +1234,12 @@ describe("Documentation", () => { test("should be handled accordingly in request, response and params", () => { const deep = Symbol("DEEP"); const rule: Depicter = ( - schema: z.ZodBranded, + schema: ReturnType, { next }, - ) => next(schema.unwrap()); + ) => { + globalRegistry.remove(schema); + return next(schema); + }; const spec = new Documentation({ config: sampleConfig, routing: { diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index 7595ab14c..adbe97863 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -135,14 +135,14 @@ describe("Endpoint", () => { test("Should throw on output validation failure", async () => { const endpoint = defaultEndpointsFactory.build({ method: "post", - output: z.object({ email: z.string().email() }), + output: z.object({ email: z.email() }), handler: async () => ({ email: "not email" }), }); const { responseMock } = await testEndpoint({ endpoint }); expect(responseMock._getStatusCode()).toBe(500); expect(responseMock._getJSONData()).toEqual({ status: "error", - error: { message: "output/email: Invalid email" }, + error: { message: "output/email: Invalid email address" }, }); }); @@ -527,8 +527,7 @@ describe("Endpoint", () => { }, ), output: z - .object({}) - .passthrough() + .looseObject({}) .refine((obj) => !("emitOutputValidationFailure" in obj), { message: "failure on demand", }), @@ -601,7 +600,7 @@ describe("Endpoint", () => { method: "post", input: z .object({ - email: z.string().email().optional(), + email: z.email().optional(), id: z.string().optional(), otherThing: z.string().optional(), }) diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 1ccedfd8a..dc053bf32 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -101,7 +101,7 @@ describe("EndpointsFactory", () => { expect(newFactory["middlewares"].length).toBe(1); expect(newFactory["middlewares"][0].schema).toBeInstanceOf(z.ZodObject); expect( - (newFactory["middlewares"][0].schema as z.AnyZodObject).shape, + (newFactory["middlewares"][0].schema as z.ZodObject).shape, ).toEqual({}); const { output: options } = await testMiddleware({ middleware: newFactory["middlewares"][0], @@ -129,7 +129,7 @@ describe("EndpointsFactory", () => { expect(newFactory["middlewares"].length).toBe(1); expect(newFactory["middlewares"][0].schema).toBeInstanceOf(z.ZodObject); expect( - (newFactory["middlewares"][0].schema as z.AnyZodObject).shape, + (newFactory["middlewares"][0].schema as z.ZodObject).shape, ).toEqual({}); const { output: options, @@ -249,7 +249,7 @@ describe("EndpointsFactory", () => { expect(endpoint.methods).toBeUndefined(); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._output).toExtend<{ + expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{ n: number; s: string; }>(); @@ -277,7 +277,7 @@ describe("EndpointsFactory", () => { }); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._output).toExtend<{ + expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{ a?: number; b?: string; i: string; @@ -286,7 +286,10 @@ describe("EndpointsFactory", () => { test("Should create an endpoint with intersection middleware", () => { const middleware = new Middleware({ - input: z.object({ n1: z.number() }).and(z.object({ n2: z.number() })), + input: z.intersection( + z.object({ n1: z.number() }), + z.object({ n2: z.number() }), + ), handler: vi.fn(), }); const factory = new EndpointsFactory(resultHandlerMock).addMiddleware( @@ -302,7 +305,7 @@ describe("EndpointsFactory", () => { expect(endpoint.methods).toBeUndefined(); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._output).toExtend<{ + expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{ n1: number; n2: number; s: string; @@ -330,7 +333,7 @@ describe("EndpointsFactory", () => { expect(endpoint.methods).toBeUndefined(); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._output).toExtend< + expectTypeOf(endpoint.inputSchema._zod.output).toExtend< { s: string } & ({ n1: number } | { n2: number }) >(); }); @@ -343,7 +346,8 @@ describe("EndpointsFactory", () => { output: z.object({}), handler: vi.fn(), }); - expectTypeOf(endpoint.inputSchema._output).toEqualTypeOf(); + /** @see $InferObjectOutput - external logic */ + expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf(); expect(endpoint.isDeprecated).toBe(true); }); }); diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts new file mode 100644 index 000000000..60dd39118 --- /dev/null +++ b/express-zod-api/tests/env.spec.ts @@ -0,0 +1,72 @@ +import createHttpError from "http-errors"; +import * as R from "ramda"; +import { z } from "zod"; + +describe("Environment checks", () => { + describe("Zod Dates", () => { + test.each(["2021-01-32", "22/01/2022", "2021-01-31T25:00:00.000Z"])( + "should detect invalid date %#", + (str) => { + expect(z.date().safeParse(new Date(str)).success).toBeFalsy(); + expect(z.string().date().safeParse(str).success).toBeFalsy(); + expect(z.string().datetime().safeParse(str).success).toBeFalsy(); + expect(z.iso.date().safeParse(str).success).toBeFalsy(); + expect(z.iso.datetime().safeParse(str).success).toBeFalsy(); + }, + ); + }); + + /** + * @todo try z.int().max(1000) when it's fixed in Zod 4 + * @link https://github.com/colinhacks/zod/issues/4162 + */ + describe("Zod checks/refinements", () => { + test.each([ + z.string().email(), + z.email(), + z.number().int(), + z.int(), + z.int32(), + ])("Snapshot control $constructor.name definition", (schema) => { + const snapshot = R.omit(["id", "version"], schema._zod); + expect(snapshot).toMatchSnapshot(); + }); + + /** + * @link https://github.com/colinhacks/zod/issues/4162 + * @link https://github.com/colinhacks/zod/issues/4141 + * */ + test("This should fail when they fix it", () => { + expect(z.email()).not.toHaveProperty("max"); + }); + }); + + describe("Vitest error comparison", () => { + test("should distinguish error instances of different classes", () => { + expect(createHttpError(500, "some message")).not.toEqual( + new Error("some message"), + ); + }); + + test("should distinguish HTTP errors by status code and message", () => { + expect(createHttpError(400, "test")).not.toEqual( + createHttpError(500, "test"), + ); + expect(createHttpError(400, "one")).not.toEqual( + createHttpError(400, "two"), + ); + expect(createHttpError(400, new Error("one"))).not.toEqual( + createHttpError(400, new Error("two")), + ); + }); + + test("should distinguish error causes", () => { + expect(new Error("test", { cause: "one" })).not.toEqual( + new Error("test", { cause: "two" }), + ); + expect( + createHttpError(400, new Error("test", { cause: "one" })), + ).not.toEqual(createHttpError(400, new Error("test", { cause: "two" }))); + }); + }); +}); diff --git a/express-zod-api/tests/errors.spec.ts b/express-zod-api/tests/errors.spec.ts index cb4676fc5..4e6aa2334 100644 --- a/express-zod-api/tests/errors.spec.ts +++ b/express-zod-api/tests/errors.spec.ts @@ -1,4 +1,3 @@ -import createHttpError from "http-errors"; import { z } from "zod"; import { DocumentationError, RoutingError } from "../src"; import { @@ -10,35 +9,6 @@ import { } from "../src/errors"; describe("Errors", () => { - describe("environment check", () => { - test("should distinguish error instances of different classes", () => { - expect(createHttpError(500, "some message")).not.toEqual( - new Error("some message"), - ); - }); - - test("should distinguish HTTP errors by status code and message", () => { - expect(createHttpError(400, "test")).not.toEqual( - createHttpError(500, "test"), - ); - expect(createHttpError(400, "one")).not.toEqual( - createHttpError(400, "two"), - ); - expect(createHttpError(400, new Error("one"))).not.toEqual( - createHttpError(400, new Error("two")), - ); - }); - - test("should distinguish error causes", () => { - expect(new Error("test", { cause: "one" })).not.toEqual( - new Error("test", { cause: "two" }), - ); - expect( - createHttpError(400, new Error("test", { cause: "one" })), - ).not.toEqual(createHttpError(400, new Error("test", { cause: "two" }))); - }); - }); - describe("RoutingError", () => { const error = new RoutingError("test"); diff --git a/express-zod-api/tests/file-schema.spec.ts b/express-zod-api/tests/file-schema.spec.ts index 64039c49d..597d0a870 100644 --- a/express-zod-api/tests/file-schema.spec.ts +++ b/express-zod-api/tests/file-schema.spec.ts @@ -8,32 +8,32 @@ describe("ez.file()", () => { describe("creation", () => { test("should create an instance being string by default", () => { const schema = ez.file(); - expect(schema).toBeInstanceOf(z.ZodBranded); - expect(schema._def[metaSymbol]?.brand).toBe(ezFileBrand); + expect(schema).toBeInstanceOf(z.ZodString); + expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezFileBrand); }); test("should create a string file", () => { const schema = ez.file("string"); - expect(schema).toBeInstanceOf(z.ZodBranded); - expectTypeOf(schema._output).toBeString(); + expect(schema).toBeInstanceOf(z.ZodString); + expectTypeOf(schema._zod.output).toBeString(); }); test("should create a buffer file", () => { const schema = ez.file("buffer"); - expect(schema).toBeInstanceOf(z.ZodBranded); - expectTypeOf(schema._output).toExtend(); + expect(schema).toBeInstanceOf(z.ZodCustom); + expectTypeOf(schema._zod.output).toExtend(); }); test("should create a binary file", () => { const schema = ez.file("binary"); - expect(schema).toBeInstanceOf(z.ZodBranded); - expectTypeOf(schema._output).toExtend(); + expect(schema).toBeInstanceOf(z.ZodUnion); + expectTypeOf(schema._zod.output).toExtend(); }); test("should create a base64 file", () => { const schema = ez.file("base64"); - expect(schema).toBeInstanceOf(z.ZodBranded); - expectTypeOf(schema._output).toBeString(); + expect(schema).toBeInstanceOf(z.ZodString); + expectTypeOf(schema._zod.output).toBeString(); }); }); @@ -44,15 +44,13 @@ describe("ez.file()", () => { subject: 123, code: "invalid_type", expected: "string", - received: "number", - message: "Expected string, received number", + message: "Invalid input: expected string, received number", }, { schema: ez.file("buffer"), subject: "123", code: "custom", message: "Expected Buffer", - fatal: true, }, ])( "should invalidate wrong types", diff --git a/express-zod-api/tests/form-schema.spec.ts b/express-zod-api/tests/form-schema.spec.ts index ec573c58a..de2aa3768 100644 --- a/express-zod-api/tests/form-schema.spec.ts +++ b/express-zod-api/tests/form-schema.spec.ts @@ -9,10 +9,9 @@ describe("ez.form()", () => { "should create a branded object instance based on the argument %#", (base) => { const schema = ez.form(base); - expect(schema).toBeInstanceOf(z.ZodBranded); - expect(schema._def[metaSymbol]?.brand).toBe(ezFormBrand); - expect(schema.unwrap()).toBeInstanceOf(z.ZodObject); - expect(schema.unwrap().shape).toHaveProperty( + expect(schema).toBeInstanceOf(z.ZodObject); + expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezFormBrand); + expect(schema._zod.def.shape).toHaveProperty( "name", expect.any(z.ZodString), ); @@ -29,7 +28,7 @@ describe("ez.form()", () => { }); test("should accept extras when the base has .passthrough()", () => { - const schema = ez.form(z.object({ name: z.string() }).passthrough()); + const schema = ez.form(z.object({ name: z.string() }).loose()); expect(schema.parse({ name: "test", extra: "kept" })).toEqual({ name: "test", extra: "kept", diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index b2f487e49..0d35bd957 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -103,13 +103,7 @@ describe("Index Entrypoint", () => { expectTypeOf>() .toHaveProperty("label") .toEqualTypeOf<(value: string) => z.ZodDefault>(); - expectTypeOf({ - remap: () => - z.pipeline( - z.object({}).transform(() => ({})), - z.object({}), - ), - }).toExtend>>(); + expectTypeOf().toHaveProperty("remap"); }); }); }); diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index c721d5d56..063ce099d 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -1,5 +1,5 @@ import ts from "typescript"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { EndpointsFactory, Integration, @@ -120,9 +120,12 @@ describe("Integration", () => { describe("Feature #1470: Custom brands", () => { test("should by handled accordingly", async () => { const rule: Producer = ( - schema: z.ZodBranded, + schema: ReturnType, { next }, - ) => next(schema.unwrap()); + ) => { + globalRegistry.remove(schema); + return next(schema); + }; const client = new Integration({ variant: "types", brandHandling: { diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 55078d217..4fc63f548 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -11,12 +11,9 @@ describe("I/O Schema and related helpers", () => { describe("IOSchema", () => { test("accepts object", () => { expectTypeOf(z.object({})).toExtend(); - expectTypeOf(z.object({})).toExtend>(); - expectTypeOf(z.object({}).strict()).toExtend>(); - expectTypeOf(z.object({}).passthrough()).toExtend< - IOSchema<"passthrough"> - >(); - expectTypeOf(z.object({}).strip()).toExtend>(); + expectTypeOf(z.object({}).strip()).toExtend(); + expectTypeOf(z.object({}).strict()).toExtend(); + expectTypeOf(z.object({}).loose()).toExtend(); }); test("accepts ez.raw()", () => { expectTypeOf(ez.raw()).toExtend(); @@ -25,9 +22,6 @@ describe("I/O Schema and related helpers", () => { test("accepts ez.form()", () => { expectTypeOf(ez.form({})).toExtend(); }); - test("respects the UnknownKeys type argument", () => { - expectTypeOf(z.object({})).not.toExtend>(); - }); test("accepts union of objects", () => { expectTypeOf(z.union([z.object({}), z.object({})])).toExtend(); expectTypeOf(z.object({}).or(z.object({}))).toExtend(); @@ -44,14 +38,19 @@ describe("I/O Schema and related helpers", () => { expectTypeOf( z.intersection(z.object({}), z.object({})), ).toExtend(); - expectTypeOf(z.object({}).and(z.object({}))).toExtend(); expectTypeOf( - z.object({}).and(z.object({}).and(z.object({}))), + z.intersection(z.object({}), z.object({})), + ).toExtend(); + expectTypeOf( + z.intersection( + z.intersection(z.object({}), z.object({})), + z.object({}), + ), ).toExtend(); }); test("does not accepts intersection of object with array of objects", () => { expectTypeOf( - z.object({}).and(z.array(z.object({}))), + z.intersection(z.object({}), z.array(z.object({}))), ).not.toExtend(); }); test("accepts discriminated union of objects", () => { @@ -64,21 +63,18 @@ describe("I/O Schema and related helpers", () => { }); test("accepts a mix of types based on object", () => { expectTypeOf( - z.object({}).or(z.object({}).and(z.object({}))), + z.object({}).or(z.intersection(z.object({}), z.object({}))), ).toExtend(); expectTypeOf( - z.object({}).and(z.object({}).or(z.object({}))), + z.intersection(z.object({}), z.object({}).or(z.object({}))), ).toExtend(); }); describe("Feature #600: Top level refinements", () => { test("accepts a refinement of object", () => { expectTypeOf(z.object({}).refine(() => true)).toExtend(); - expectTypeOf(z.object({}).superRefine(() => true)).toExtend(); + expectTypeOf(z.object({}).check(() => void 0)).toExtend(); expectTypeOf( - z.object({}).refinement(() => true, { - code: "custom", - message: "test", - }), + z.object({}).refine(() => true, { error: "test" }), ).toExtend(); }); test("Issue 662: accepts nested refinements", () => { @@ -92,9 +88,9 @@ describe("I/O Schema and related helpers", () => { expectTypeOf( z .object({}) - .superRefine(() => true) - .superRefine(() => true) - .superRefine(() => true), + .check(() => void 0) + .check(() => void 0) + .check(() => void 0), ).toExtend(); }); }); @@ -128,19 +124,16 @@ describe("I/O Schema and related helpers", () => { expectTypeOf( z.object({}).transform(() => true), ).not.toExtend(); + expectTypeOf(z.object({}).transform(String)).not.toExtend(); expectTypeOf(z.object({}).transform(() => [])).not.toExtend(); }); test("does not accept piping into another kind of schema", () => { - expectTypeOf( - z.object({ s: z.string() }).pipe(z.array(z.string())), - ).not.toExtend(); - }); - test("does not accept nested piping", () => { + expectTypeOf(z.unknown({}).pipe(z.string())).not.toExtend(); expectTypeOf( z - .object({ a: z.string() }) - .remap({ a: "b" }) - .pipe(z.object({ b: z.string() })), + .object({ s: z.string() }) + .transform(Object.values) + .pipe(z.array(z.string())), ).not.toExtend(); }); }); @@ -228,17 +221,17 @@ describe("I/O Schema and related helpers", () => { test("Zod Issue #600: can not intersect object schema with passthrough and transformation", () => { // @see https://github.com/colinhacks/zod/issues/600 - // this is the reason why IOSchema is generic and middlewares have to be "strip" - const left = z.object({}).passthrough(); + // Limitation: IOSchema in middlewares must be of "strip" kind + const left = z.looseObject({}); const right = z.object({ id: z.string().transform((str) => parseInt(str)), }); const schema = z.intersection(left, right); - const result = schema.safeParse({ - id: "123", - }); - expect(result.success).toBeFalsy(); - expect(result.error).toMatchSnapshot(); + expect(() => + schema.parse({ + id: "123", + }), + ).toThrowErrorMatchingSnapshot(); }); test("Should merge mixed object schemas", () => { @@ -285,7 +278,7 @@ describe("I/O Schema and related helpers", () => { .object({ five: z.string() }) .example({ five: "some" }); const result = getFinalEndpointInputSchema(middlewares, endpointInput); - expect(result._def[metaSymbol]?.examples).toEqual([ + expect(result.meta()?.[metaSymbol]?.examples).toEqual([ { one: "test", two: 123, @@ -345,5 +338,13 @@ describe("I/O Schema and related helpers", () => { expect(subject).toMatchSnapshot(); }); }); + + describe("Zod 4", () => { + test("should throw for incompatible ones", () => { + expect(() => + extractObjectSchema(z.string() as unknown as IOSchema), + ).toThrowErrorMatchingSnapshot(); + }); + }); }); }); diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts index febd804a2..36b32d4c2 100644 --- a/express-zod-api/tests/metadata.spec.ts +++ b/express-zod-api/tests/metadata.spec.ts @@ -8,16 +8,16 @@ describe("Metadata", () => { const dest = z.number(); const result = copyMeta(src, dest); expect(result).toEqual(dest); - expect(result._def[metaSymbol]).toBeFalsy(); - expect(dest._def[metaSymbol]).toBeFalsy(); + expect(result.meta()?.[metaSymbol]).toBeFalsy(); + expect(dest.meta()?.[metaSymbol]).toBeFalsy(); }); test("should copy meta from src to dest in case meta is defined", () => { const src = z.string().example("some"); const dest = z.number(); const result = copyMeta(src, dest); - expect(result._def[metaSymbol]).toBeTruthy(); - expect(result._def[metaSymbol]?.examples).toEqual( - src._def[metaSymbol]?.examples, + expect(result.meta()?.[metaSymbol]).toBeTruthy(); + expect(result.meta()?.[metaSymbol]?.examples).toEqual( + src.meta()?.[metaSymbol]?.examples, ); }); @@ -32,8 +32,8 @@ describe("Metadata", () => { .example({ b: 456 }) .example({ b: 789 }); const result = copyMeta(src, dest); - expect(result._def[metaSymbol]).toBeTruthy(); - expect(result._def[metaSymbol]?.examples).toEqual([ + expect(result.meta()?.[metaSymbol]).toBeTruthy(); + expect(result.meta()?.[metaSymbol]?.examples).toEqual([ { a: "some", b: 123 }, { a: "another", b: 123 }, { a: "some", b: 456 }, @@ -54,8 +54,8 @@ describe("Metadata", () => { .example({ a: { c: 456 } }) .example({ a: { c: 789 } }); const result = copyMeta(src, dest); - expect(result._def[metaSymbol]).toBeTruthy(); - expect(result._def[metaSymbol]?.examples).toEqual([ + expect(result.meta()?.[metaSymbol]).toBeTruthy(); + expect(result.meta()?.[metaSymbol]?.examples).toEqual([ { a: { b: "some", c: 123 } }, { a: { b: "another", c: 123 } }, { a: { b: "some", c: 456 } }, @@ -71,7 +71,7 @@ describe("Metadata", () => { .object({ items: z.array(z.string()) }) .example({ items: ["e", "f", "g"] }); const result = copyMeta(src, dest); - expect(result._def[metaSymbol]?.examples).toEqual(["a", "b"]); + expect(result.meta()?.[metaSymbol]?.examples).toEqual(["a", "b"]); }); }); }); diff --git a/express-zod-api/tests/middleware.spec.ts b/express-zod-api/tests/middleware.spec.ts index cf60cd7f3..329e1c306 100644 --- a/express-zod-api/tests/middleware.spec.ts +++ b/express-zod-api/tests/middleware.spec.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { InputValidationError, Middleware } from "../src"; -import { EmptyObject } from "../src/common-helpers"; import { AbstractMiddleware, ExpressMiddleware } from "../src/middleware"; import { makeLoggerMock, @@ -16,14 +15,15 @@ describe("Middleware", () => { handler: vi.fn(), }); expect(mw).toBeInstanceOf(AbstractMiddleware); - expectTypeOf(mw.schema._output).toEqualTypeOf<{ + expectTypeOf(mw.schema._zod.output).toEqualTypeOf<{ something: number; }>(); }); test("should allow to omit input schema", () => { const mw = new Middleware({ handler: vi.fn() }); - expectTypeOf(mw.schema._output).toEqualTypeOf(); + /** @see $InferObjectOutput - external logic */ + expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); }); describe("#600: Top level refinements", () => { @@ -32,7 +32,7 @@ describe("Middleware", () => { input: z.object({ something: z.number() }).refine(() => true), handler: vi.fn(), }); - expect(mw.schema).toBeInstanceOf(z.ZodEffects); + expect(mw.schema).toBeInstanceOf(z.ZodObject); }); }); }); @@ -87,6 +87,7 @@ describe("ExpressMiddleware", () => { test("should inherit from Middleware", () => { const mw = new ExpressMiddleware(vi.fn()); expect(mw).toBeInstanceOf(Middleware); - expectTypeOf(mw.schema._output).toEqualTypeOf(); + /** @see $InferObjectOutput - external logic */ + expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); }); }); diff --git a/express-zod-api/tests/raw-schema.spec.ts b/express-zod-api/tests/raw-schema.spec.ts index 764dbef3b..c308f54fb 100644 --- a/express-zod-api/tests/raw-schema.spec.ts +++ b/express-zod-api/tests/raw-schema.spec.ts @@ -7,25 +7,28 @@ describe("ez.raw()", () => { describe("creation", () => { test("should be an instance of branded object", () => { const schema = ez.raw(); - expect(schema).toBeInstanceOf(z.ZodBranded); - expect(schema._def[metaSymbol]?.brand).toBe(ezRawBrand); + expect(schema).toBeInstanceOf(z.ZodObject); + expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezRawBrand); }); }); describe("types", () => { test("without extension", () => { const schema = ez.raw(); - expectTypeOf(schema._output).toExtend<{ raw: Buffer }>(); + expectTypeOf(schema._zod.output).toExtend<{ raw: Buffer }>(); }); test("with empty extension", () => { const schema = ez.raw({}); - expectTypeOf(schema._output).toExtend<{ raw: Buffer }>(); + expectTypeOf(schema._zod.output).toExtend<{ raw: Buffer }>(); }); test("with populated extension", () => { const schema = ez.raw({ extra: z.number() }); - expectTypeOf(schema._output).toExtend<{ raw: Buffer; extra: number }>(); + expectTypeOf(schema._zod.output).toExtend<{ + raw: Buffer; + extra: number; + }>(); }); }); diff --git a/express-zod-api/tests/result-handler.spec.ts b/express-zod-api/tests/result-handler.spec.ts index 69873f859..79a1c74da 100644 --- a/express-zod-api/tests/result-handler.spec.ts +++ b/express-zod-api/tests/result-handler.spec.ts @@ -118,7 +118,7 @@ describe("ResultHandler", () => { message: "Expected string, got number", path: ["something"], expected: "string", - received: "number", + input: 453, }, ]), ), @@ -197,13 +197,13 @@ describe("ResultHandler", () => { }), ); expect(apiResponse).toHaveLength(1); - expect(apiResponse[0].schema._def[metaSymbol]).toMatchSnapshot(); + expect(apiResponse[0].schema.meta()?.[metaSymbol]).toMatchSnapshot(); }); test("should generate negative response example", () => { const apiResponse = subject.getNegativeResponse(); expect(apiResponse).toHaveLength(1); - expect(apiResponse[0].schema._def[metaSymbol]).toMatchSnapshot(); + expect(apiResponse[0].schema.meta()?.[metaSymbol]).toMatchSnapshot(); }); }); @@ -215,10 +215,10 @@ describe("ResultHandler", () => { z.object({ anything: z.number() }).example({ anything: 118 }), ) .pop()?.schema; - expect(positiveSchema?._def).toHaveProperty("typeName", "ZodArray"); + expect(positiveSchema).toHaveProperty(["_zod", "def", "type"], "array"); expect(positiveSchema).toHaveProperty( - ["element", "_def", "typeName"], - "ZodAny", + ["_zod", "def", "element", "_zod", "def", "type"], + "any", ); expect(() => arrayResultHandler.execute({ diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index d600e33c8..d4baabe94 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -393,15 +393,19 @@ describe("Routing", () => { }); }); + const circular: z.ZodType = z.lazy(() => z.tuple([circular, z.nan()])); test.each([ [z.bigint(), z.set(z.string())], [z.nan(), z.map(z.string(), z.boolean())], - [z.date().pipe(z.string()), z.symbol().catch(Symbol("test"))], - [z.function().transform(() => "test"), z.tuple([z.function()])], + [ + z.date().transform(String).pipe(z.string()), + z.symbol().catch(Symbol("test")), + ], [ez.dateOut(), ez.dateIn()], [z.lazy(() => z.void()), ez.raw()], [z.promise(z.any()), ez.upload()], [z.never(), z.tuple([ez.file()]).rest(z.nan())], + [z.nan().pipe(z.any()), circular], ])("should warn about JSON incompatible schemas %#", (input, output) => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ input: z.object({ input }), @@ -428,6 +432,35 @@ describe("Routing", () => { ]); }); + test.each([ + [z.string().array(), z.string()], + [z.lazy(() => z.number()), z.object({}).pipe(z.array(z.string()))], + ])("should warn about non-object based schemas I/O %#", (input, output) => { + const endpoint = new EndpointsFactory(defaultResultHandler).build({ + input: input as unknown as z.ZodObject, + output: output as unknown as z.ZodObject, + handler: vi.fn(), + }); + const configMock = { cors: false, startupLogo: false }; + const logger = makeLoggerMock(); + initRouting({ + app: appMock as unknown as IRouter, + getLogger: () => logger, + config: configMock as CommonConfig, + routing: { path: endpoint }, + }); + expect(logger._getLogs().warn).toEqual([ + [ + "Endpoint input schema is not object-based", + { method: "get", path: "/path" }, + ], + [ + "Endpoint output schema is not object-based", + { method: "get", path: "/path" }, + ], + ]); + }); + test("should warn about unused path params", () => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ input: z.object({ id: z.string() }), diff --git a/express-zod-api/tests/schema-helpers.spec.ts b/express-zod-api/tests/schema-helpers.spec.ts deleted file mode 100644 index 732cddac3..000000000 --- a/express-zod-api/tests/schema-helpers.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { isValidDate } from "../src/schema-helpers"; - -describe("Schema helpers", () => { - describe("isValidDate()", () => { - test("should accept valid date", () => { - expect(isValidDate(new Date())).toBeTruthy(); - expect(isValidDate(new Date("2021-01-31"))).toBeTruthy(); - expect(isValidDate(new Date("12.01.2022"))).toBeTruthy(); - expect(isValidDate(new Date("01/22/2022"))).toBeTruthy(); - }); - - test("should handle invalid date", () => { - expect(isValidDate(new Date("2021-01-32"))).toBeFalsy(); - expect(isValidDate(new Date("22/01/2022"))).toBeFalsy(); - expect(isValidDate(new Date("2021-01-31T25:00:00.000Z"))).toBeFalsy(); - }); - }); -}); diff --git a/express-zod-api/tests/system.spec.ts b/express-zod-api/tests/system.spec.ts index 1ddc1aab4..e4ed72992 100644 --- a/express-zod-api/tests/system.spec.ts +++ b/express-zod-api/tests/system.spec.ts @@ -83,7 +83,7 @@ describe("App in production mode", async () => { .build({ method: ["get", "post"], input: z.object({ something: z.string() }), - output: z.object({ anything: z.number().positive() }).passthrough(), // allow excessive keys + output: z.looseObject({ anything: z.number().positive() }), // allow excessive keys handler: async ({ input: { key, something }, options: { user, permissions, method }, diff --git a/express-zod-api/tests/upload-schema.spec.ts b/express-zod-api/tests/upload-schema.spec.ts index a314e49bc..eb58f093d 100644 --- a/express-zod-api/tests/upload-schema.spec.ts +++ b/express-zod-api/tests/upload-schema.spec.ts @@ -7,8 +7,8 @@ describe("ez.upload()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.upload(); - expect(schema).toBeInstanceOf(z.ZodBranded); - expect(schema._def[metaSymbol]?.brand).toBe(ezUploadBrand); + expect(schema).toBeInstanceOf(z.ZodCustom); + expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezUploadBrand); }); }); @@ -21,7 +21,6 @@ describe("ez.upload()", () => { expect(result.error.issues).toEqual([ { code: "custom", - fatal: true, message: "Expected file upload, received number", path: [], }, diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index dbcc9708a..60fc22eab 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -13,7 +13,7 @@ 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._def[metaSymbol]).toHaveProperty("examples", [ + expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty("examples", [ "test", ]); }); @@ -21,8 +21,15 @@ describe("Zod Runtime Plugin", () => { test("Issue 827: should be immutable", () => { const schema = z.string(); const schemaWithExample = schema.example("test"); - expect(schemaWithExample._def[metaSymbol]?.examples).toEqual(["test"]); - expect(schema._def[metaSymbol]).toBeUndefined(); + expect(schemaWithExample.meta()?.[metaSymbol]?.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", + ]); }); test("can be used multiple times", () => { @@ -31,7 +38,7 @@ describe("Zod Runtime Plugin", () => { .example("test1") .example("test2") .example("test3"); - expect(schemaWithMeta._def[metaSymbol]?.examples).toEqual([ + expect(schemaWithMeta.meta()?.[metaSymbol]?.examples).toEqual([ "test1", "test2", "test3", @@ -41,8 +48,10 @@ describe("Zod Runtime Plugin", () => { test("should withstand refinements", () => { const schema = z.string(); const schemaWithMeta = schema.example("test"); - expect(schemaWithMeta._def[metaSymbol]?.examples).toEqual(["test"]); - expect(schemaWithMeta.email()._def[metaSymbol]).toEqual({ + expect(schemaWithMeta.meta()?.[metaSymbol]?.examples).toEqual(["test"]); + expect( + schemaWithMeta.regex(/@example.com$/).meta()?.[metaSymbol], + ).toEqual({ examples: ["test"], }); }); @@ -58,7 +67,7 @@ describe("Zod Runtime Plugin", () => { test("should set the corresponding metadata in the schema definition", () => { const schema = z.string(); const schemaWithMeta = schema.deprecated(); - expect(schemaWithMeta._def[metaSymbol]).toHaveProperty( + expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty( "isDeprecated", true, ); @@ -67,12 +76,10 @@ describe("Zod Runtime Plugin", () => { describe(".label()", () => { test("should set the corresponding metadata in the schema definition", () => { - const schema = z - .string() - .datetime() - .default(() => new Date().toISOString()); + const schema = z.iso.datetime().default(() => new Date().toISOString()); + expect(schema).toHaveProperty("label"); const schemaWithMeta = schema.label("Today"); - expect(schemaWithMeta._def[metaSymbol]).toHaveProperty( + expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty( "defaultLabel", "Today", ); @@ -81,7 +88,9 @@ describe("Zod Runtime Plugin", () => { describe(".brand()", () => { test("should set the brand", () => { - expect(z.string().brand("test")._def[metaSymbol]?.brand).toEqual("test"); + expect(z.string().brand("test").meta()?.[metaSymbol]?.brand).toEqual( + "test", + ); }); }); @@ -89,11 +98,13 @@ describe("Zod Runtime Plugin", () => { test("should transform and pipe the object schema keys", () => { const schema = z.object({ user_id: z.string() }); const mappedSchema = schema.remap({ user_id: "userId" }); - expect(mappedSchema._def.in._def.schema).toEqual(schema); - expect(mappedSchema._def.out.shape).toEqual({ + expect(mappedSchema.in.in).toEqual(schema); + expect(mappedSchema.out.shape).toEqual({ userId: schema.shape.user_id, }); - expect(mappedSchema._def.out.shape.userId).not.toBe(schema.shape.user_id); + expect(mappedSchema._zod.def.out.shape.userId).not.toBe( + schema.shape.user_id, + ); expect(mappedSchema.parse({ user_id: "test" })).toEqual({ userId: "test", }); @@ -104,7 +115,7 @@ describe("Zod Runtime Plugin", () => { (mapping) => { const schema = z.object({ user_id: z.string(), name: z.string() }); const mappedSchema = schema.remap(mapping); - expect(mappedSchema._def.out.shape).toEqual({ + expect(mappedSchema._zod.def.out.shape).toEqual({ userId: schema.shape.user_id, name: schema.shape.name, }); @@ -118,7 +129,7 @@ describe("Zod Runtime Plugin", () => { test("should support a mapping function", () => { const schema = z.object({ user_id: z.string(), name: z.string() }); const mappedSchema = schema.remap((shape) => camelize(shape, true)); - expect(mappedSchema._def.out.shape).toEqual({ + expect(mappedSchema._zod.def.out.shape).toEqual({ userId: schema.shape.user_id, name: schema.shape.name, }); @@ -129,7 +140,7 @@ describe("Zod Runtime Plugin", () => { }); test("should support passthrough object schemas", () => { - const schema = z.object({ user_id: z.string() }).passthrough(); + const schema = z.looseObject({ user_id: z.string() }); const mappedSchema = schema.remap({ user_id: "userId" }); expect( mappedSchema.parse({ user_id: "test", extra: "excessive" }), diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index c4ef50a9e..a71d77298 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -64,9 +64,9 @@ describe("zod-to-ts", () => { } test.each([ - { schema: z.nativeEnum(Color), feature: "numeric" }, - { schema: z.nativeEnum(Fruit), feature: "string" }, - { schema: z.nativeEnum(StringLiteral), feature: "quoted string" }, + { schema: z.enum(Color), feature: "numeric" }, + { schema: z.enum(Fruit), feature: "string" }, + { schema: z.enum(StringLiteral), feature: "quoted string" }, ])("handles $feature literals", ({ schema }) => { expect(printNodeTest(zodToTs(schema, { ctx }))).toMatchSnapshot(); }); @@ -92,7 +92,7 @@ describe("zod-to-ts", () => { }) .partial(); - const circular: z.ZodLazy = z.lazy(() => + const circular: z.ZodLazy = z.lazy(() => z.object({ a: z.string(), b: circular, @@ -112,9 +112,10 @@ describe("zod-to-ts", () => { union: z.union([z.object({ number: z.number() }), z.literal("hi")]), enum: z.enum(["hi", "bye"]), intersectionWithTransform: z - .number() - .and(z.bigint()) - .and(z.number().and(z.string())) + .intersection( + z.intersection(z.number(), z.bigint()), + z.intersection(z.number(), z.string()), + ) .transform((arg) => console.log(arg)), date: z.date(), undefined: z.undefined(), @@ -132,6 +133,7 @@ describe("zod-to-ts", () => { ]), tupleRest: z.tuple([z.string(), z.number()]).rest(z.boolean()), record: z.record( + z.string(), z.object({ object: z.object({ arrayOfUnions: z @@ -147,17 +149,15 @@ describe("zod-to-ts", () => { set: z.set(z.string()), intersection: z.intersection(z.string(), z.number()).or(z.bigint()), promise: z.promise(z.number()), - function: z - .function() - .args(z.string().nullish().default("heo"), z.boolean(), z.boolean()) - .returns(z.string()), optDefaultString: z.string().optional().default("hi"), - refinedStringWithSomeBullshit: z - .string() - .refine((val) => val.length > 10) - .or(z.number()) - .and(z.bigint().nullish().default(1000n)), - nativeEnum: z.nativeEnum(Fruits), + refinedStringWithSomeBullshit: z.intersection( + z + .string() + .refine((val) => val.length > 10) + .or(z.number()), + z.bigint().nullish().default(1000n), + ), + nativeEnum: z.enum(Fruits), lazy: z.lazy(() => z.string()), discUnion: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("circle"), radius: z.number() }), @@ -166,7 +166,7 @@ describe("zod-to-ts", () => { ]), branded: z.string().brand("BRAND"), catch: z.number().catch(123), - pipeline: z.string().regex(/\d+/).pipe(z.coerce.number()), + pipeline: z.string().regex(/\d+/).transform(Number).pipe(z.number()), readonly: z.string().readonly(), }); @@ -286,11 +286,20 @@ describe("zod-to-ts", () => { describe("Issue #2352: intersection of objects having same prop %#", () => { test.each([ [z.string(), z.string()], - [z.string().nonempty(), z.string().email()], - [z.string().transform(Number), z.string().pipe(z.coerce.date())], + [z.string().nonempty(), z.email()], + [ + z.string().transform(Number), + z + .string() + .transform((str) => new Date(str)) + .pipe(z.date()), + ], [z.object({}), z.object({})], ])("should deduplicate the prop with a same name", (a, b) => { - const schema = z.object({ query: a }).and(z.object({ query: b })); + const schema = z.intersection( + z.object({ query: a }), + z.object({ query: b }), + ); const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -315,7 +324,7 @@ describe("zod-to-ts", () => { ])( "should not flatten the result for objects with a conflicting prop %#", (a, b) => { - const node = zodToTs(a.and(b), { ctx }); + const node = zodToTs(z.intersection(a, b), { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }, ); @@ -360,6 +369,7 @@ describe("zod-to-ts", () => { z.literal(true), z.literal(false), z.literal(123), + z.literal(undefined), ])("Should produce the correct typescript %#", (schema) => { expect(printNodeTest(zodToTs(schema, { ctx }))).toMatchSnapshot(); }); diff --git a/express-zod-api/vitest.setup.ts b/express-zod-api/vitest.setup.ts index 37c5a1df6..b7c867470 100644 --- a/express-zod-api/vitest.setup.ts +++ b/express-zod-api/vitest.setup.ts @@ -1,6 +1,6 @@ import "./src/zod-plugin"; // required for tests importing sources using the plugin methods import type { NewPlugin } from "@vitest/pretty-format"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { ResultHandlerError } from "./src/errors"; import { metaSymbol } from "./src/metadata"; @@ -19,52 +19,26 @@ const errorSerializer: NewPlugin = { }, }; -const makeSchemaSerializer = < - C extends z.ZodType, - T extends { new (...args: any[]): C }[], ->( - subject: T | T[number], - fn: (subject: C) => object, -): NewPlugin => { - const classes = Array.isArray(subject) ? subject : [subject]; - return { - test: (entity) => classes.some((Cls) => entity instanceof Cls), - serialize: (entity, config, indentation, depth, refs, printer) => { - const obj = Object.assign(fn(entity as C), { - _type: entity._def.typeName, - }); - return printer(obj, config, indentation, depth, refs); - }, - }; +const schemaSerializer: NewPlugin = { + test: (subject) => subject instanceof z.ZodType, + serialize: (entity: z.ZodType, config, indentation, depth, refs, printer) => { + const serialization = z.toJSONSchema(entity, { + unrepresentable: "any", + override: ({ zodSchema, jsonSchema }) => { + if (zodSchema._zod.def.type === "custom") { + jsonSchema["x-brand"] = globalRegistry + .get(zodSchema) + ?.[metaSymbol]?.brand?.toString(); + } + }, + }); + return printer(serialization, config, indentation, depth, refs); + }, }; /** * @see https://github.com/vitest-dev/vitest/issues/5697 * @see https://vitest.dev/guide/snapshot.html#custom-serializer */ -const serializers = [ - errorSerializer, - makeSchemaSerializer(z.ZodObject, ({ shape }) => ({ shape })), - makeSchemaSerializer(z.ZodLiteral, ({ value }) => ({ value })), - makeSchemaSerializer(z.ZodIntersection, ({ _def: { left, right } }) => ({ - left, - right, - })), - makeSchemaSerializer(z.ZodUnion, ({ options }) => ({ options })), - makeSchemaSerializer( - z.ZodDiscriminatedUnion, - ({ options, discriminator }) => ({ discriminator, options }), - ), - makeSchemaSerializer(z.ZodEffects, ({ _def: { schema: value } }) => ({ - value, - })), - makeSchemaSerializer(z.ZodOptional, (schema) => ({ value: schema.unwrap() })), - makeSchemaSerializer(z.ZodBranded, ({ _def }) => ({ - brand: _def[metaSymbol]?.brand, - })), - makeSchemaSerializer( - [z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull], - () => ({}), - ), -]; +const serializers = [errorSerializer, schemaSerializer]; for (const serializer of serializers) expect.addSnapshotSerializer(serializer); diff --git a/package.json b/package.json index 83e88178a..4f838214a 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.29.1", "vitest": "^3.1.1", - "zod": "^3.23.0" + "zod": "^4.0.0-beta.20250417T043022" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index d63233ede..1edd1bc3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -832,6 +832,11 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" +"@zod/core@0.6.2": + version "0.6.2" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.6.2.tgz#0c87b9b06b43c1b7bc44e2b8341973ba080b1282" + integrity sha512-KdH7bT0BRG1CvJ1LWH8oyNnkvLpjVZ5qVGpRu7Vq8WsFTKRDWfdr3rFfBYh8atZJSWDgD0ibhOyff1AyRvG1DA== + accepts@^1.3.7: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -3080,7 +3085,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.23.0: - version "3.24.3" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87" - integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== +zod@^4.0.0-beta.20250417T043022: + version "4.0.0-beta.20250417T043022" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250417T043022.tgz#cad651d63ea665df6a01389102785f8bbccd92a1" + integrity sha512-zjfYudLXPgHvRdCWzy/iJqhB6suE8tBqnGubbFHSkMvcknI4iexEP53QCO13FoC/EIALseuZReVykCY8yd/skA== + dependencies: + "@zod/core" "0.6.2" From c52677653068a9487bd57da60e162fe6003c1778 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 18 Apr 2025 09:49:02 +0200 Subject: [PATCH 003/187] Upgrading zod, core 0.7.1. --- .../tests/__snapshots__/env.spec.ts.snap | 15 ++++++++++++--- express-zod-api/tests/env.spec.ts | 2 +- package.json | 2 +- yarn.lock | 18 +++++++++--------- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 1f9fda211..e17955906 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -16,7 +16,10 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmai "type": "string", }, "deferred": [], - "onattach": [Function], + "onattach": [ + [Function], + [Function], + ], "parse": [Function], "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, "run": [Function], @@ -28,6 +31,8 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmai "$ZodCheck", "$ZodString", "$ZodType", + "ZodStringFormat", + "ZodString", "ZodType", }, } @@ -82,7 +87,9 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "type": "number", }, "deferred": [], - "onattach": [Function], + "onattach": [ + [Function], + ], "parse": [Function], "pattern": /\\^\\\\d\\+\\$/, "run": [Function], @@ -114,7 +121,9 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "type": "number", }, "deferred": [], - "onattach": [Function], + "onattach": [ + [Function], + ], "parse": [Function], "pattern": /\\^\\\\d\\+\\$/, "run": [Function], diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 60dd39118..46639c95f 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -37,7 +37,7 @@ describe("Environment checks", () => { * @link https://github.com/colinhacks/zod/issues/4141 * */ test("This should fail when they fix it", () => { - expect(z.email()).not.toHaveProperty("max"); + expect(z.int()).not.toHaveProperty("max"); }); }); diff --git a/package.json b/package.json index 4f838214a..d7c85388c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.29.1", "vitest": "^3.1.1", - "zod": "^4.0.0-beta.20250417T043022" + "zod": "^4.0.0-beta.20250418T073619" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 1edd1bc3d..262c81c4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -832,10 +832,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.6.2.tgz#0c87b9b06b43c1b7bc44e2b8341973ba080b1282" - integrity sha512-KdH7bT0BRG1CvJ1LWH8oyNnkvLpjVZ5qVGpRu7Vq8WsFTKRDWfdr3rFfBYh8atZJSWDgD0ibhOyff1AyRvG1DA== +"@zod/core@0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.7.1.tgz#974c954ec03ab2943cbb321d510b64875c798d8d" + integrity sha512-5mV2cmDlJyKNlRGinyg2Lgn4m3CvNItWW8BcolrEvekfQP/y+2G1udVFnRBGJt4QqSHVADGyzxm3bBffPGGf+g== accepts@^1.3.7: version "1.3.8" @@ -3085,9 +3085,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250417T043022: - version "4.0.0-beta.20250417T043022" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250417T043022.tgz#cad651d63ea665df6a01389102785f8bbccd92a1" - integrity sha512-zjfYudLXPgHvRdCWzy/iJqhB6suE8tBqnGubbFHSkMvcknI4iexEP53QCO13FoC/EIALseuZReVykCY8yd/skA== +zod@^4.0.0-beta.20250418T073619: + version "4.0.0-beta.20250418T073619" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250418T073619.tgz#beba59f7239d7368ae2c963f1cec2c32a29bbab4" + integrity sha512-IdLjh8m+/vaSdPbRbQHGV8ei6EgFhCIhaPNwkFlv/KoCG2dMRmysmgXO7oHWKMxk3RVoQhtMANFP2H6d5MMflg== dependencies: - "@zod/core" "0.6.2" + "@zod/core" "0.7.1" From c42bbd072e7d4757b2ac89042a5af4bdbd5524f5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 18 Apr 2025 10:30:09 +0200 Subject: [PATCH 004/187] Migrating previously faulty shorthands. --- .../tests/__snapshots__/documentation.spec.ts.snap | 2 +- express-zod-api/tests/documentation-helpers.spec.ts | 4 ++-- express-zod-api/tests/documentation.spec.ts | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 0d466c27a..cf6c12df5 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1604,9 +1604,9 @@ paths: combined: type: string minLength: 1 - format: email pattern: .*@example\\.com maxLength: 90 + format: email required: - regular - min diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index ec1807adc..86711cc1d 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -461,8 +461,8 @@ describe("Documentation helpers", () => { }); test.each([ - z.string().email().min(10).max(20), - z.string().url().length(15), + z.email().min(10).max(20), + z.url().length(15), z.uuid(), z.cuid(), z.iso.datetime(), diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 2968cc880..5910bcf2b 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -379,9 +379,8 @@ describe("Documentation", () => { url: z.url(), numeric: z.string().regex(/\d+/), combined: z - .string() - .min(1) .email() + .min(1) .regex(/.*@example\.com/is) .max(90), }), From b60b4496be87fb5235878d5887f47e86b73e1d52 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 18 Apr 2025 12:23:17 +0200 Subject: [PATCH 005/187] Moving `deprecated` to the top level of global registry (#2549) `deprecated` is a valid JSON Schema and OpenAPI prop. Therefore it should be stored directly there. --- express-zod-api/src/documentation-helpers.ts | 4 ++-- express-zod-api/src/metadata.ts | 1 - express-zod-api/src/zod-plugin.ts | 8 +++----- express-zod-api/src/zts.ts | 6 +++--- express-zod-api/tests/zod-plugin.spec.ts | 5 +---- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 86a49e853..2c2241798 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -706,7 +706,7 @@ export const depictRequestParams = ({ return acc.concat({ name, in: location, - deprecated: globalRegistry.get(paramSchema)?.[metaSymbol]?.isDeprecated, + deprecated: globalRegistry.get(paramSchema)?.deprecated, required: !(paramSchema as z.ZodType).isOptional(), description: depicted.description || description, schema: result, @@ -769,7 +769,7 @@ export const onEach: SchemaHandler< schema.isNullable(); const result: SchemaObject = {}; if (description) result.description = description; - if (schema.meta()?.[metaSymbol]?.isDeprecated) result.deprecated = true; + if (schema.meta()?.deprecated) result.deprecated = true; if (isActuallyNullable) result.type = makeNullableType(prev); if (!shouldAvoidParsing) { const examples = getExamples({ diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index a0a67db2c..cca360202 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -9,7 +9,6 @@ export interface Metadata { /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; - isDeprecated?: boolean; } export const copyMeta = ( diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 5ea7901a4..e6d893c4e 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -17,6 +17,7 @@ import type { $ZodType, $ZodShape } from "@zod/core"; declare module "@zod/core" { interface GlobalMeta { [metaSymbol]?: Metadata; + deprecated?: boolean; } } @@ -66,11 +67,8 @@ const exampleSetter = function (this: z.ZodType, value: z.input) { const deprecationSetter = function (this: z.ZodType) { return this.meta({ description: this.description, - [metaSymbol]: { - examples: [], - ...this.meta()?.[metaSymbol], - isDeprecated: true, - }, + deprecated: true, + [metaSymbol]: this.meta()?.[metaSymbol], }); }; diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 867e889d1..c24e57e1f 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -22,7 +22,6 @@ import { hasCoercion, getTransformedType } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { ezFileBrand, FileSchema } from "./file-schema"; -import { metaSymbol } from "./metadata"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand, RawSchema } from "./raw-schema"; import { FirstPartyKind, HandlingRules, walkSchema } from "./schema-walker"; @@ -83,11 +82,12 @@ const onObject: Producer = ( : value instanceof z.ZodPromise ? false : (value as z.ZodType).isOptional(); - const { description: comment, ...meta } = globalRegistry.get(value) || {}; + const { description: comment, deprecated: isDeprecated } = + globalRegistry.get(value) || {}; return makeInterfaceProp(key, next(value), { comment, + isDeprecated, isOptional: isOptional && hasQuestionMark, - isDeprecated: meta[metaSymbol]?.isDeprecated, }); }, ); diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index 60fc22eab..d10f90bb4 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -67,10 +67,7 @@ describe("Zod Runtime Plugin", () => { test("should set the corresponding metadata in the schema definition", () => { const schema = z.string(); const schemaWithMeta = schema.deprecated(); - expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty( - "isDeprecated", - true, - ); + expect(schemaWithMeta.meta()).toHaveProperty("deprecated", true); }); }); From 11f5bc3e09e2e7d86dc8ac65b398fd07109cde7e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 19 Apr 2025 09:53:33 +0200 Subject: [PATCH 006/187] Updating zod, core 0.8.0, external issue with circular ZodLazy, worked around. --- example/endpoints/retrieve-user.ts | 9 ++++++++- express-zod-api/tests/documentation.spec.ts | 11 +++++++---- package.json | 2 +- yarn.lock | 18 +++++++++--------- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index fed89cefe..2b61fbcbd 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -5,13 +5,20 @@ import { defaultEndpointsFactory } from "express-zod-api"; import { methodProviderMiddleware } from "../middlewares"; // Demonstrating circular schemas using z.lazy() +// @todo switch to using z.interface instead const baseFeature = z.object({ title: z.string(), }); type Feature = z.infer & { features: Feature[]; }; -const feature: z.ZodType = baseFeature.extend({ +/** + * External issue in core 0.8.0 + * @link https://github.com/colinhacks/zod/issues/4234 + * @todo remove let when fixed + */ +let feature = baseFeature as unknown as z.ZodType; +feature = baseFeature.extend({ features: z.lazy(() => feature.array()), }); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 5910bcf2b..16f328181 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -486,10 +486,13 @@ describe("Documentation", () => { const baseCategorySchema = z.object({ name: z.string(), }); - type Category = z.infer & { - subcategories: Category[]; - }; - const categorySchema: z.ZodType = baseCategorySchema.extend({ + /** + * External issue in core 0.8.0 + * @link https://github.com/colinhacks/zod/issues/4234 + * @todo remove let when fixed + */ + let categorySchema = baseCategorySchema; + categorySchema = baseCategorySchema.extend({ subcategories: z.lazy(() => categorySchema.array()), }); const spec = new Documentation({ diff --git a/package.json b/package.json index d7c85388c..54efc1117 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.29.1", "vitest": "^3.1.1", - "zod": "^4.0.0-beta.20250418T073619" + "zod": "^4.0.0-beta.20250418T202744" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 262c81c4d..8c2f63ea6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -832,10 +832,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.7.1": - version "0.7.1" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.7.1.tgz#974c954ec03ab2943cbb321d510b64875c798d8d" - integrity sha512-5mV2cmDlJyKNlRGinyg2Lgn4m3CvNItWW8BcolrEvekfQP/y+2G1udVFnRBGJt4QqSHVADGyzxm3bBffPGGf+g== +"@zod/core@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.8.0.tgz#efcb4f591eaff251a5cc2bbb40869d94f69c1f75" + integrity sha512-c93EH8NXxc08ATS+yuDfjrHVsUi6KNcAM+JE8UZuZsfJuYraP+pg7WhurS/CwDB3FG2NuTpXnozyRpwAHItFhA== accepts@^1.3.7: version "1.3.8" @@ -3085,9 +3085,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250418T073619: - version "4.0.0-beta.20250418T073619" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250418T073619.tgz#beba59f7239d7368ae2c963f1cec2c32a29bbab4" - integrity sha512-IdLjh8m+/vaSdPbRbQHGV8ei6EgFhCIhaPNwkFlv/KoCG2dMRmysmgXO7oHWKMxk3RVoQhtMANFP2H6d5MMflg== +zod@^4.0.0-beta.20250418T202744: + version "4.0.0-beta.20250418T202744" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250418T202744.tgz#f6ae54391a728c93f61cd40a28a399ffbc755e9d" + integrity sha512-LFBAGnymB/lmgrvO/6RrcYbkvxulIxaVHyrNWyZ0MTDIxr6IrXAXfZ0pzmBB3E7FlmOEX08n6LPIIZuHcxfwUA== dependencies: - "@zod/core" "0.7.1" + "@zod/core" "0.8.0" From 43ff8f202f62933b7a595f4f3c167307d9ccce0c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 19 Apr 2025 10:01:57 +0200 Subject: [PATCH 007/187] Fix example test. --- example/endpoints/retrieve-user.ts | 2 +- express-zod-api/tests/documentation.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 2b61fbcbd..866ed76e0 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -6,7 +6,7 @@ import { methodProviderMiddleware } from "../middlewares"; // Demonstrating circular schemas using z.lazy() // @todo switch to using z.interface instead -const baseFeature = z.object({ +const baseFeature = z.looseObject({ title: z.string(), }); type Feature = z.infer & { diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 16f328181..beb8f802c 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -483,7 +483,8 @@ describe("Documentation", () => { }); test("should handle circular schemas via z.lazy()", () => { - const baseCategorySchema = z.object({ + // @todo switch to z.interface instead + const baseCategorySchema = z.looseObject({ name: z.string(), }); /** From 67ea7a07ae5583aca6256d889ef960a73b8dd288 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 19 Apr 2025 11:20:29 +0200 Subject: [PATCH 008/187] FIX lazy: avoiding .extend on objects. --- example/endpoints/retrieve-user.ts | 20 +++++++------------ .../__snapshots__/documentation.spec.ts.snap | 3 +++ express-zod-api/tests/documentation.spec.ts | 18 +++++------------ 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 866ed76e0..0c0746b00 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -5,20 +5,14 @@ import { defaultEndpointsFactory } from "express-zod-api"; import { methodProviderMiddleware } from "../middlewares"; // Demonstrating circular schemas using z.lazy() -// @todo switch to using z.interface instead -const baseFeature = z.looseObject({ - title: z.string(), -}); -type Feature = z.infer & { +// @todo switch to z.interface for that +interface Feature { + title: string; features: Feature[]; -}; -/** - * External issue in core 0.8.0 - * @link https://github.com/colinhacks/zod/issues/4234 - * @todo remove let when fixed - */ -let feature = baseFeature as unknown as z.ZodType; -feature = baseFeature.extend({ +} + +const feature: z.ZodType = z.object({ + title: z.string(), features: z.lazy(() => feature.array()), }); diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 24c748822..78b9f39c0 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1272,8 +1272,11 @@ paths: properties: name: type: string + subcategories: + $ref: "#/components/schemas/Schema1" required: - name + - subcategories required: true responses: "200": diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index beb8f802c..9151593b6 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -482,19 +482,11 @@ describe("Documentation", () => { expect(boolean.parse(null)).toBe(false); }); + // @todo switch to z.interface for that test("should handle circular schemas via z.lazy()", () => { - // @todo switch to z.interface instead - const baseCategorySchema = z.looseObject({ + const category: z.ZodObject = z.object({ name: z.string(), - }); - /** - * External issue in core 0.8.0 - * @link https://github.com/colinhacks/zod/issues/4234 - * @todo remove let when fixed - */ - let categorySchema = baseCategorySchema; - categorySchema = baseCategorySchema.extend({ - subcategories: z.lazy(() => categorySchema.array()), + subcategories: z.lazy(() => category.array()), }); const spec = new Documentation({ config: sampleConfig, @@ -502,9 +494,9 @@ describe("Documentation", () => { v1: { getSomething: defaultEndpointsFactory.build({ method: "post", - input: baseCategorySchema, + input: category, output: z.object({ - zodExample: categorySchema, + zodExample: category, }), handler: async () => ({ zodExample: { From eb9ce4249a4f07a80cd4444fe4682f63a41c8ee1 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 20 Apr 2025 07:59:12 +0200 Subject: [PATCH 009/187] Updating zod, core 0.8.1, oneOf changed to anyOf in some places. --- .../endpoints-factory.spec.ts.snap | 2 +- .../tests/__snapshots__/io-schema.spec.ts.snap | 8 ++++---- .../tests/__snapshots__/sse.spec.ts.snap | 2 +- package.json | 2 +- yarn.lock | 18 +++++++++--------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap index b5c8f845f..694f854dd 100644 --- a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap @@ -149,7 +149,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd { "allOf": [ { - "oneOf": [ + "anyOf": [ { "properties": { "n1": { diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 6569d4c39..e8b3816fe 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -292,7 +292,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should ], }, { - "oneOf": [ + "anyOf": [ { "properties": { "three": { @@ -340,7 +340,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "allOf": [ { - "oneOf": [ + "anyOf": [ { "properties": { "one": { @@ -366,7 +366,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should ], }, { - "oneOf": [ + "anyOf": [ { "properties": { "three": { @@ -394,7 +394,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should ], }, { - "oneOf": [ + "anyOf": [ { "properties": { "five": { diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index d647b9e85..220e0a348 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -32,7 +32,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { - "oneOf": [ + "anyOf": [ { "properties": { "data": { diff --git a/package.json b/package.json index 54efc1117..d63ecde99 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.29.1", "vitest": "^3.1.1", - "zod": "^4.0.0-beta.20250418T202744" + "zod": "^4.0.0-beta.20250420T053007" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 8c2f63ea6..988daafb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -832,10 +832,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.8.0.tgz#efcb4f591eaff251a5cc2bbb40869d94f69c1f75" - integrity sha512-c93EH8NXxc08ATS+yuDfjrHVsUi6KNcAM+JE8UZuZsfJuYraP+pg7WhurS/CwDB3FG2NuTpXnozyRpwAHItFhA== +"@zod/core@0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.8.1.tgz#8d1c245677a80805d183e8b493dd37f459674416" + integrity sha512-djj8hPhxIHcG8ptxITaw/Bout5HJZ9NyRbKr95Eilqwt9R0kvITwUQGDU+n+MVdsBIka5KwztmZSLti22F+P0A== accepts@^1.3.7: version "1.3.8" @@ -3085,9 +3085,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250418T202744: - version "4.0.0-beta.20250418T202744" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250418T202744.tgz#f6ae54391a728c93f61cd40a28a399ffbc755e9d" - integrity sha512-LFBAGnymB/lmgrvO/6RrcYbkvxulIxaVHyrNWyZ0MTDIxr6IrXAXfZ0pzmBB3E7FlmOEX08n6LPIIZuHcxfwUA== +zod@^4.0.0-beta.20250420T053007: + version "4.0.0-beta.20250420T053007" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250420T053007.tgz#ab64663e7835d5db5d61d708f4cb5c5ab97623db" + integrity sha512-5pp8Q0PNDaNcUptGiBE9akyioJh3RJpagIxrLtAVMR9IxwcSZiOsJD/1/98CyhItdTlI2H91MfhhLzRlU+fifA== dependencies: - "@zod/core" "0.8.0" + "@zod/core" "0.8.1" From 16d8501474919b9930136f214bdcdf7da3bf6393 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 20 Apr 2025 10:27:33 +0200 Subject: [PATCH 010/187] Changelog: initial entry for v24. --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0745755..79980179e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Version 24 + +### v24.0.0 + +- Switched to Zod 4: + - Minimum supported version of `zod` is 4.0.0; + - ⚠️This version might not support all new features of Zod 4; + - `IOSchema` type had to be simplified down to a schema resulting in a `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; + ## Version 23 ### v23.1.1 From a0345aee1dcc1b68b7c898f8055e7149efee8fce Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Apr 2025 20:07:32 +0200 Subject: [PATCH 011/187] Delegating depiction to Zod 4 (#2547) Exploring the possibility to delegate OpenAPI compatible depiction to JSON Schema based method of Zod 4. Current issues: - Can not depict `BigInt` with throwing error if it's within `ZodLiteral` - https://github.com/colinhacks/zod/issues/4220 - Numeric limits are wrong: - exclusive, while should be inclusive - missing at all (`z.number().int().positive()`) - https://github.com/colinhacks/zod/pull/4074#discussion_r2048393003 - suggested fix https://github.com/colinhacks/zod/pull/4224 - Overrides act after default depiction, not before, so that much has to be rewritten --- CHANGELOG.md | 6 + README.md | 14 +- example/example.documentation.yaml | 99 +-- express-zod-api/src/common-helpers.ts | 9 +- express-zod-api/src/documentation-helpers.ts | 703 ++++++------------ express-zod-api/src/documentation.ts | 37 +- express-zod-api/src/index.ts | 2 +- .../documentation-helpers.spec.ts.snap | 304 +++----- .../__snapshots__/documentation.spec.ts.snap | 520 +++++++------ .../tests/documentation-helpers.spec.ts | 278 ++----- express-zod-api/tests/documentation.spec.ts | 62 +- express-zod-api/tests/index.spec.ts | 10 +- 12 files changed, 723 insertions(+), 1321 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79980179e..9553a1aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ - `IOSchema` type had to be simplified down to a schema resulting in a `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()`: + - 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; + - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; + - The `Depicter` type changed to `Overrider` having different signature; ## Version 23 diff --git a/README.md b/README.md index 04e3ac814..4c41b2bbe 100644 --- a/README.md +++ b/README.md @@ -1388,7 +1388,7 @@ const routing: Routing = { You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your schema to make it special and distinguishable for the framework in runtime. Using symbols is recommended for branding. After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. In case you -need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`. +need to reuse a handling rule for multiple brands, use the exposed types `Overrider` and `Producer`. ```ts import ts from "typescript"; @@ -1396,19 +1396,19 @@ import { z } from "zod"; import { Documentation, Integration, - Depicter, + Overrider, Producer, } from "express-zod-api"; const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose const myBrandedSchema = z.string().brand(myBrand); -const ruleForDocs: Depicter = ( - schema: typeof myBrandedSchema, // you should assign type yourself - { next, path, method, isResponse }, // handle a nested schema using next() +const ruleForDocs: Overrider = ( + { zodSchema, jsonSchema }, // adjust jsonSchema for overrides + { path, method, isResponse }, // handle a nested schema using next() ) => { - const defaultDepiction = next(schema.unwrap()); // { type: string } - return { summary: "Special type of data" }; + delete jsonSchema.format; + jsonSchema.summary = "Special type of data"; }; const ruleForClient: Producer = ( diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 6e48ba8ce..b729d5ca5 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -17,6 +17,7 @@ paths: description: a numeric string containing the id of the user schema: type: string + format: regex pattern: \d+ description: a numeric string containing the id of the user responses: @@ -28,30 +29,20 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: id: type: integer - format: int64 - minimum: 0 maximum: 9007199254740991 name: type: string features: type: array items: - type: object - properties: - title: - type: string - features: - $ref: "#/components/schemas/Schema1" - required: - - title - - features + $ref: "#/components/schemas/Schema1" required: - id - name @@ -67,8 +58,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -97,6 +88,7 @@ paths: description: numeric string schema: type: string + format: regex pattern: \d+ description: numeric string responses: @@ -184,8 +176,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -222,8 +214,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -267,16 +259,14 @@ paths: type: object properties: status: - type: string const: created + type: string data: type: object properties: id: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - id required: @@ -290,16 +280,14 @@ paths: type: object properties: status: - type: string const: created + type: string data: type: object properties: id: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - id required: @@ -313,8 +301,8 @@ paths: type: object properties: status: - type: string const: error + type: string reason: type: string required: @@ -328,13 +316,12 @@ paths: type: object properties: status: - type: string const: exists + type: string id: type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 + exclusiveMinimum: -9007199254740991 + exclusiveMaximum: 9007199254740991 required: - status - id @@ -346,8 +333,8 @@ paths: type: object properties: status: - type: string const: error + type: string reason: type: string required: @@ -402,6 +389,7 @@ paths: description: GET /v1/avatar/send Parameter schema: type: string + format: regex pattern: \d+ responses: "200": @@ -430,6 +418,7 @@ paths: description: GET /v1/avatar/stream Parameter schema: type: string + format: regex pattern: \d+ responses: "200": @@ -464,6 +453,7 @@ paths: format: binary required: - avatar + additionalProperties: {} required: true responses: "200": @@ -474,8 +464,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -483,8 +473,6 @@ paths: type: string size: type: integer - format: int64 - minimum: 0 maximum: 9007199254740991 mime: type: string @@ -513,8 +501,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -553,15 +541,13 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: length: type: integer - format: int64 - minimum: 0 maximum: 9007199254740991 required: - length @@ -576,8 +562,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -619,19 +605,15 @@ paths: properties: data: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 event: - type: string const: time + type: string id: type: string retry: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - data - event @@ -659,6 +641,7 @@ paths: email: type: string format: email + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ message: type: string minLength: 1 @@ -676,16 +659,14 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: crc: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - crc required: @@ -699,8 +680,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -720,17 +701,17 @@ paths: components: schemas: Schema1: - type: array - items: - type: object - properties: - title: - type: string - features: + type: object + properties: + title: + type: string + features: + type: array + items: $ref: "#/components/schemas/Schema1" - required: - - title - - features + required: + - title + - features responses: {} parameters: {} examples: {} diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 4c020653a..ef2f86a16 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,6 +1,7 @@ +import type { $ZodType } from "@zod/core"; import { Request } from "express"; import * as R from "ramda"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; import { OutputValidationError } from "./errors"; @@ -99,7 +100,7 @@ export const pullExampleProps = (subject: T) => ); export const getExamples = < - T extends z.ZodType, + T extends $ZodType, V extends "original" | "parsed" | undefined, >({ schema, @@ -126,13 +127,13 @@ export const getExamples = < * */ pullProps?: boolean; }): ReadonlyArray : z.input> => { - let examples = schema.meta()?.[metaSymbol]?.examples || []; + let examples = globalRegistry.get(schema)?.[metaSymbol]?.examples || []; if (!examples.length && pullProps && schema instanceof z.ZodObject) examples = pullExampleProps(schema); if (!validate && variant === "original") return examples; const result: Array | z.output> = []; for (const example of examples) { - const parsedExample = schema.safeParse(example); + const parsedExample = z.safeParse(schema, example); if (parsedExample.success) result.push(variant === "parsed" ? parsedExample.data : example); } diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 4a1767c1b..8048e8633 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,38 +1,15 @@ import type { - $ZodArray, - $ZodCatch, - $ZodDate, - $ZodDefault, - $ZodDiscriminatedUnion, $ZodEnum, - $ZodIntersection, - $ZodLazy, $ZodLiteral, - $ZodNullable, - $ZodObject, - $ZodOptional, $ZodPipe, - $ZodRecord, $ZodTuple, $ZodType, - $ZodUnion, - $ZodChecks, - $ZodCheckMinLength, - $ZodCheckMaxLength, - $ZodString, - $ZodISODateTime, - $ZodCheckLengthEquals, - $ZodNumber, - $ZodCheckGreaterThan, - $ZodCheckLessThan, - $ZodReadonly, - $ZodStringFormat, - $ZodNumberFormat, - $ZodStringFormats, - $ZodNumberFormats, + JSONSchema, } from "@zod/core"; import { ExamplesObject, + isReferenceObject, + isSchemaObject, MediaTypeObject, OAuthFlowObject, ParameterObject, @@ -44,66 +21,53 @@ import { SecurityRequirementObject, SecuritySchemeObject, TagObject, - isReferenceObject, - isSchemaObject, } from "openapi3-ts/oas31"; import * as R from "ramda"; import { globalRegistry, z } from "zod"; import { ResponseVariant } from "./api-response"; import { - FlatObject, combinations, + FlatObject, getExamples, getRoutePathParams, + getTransformedType, makeCleanId, routePathParamsRegex, - getTransformedType, - ucFirst, Tag, + ucFirst, } from "./common-helpers"; import { InputSource } from "./config-type"; -import { DateInSchema, ezDateInBrand } from "./date-in-schema"; -import { DateOutSchema, ezDateOutBrand } from "./date-out-schema"; +import { ezDateInBrand } from "./date-in-schema"; +import { ezDateOutBrand } from "./date-out-schema"; import { hasRaw } from "./deep-checks"; import { DocumentationError } from "./errors"; -import { FileSchema, ezFileBrand } from "./file-schema"; +import { ezFileBrand } from "./file-schema"; import { extractObjectSchema, IOSchema } from "./io-schema"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; import { ProprietaryBrand } from "./proprietary-schemas"; -import { RawSchema, ezRawBrand } from "./raw-schema"; -import { - FirstPartyKind, - HandlingRules, - SchemaHandler, - walkSchema, -} from "./schema-walker"; +import { ezRawBrand } from "./raw-schema"; +import { FirstPartyKind } from "./schema-walker"; import { Security } from "./security"; -import { UploadSchema, ezUploadBrand } from "./upload-schema"; +import { ezUploadBrand } from "./upload-schema"; import wellKnownHeaders from "./well-known-headers.json"; -export type NumericRange = Record<"integer" | "float", [number, number]>; - -export interface OpenAPIContext extends FlatObject { +export interface OpenAPIContext { isResponse: boolean; makeRef: ( - schema: $ZodType | (() => $ZodType), - subject: - | SchemaObject - | ReferenceObject - | (() => SchemaObject | ReferenceObject), + key: object, + subject: SchemaObject | ReferenceObject, name?: string, ) => ReferenceObject; - numericRange?: NumericRange | null; path: string; method: Method; } -export type Depicter = SchemaHandler< - SchemaObject | ReferenceObject, - OpenAPIContext ->; +export type Overrider = ( + zodCtx: { zodSchema: $ZodType; jsonSchema: JSONSchema.BaseSchema }, + oasCtx: OpenAPIContext, +) => void; /** @desc Using defaultIsHeader when returns null or undefined */ export type IsHeader = ( @@ -112,12 +76,14 @@ export type IsHeader = ( path: string, ) => boolean | null | undefined; +export type BrandHandling = Record; + interface ReqResHandlingProps - extends Pick { + extends Omit { schema: S; composition: "inline" | "components"; description?: string; - brandHandling?: HandlingRules; + brandHandling?: BrandHandling; } const shortDescriptionLimit = 50; @@ -134,59 +100,39 @@ const samples = { array: [], } satisfies Record, unknown>; -const dateRegex = /^\d{4}-\d{2}-\d{2}$/; -const timeRegex = /^\d{2}:\d{2}:\d{2}(\.\d+)?$/; - -const getTimestampRegex = (hasOffset?: boolean) => - hasOffset - ? /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(([+-]\d{2}:\d{2})|Z)$/ - : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; - export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const depictDefault: Depicter = (schema: $ZodDefault, { next }) => ({ - ...next(schema._zod.def.innerType), - default: - globalRegistry.get(schema)?.[metaSymbol]?.defaultLabel || - schema._zod.def.defaultValue(), -}); +const onDefault: Overrider = ({ zodSchema, jsonSchema }) => + (jsonSchema.default = + globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? + jsonSchema.default); -export const depictAny: Depicter = () => ({ format: "any" }); +const onAny: Overrider = ({ jsonSchema }) => (jsonSchema.format = "any"); -export const depictUpload: Depicter = ({}: UploadSchema, ctx) => { +const onUpload: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.upload() only for input.", ctx); - return { type: "string", format: "binary" }; + Object.assign(jsonSchema, { type: "string", format: "binary" }); }; -export const depictFile: Depicter = (schema: FileSchema) => { - return { +const onFile: Overrider = ({ jsonSchema }) => { + delete jsonSchema.anyOf; // undo default + Object.assign(jsonSchema, { type: "string", format: - schema instanceof z.ZodString - ? schema._zod.def.checks?.find( - (entry) => - isCheck<$ZodStringFormat>(entry, "string_format") && - entry._zod.def.format === "base64", - ) + jsonSchema.type === "string" + ? jsonSchema.format === "base64" ? "byte" : "file" : "binary", - }; + }); }; -export const depictUnion: Depicter = ( - { _zod }: $ZodUnion | $ZodDiscriminatedUnion, - { next }, -) => { - const result: SchemaObject = { oneOf: _zod.def.options.map(next) }; - if (_zod.disc) { - const propertyName = Array.from(_zod.disc.keys()).pop(); - if (typeof propertyName === "string") - result.discriminator = { propertyName }; - } - return result; +const onUnion: Overrider = ({ zodSchema, jsonSchema }) => { + if (!zodSchema._zod.disc) return; + const propertyName = Array.from(zodSchema._zod.disc.keys()).pop(); + jsonSchema.discriminator ??= { propertyName }; }; const propsMerger = (a: unknown, b: unknown) => { @@ -201,18 +147,30 @@ const approaches = { required: ({ required: left = [] }, { required: right = [] }) => R.union(left, right), examples: ({ examples: left = [] }, { examples: right = [] }) => - combinations(left, right, ([a, b]) => R.mergeDeepRight(a, b)), + combinations(left, right, ([a, b]) => + typeof a === "object" && typeof b === "object" + ? R.mergeDeepRight({ ...a }, { ...b }) + : a, + ), + description: ({ description: left }, { description: right }) => left || right, } satisfies { - [K in keyof SchemaObject]: (...subj: SchemaObject[]) => SchemaObject[K]; + [K in keyof JSONSchema.ObjectSchema]: ( + ...subj: JSONSchema.ObjectSchema[] + ) => JSONSchema.ObjectSchema[K]; }; -const canMerge = R.both( - ({ type }: SchemaObject) => type === "object", - R.pipe(Object.keys, R.without(Object.keys(approaches)), R.isEmpty), +const canMerge = R.pipe( + Object.keys, + R.without(Object.keys(approaches)), + R.isEmpty, ); const intersect = R.tryCatch( - (children: Array): SchemaObject => { - const [left, right] = children.filter(isSchemaObject).filter(canMerge); + (children: Array): JSONSchema.ObjectSchema => { + const [left, right] = children + .filter( + (schema): schema is JSONSchema.ObjectSchema => schema.type === "object", + ) + .filter(canMerge); if (!left || !right) throw new Error("Can not flatten objects"); const suitable: typeof approaches = R.pickBy( (_, prop) => (left[prop] || right[prop]) !== undefined, @@ -220,27 +178,23 @@ const intersect = R.tryCatch( ); return R.map((fn) => fn(left, right), suitable); }, - (_err, allOf): SchemaObject => ({ allOf }), + (_err, allOf): JSONSchema.BaseSchema => ({ allOf }), ); -export const depictIntersection: Depicter = ( - { _zod: { def } }: $ZodIntersection, - { next }, -) => intersect([def.left, def.right].map(next)); - -export const depictWrapped: Depicter = ( - { _zod: { def } }: $ZodOptional | $ZodReadonly | $ZodCatch, - { next }, -) => next(def.innerType); +const onIntersection: Overrider = ({ jsonSchema }) => { + if (!jsonSchema.allOf) return; + const attempt = intersect(jsonSchema.allOf); + delete jsonSchema.allOf; // undo default + Object.assign(jsonSchema, attempt); +}; /** @since OAS 3.1 nullable replaced with type array having null */ -export const depictNullable: Depicter = ( - { _zod: { def } }: $ZodNullable, - { next }, -) => { - const nested = next(def.innerType); - if (isSchemaObject(nested)) nested.type = makeNullableType(nested); - return nested; +const onNullable: Overrider = ({ jsonSchema }) => { + if (!jsonSchema.anyOf) return; + const original = jsonSchema.anyOf[0]; + Object.assign(original, { type: makeNullableType(original) }); + Object.assign(jsonSchema, original); + delete jsonSchema.anyOf; }; const getSupportedType = (value: unknown): SchemaObjectType | undefined => { @@ -259,49 +213,21 @@ const getSupportedType = (value: unknown): SchemaObjectType | undefined => { : undefined; }; -export const depictEnum: Depicter = ({ _zod: { def } }: $ZodEnum) => ({ - type: getSupportedType(Object.values(def.entries)[0]), - enum: Object.values(def.entries), -}); - -export const depictLiteral: Depicter = ({ _zod: { def } }: $ZodLiteral) => { - const values = Object.values(def.values); - const result: SchemaObject = { type: getSupportedType(values[0]) }; - if (values.length === 1) result.const = values[0]; - else result.enum = Object.values(def.values); - return result; -}; - -export const depictObject: Depicter = ( - schema: $ZodObject, - { isResponse, next }, -) => { - const keys = Object.keys(schema._zod.def.shape); - const isOptionalProp = (prop: $ZodType) => - isResponse - ? prop instanceof z.ZodOptional - : prop instanceof z.ZodPromise - ? false - : (prop as z.ZodType).isOptional(); - const required = keys.filter( - (key) => !isOptionalProp(schema._zod.def.shape[key]), - ); - const result: SchemaObject = { type: "object" }; - if (keys.length) result.properties = depictObjectProperties(schema, next); - if (required.length) result.required = required; - return result; -}; +const onEnum: Overrider = ({ zodSchema, jsonSchema }) => + (jsonSchema.type = getSupportedType( + Object.values((zodSchema as $ZodEnum)._zod.def.entries)[0], + )); -/** - * @see https://swagger.io/docs/specification/data-models/data-types/ - * @since OAS 3.1: using type: "null" - * */ -export const depictNull: Depicter = () => ({ type: "null" }); +const onLiteral: Overrider = ({ zodSchema, jsonSchema }) => + (jsonSchema.type = getSupportedType( + Object.values((zodSchema as $ZodLiteral)._zod.def.values)[0], + )); -export const depictDateIn: Depicter = ({}: DateInSchema, ctx) => { +const onDateIn: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); - return { + delete jsonSchema.anyOf; // undo default + Object.assign(jsonSchema, { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", @@ -309,244 +235,35 @@ export const depictDateIn: Depicter = ({}: DateInSchema, ctx) => { externalDocs: { url: isoDateDocumentationUrl, }, - }; + }); }; -export const depictDateOut: Depicter = ({}: DateOutSchema, ctx) => { +const onDateOut: Overrider = ({ jsonSchema }, ctx) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); - return { + Object.assign(jsonSchema, { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", externalDocs: { url: isoDateDocumentationUrl, }, - }; -}; - -/** @throws DocumentationError */ -export const depictDate: Depicter = ({}: $ZodDate, ctx) => { - throw new DocumentationError( - `Using z.date() within ${ - ctx.isResponse ? "output" : "input" - } schema is forbidden. Please use ez.date${ - ctx.isResponse ? "Out" : "In" - }() instead. Check out the documentation for details.`, - ctx, - ); -}; - -export const depictBoolean: Depicter = () => ({ type: "boolean" }); - -export const depictBigInt: Depicter = () => ({ - type: "integer", - format: "bigint", -}); - -const areOptionsLiteral = ( - subject: ReadonlyArray<$ZodType>, -): subject is $ZodLiteral[] => - subject.every((option) => option._zod.def.type === "literal"); - -export const depictRecord: Depicter = ( - { _zod: { def } }: $ZodRecord, - { next }, -) => { - if (def.keyType instanceof z.ZodEnum) { - const keys = Object.values(def.keyType._zod.def.entries).map(String); - const result: SchemaObject = { type: "object" }; - if (keys.length) { - result.properties = depictObjectProperties( - z.looseObject(R.fromPairs(R.xprod(keys, [def.valueType]))), - next, - ); - result.required = keys; - } - return result; - } - if (def.keyType instanceof z.ZodLiteral) { - const keys = def.keyType._zod.def.values.map(String); - return { - type: "object", - properties: depictObjectProperties( - z.looseObject(R.fromPairs(R.xprod(keys, [def.valueType]))), - next, - ), - required: keys, - }; - } - if ( - def.keyType instanceof z.ZodUnion && - areOptionsLiteral(def.keyType._zod.def.options) - ) { - const required = R.map( - (opt: $ZodLiteral) => `${opt._zod.def.values[0]}`, - def.keyType._zod.def.options as $ZodLiteral[], // ensured above - ); - const shape = R.fromPairs(R.xprod(required, [def.valueType])); - return { - type: "object", - properties: depictObjectProperties(z.looseObject(shape), next), - required, - }; - } - return { - type: "object", - propertyNames: next(def.keyType), - additionalProperties: next(def.valueType), - }; + }); }; -export const depictArray: Depicter = ( - { _zod: { def } }: $ZodArray, - { next }, -) => { - const result: SchemaObject = { - type: "array", - items: next(def.element), - }; - for (const check of def.checks || []) { - if (isCheck<$ZodCheckLengthEquals>(check, "length_equals")) - [result.minItems, result.maxItems] = Array(2).fill(check._zod.def.length); - if (isCheck<$ZodCheckMinLength>(check, "min_length")) - result.minItems = check._zod.def.minimum; - if (isCheck<$ZodCheckMaxLength>(check, "max_length")) - result.maxItems = check._zod.def.maximum; - } - return result; -}; +const onBigInt: Overrider = ({ jsonSchema }) => + Object.assign(jsonSchema, { type: "integer", format: "bigint" }); /** * @since OAS 3.1 using prefixItems for depicting tuples * @since 17.5.0 added rest handling, fixed tuple type - * */ -export const depictTuple: Depicter = ( - { _zod: { def } }: $ZodTuple, - { next }, -) => ({ - type: "array", - prefixItems: def.items.map(next), + */ +const onTuple: Overrider = ({ zodSchema, jsonSchema }) => { + if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return; // does not appear to support items:false, so not:{} is a recommended alias - items: def.rest === null ? { not: {} } : next(def.rest), -}); - -const isCheck = ( - check: unknown, - name: T["_zod"]["def"]["check"], -): check is T => R.pathEq(name, ["_zod", "def", "check"], check); - -export const depictString: Depicter = ( - schema: $ZodString | $ZodStringFormat, -) => { - const result: SchemaObject = { type: "string" }; - const formatCast: Partial> = - { - datetime: "date-time", - base64: "byte", - ipv4: "ip", - ipv6: "ip", - cidrv4: "cidr", - cidrv6: "cidr", - regex: undefined, - }; - const { checks = [] } = schema._zod.def; - if (isCheck<$ZodStringFormat>(schema, "string_format")) checks.push(schema); - for (const check of checks) { - if (isCheck<$ZodCheckLengthEquals>(check, "length_equals")) { - [result.minLength, result.maxLength] = Array(2).fill( - check._zod.def.length, - ); - } - if (isCheck<$ZodCheckMinLength>(check, "min_length")) - result.minLength = check._zod.def.minimum; - if (isCheck<$ZodCheckMaxLength>(check, "max_length")) - result.maxLength = check._zod.def.maximum; - if (isCheck<$ZodStringFormat>(check, "string_format")) { - if (check._zod.def.format === "regex") - result.pattern = check._zod.def.pattern?.source; - if (check._zod.def.format === "date") result.pattern = dateRegex.source; - if (check._zod.def.format === "time") result.pattern = timeRegex.source; - if (check._zod.def.format === "datetime") { - result.pattern = getTimestampRegex( - (check as $ZodISODateTime)._zod.def.offset, - ).source; - } - const format = - check._zod.def.format in formatCast - ? formatCast[check._zod.def.format] - : check._zod.def.format; - if (format) result.format = format; - } - } - return result; -}; - -/** @since OAS 3.1: exclusive min/max are numbers */ -export const depictNumber: Depicter = ( - schema: $ZodNumber, - { - // @todo consider using computed values provided by Zod instead - numericRange = { - integer: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], - float: [-Number.MAX_VALUE, Number.MAX_VALUE], - }, - }, -) => { - const { integer: intRange, float: floatRange } = numericRange || { - integer: null, - float: null, - }; - let min = floatRange?.[0]; - let inclMin = true; - let max = floatRange?.[1]; - let inclMax = true; - const result: SchemaObject = { - type: "number", - format: "double", - }; - const formatCast: Partial> = - { - safeint: "int64", - uint32: "int32", - float32: "float", - float64: "double", - }; - const { checks = [] } = schema._zod.def; - if (isCheck<$ZodNumberFormat>(schema, "number_format")) checks.push(schema); - for (const check of checks) { - if (isCheck<$ZodNumberFormat>(check, "number_format")) { - if (check._zod.def.format.includes("int")) { - result.type = "integer"; - min = intRange?.[0]; - max = intRange?.[1]; - inclMin = true; - inclMax = true; - } - result.format = - check._zod.def.format in formatCast - ? formatCast[check._zod.def.format] - : check._zod.def.format; - } - if (isCheck<$ZodCheckGreaterThan>(check, "greater_than")) { - min = Number(check._zod.def.value); - inclMin = check._zod.def.inclusive; - } - if (isCheck<$ZodCheckLessThan>(check, "less_than")) { - max = Number(check._zod.def.value); - inclMax = check._zod.def.inclusive; - } - } - result[inclMin ? "minimum" : "exclusiveMinimum"] = min; - result[inclMax ? "maximum" : "exclusiveMaximum"] = max; - return result; + jsonSchema.items = { not: {} }; }; -export const depictObjectProperties = ( - { _zod: { def } }: $ZodObject, - next: Parameters[1]["next"], -) => R.map(next, def.shape); - const makeSample = (depicted: SchemaObject) => { const firstType = ( Array.isArray(depicted.type) ? depicted.type[0] : depicted.type @@ -556,48 +273,58 @@ const makeSample = (depicted: SchemaObject) => { const makeNullableType = ({ type, -}: SchemaObject): SchemaObjectType | SchemaObjectType[] => { +}: JSONSchema.BaseSchema | SchemaObject): + | SchemaObjectType + | SchemaObjectType[] => { if (type === "null") return type; - if (typeof type === "string") return [type, "null"]; + if (typeof type === "string") return [type as SchemaObjectType, "null"]; // @todo make method instead of "as" return type ? [...new Set(type).add("null")] : "null"; }; -export const depictPipeline: Depicter = ( - { _zod: { def } }: $ZodPipe, - { isResponse, next }, -) => { - const target = def[isResponse ? "out" : "in"]; - const opposite = def[isResponse ? "in" : "out"]; +const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { + const target = (zodSchema as $ZodPipe)._zod.def[ + ctx.isResponse ? "out" : "in" + ]; + const opposite = (zodSchema as $ZodPipe)._zod.def[ + ctx.isResponse ? "in" : "out" + ]; if (target instanceof z.ZodTransform) { - const opposingDepiction = next(opposite); + const opposingDepiction = delegate(opposite, { ctx, rules: overrides }); if (isSchemaObject(opposingDepiction)) { - if (!isResponse) { + if (!ctx.isResponse) { const { type: opposingType, ...rest } = opposingDepiction; - return { + Object.assign(jsonSchema, { ...rest, format: `${rest.format || opposingType} (preprocessed)`, - }; + }); } else { const targetType = getTransformedType( target, makeSample(opposingDepiction), ); - if (targetType && ["number", "string", "boolean"].includes(targetType)) - return { type: targetType as "number" | "string" | "boolean" }; - else return next(z.any()); + if ( + targetType && + ["number", "string", "boolean"].includes(targetType) + ) { + Object.assign(jsonSchema, { + type: targetType as "number" | "string" | "boolean", + }); + } else { + onAny({ zodSchema, jsonSchema }, ctx); + } } } } - return next(target); }; -export const depictLazy: Depicter = ( - { _zod: { def } }: $ZodLazy, - { next, makeRef }, -): ReferenceObject => makeRef(def.getter, () => next(def.getter())); - -export const depictRaw: Depicter = ({ _zod: { def } }: RawSchema, { next }) => - next(def.shape.raw); +const onRaw: Overrider = ({ jsonSchema }) => { + Object.assign( + jsonSchema, + (jsonSchema as JSONSchema.ObjectSchema).properties!.raw, + ); + delete jsonSchema.properties; // undo default + delete jsonSchema.required; +}; const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => examples.length @@ -660,7 +387,6 @@ export const depictRequestParams = ({ brandHandling, isHeader, security, - numericRange, description = `${method.toUpperCase()} ${path} Parameter`, }: ReqResHandlingProps & { inputSources: InputSource[]; @@ -692,11 +418,9 @@ export const depictRequestParams = ({ ? "query" : undefined; if (!location) return acc; - const depicted = walkSchema(paramSchema, { - rules: { ...brandHandling, ...depicters }, - onEach, - onMissing, - ctx: { isResponse: false, makeRef, path, method, numericRange }, + const depicted = delegate(paramSchema, { + rules: { ...brandHandling, ...overrides }, + ctx: { isResponse: false, makeRef, path, method }, }); const result = composition === "components" @@ -716,80 +440,104 @@ export const depictRequestParams = ({ ); }; -export const depicters: HandlingRules< - SchemaObject | ReferenceObject, - OpenAPIContext, - FirstPartyKind | ProprietaryBrand -> = { - string: depictString, - number: depictNumber, - bigint: depictBigInt, - boolean: depictBoolean, - null: depictNull, - array: depictArray, - tuple: depictTuple, - record: depictRecord, - object: depictObject, - literal: depictLiteral, - intersection: depictIntersection, - union: depictUnion, - any: depictAny, - default: depictDefault, - enum: depictEnum, - optional: depictWrapped, - nullable: depictNullable, - date: depictDate, - catch: depictWrapped, - pipe: depictPipeline, - lazy: depictLazy, - readonly: depictWrapped, - [ezFileBrand]: depictFile, - [ezUploadBrand]: depictUpload, - [ezDateOutBrand]: depictDateOut, - [ezDateInBrand]: depictDateIn, - [ezRawBrand]: depictRaw, -}; +const overrides: Partial> = + { + nullable: onNullable, + default: onDefault, + any: onAny, + union: onUnion, + enum: onEnum, + bigint: onBigInt, + intersection: onIntersection, + literal: onLiteral, + tuple: onTuple, + pipe: onPipeline, + [ezDateInBrand]: onDateIn, + [ezDateOutBrand]: onDateOut, + [ezUploadBrand]: onUpload, + [ezFileBrand]: onFile, + [ezRawBrand]: onRaw, + }; -export const onEach: SchemaHandler< - SchemaObject | ReferenceObject, - OpenAPIContext, - "each" -> = (schema: z.ZodType, { isResponse, prev }) => { - if (isReferenceObject(prev)) return {}; - const { description } = schema; +const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { + const { description, deprecated } = globalRegistry.get(zodSchema) ?? {}; + if (description) jsonSchema.description ??= description; + if (deprecated) jsonSchema.deprecated = true; const shouldAvoidParsing = - schema instanceof z.ZodLazy || schema instanceof z.ZodPromise; - const hasTypePropertyInDepiction = prev.type !== undefined; + zodSchema._zod.def.type === "lazy" || zodSchema._zod.def.type === "promise"; + const hasTypePropertyInDepiction = jsonSchema.type !== undefined; const acceptsNull = !isResponse && !shouldAvoidParsing && hasTypePropertyInDepiction && - schema.isNullable(); - const result: SchemaObject = {}; - if (description) result.description = description; - if (schema.meta()?.deprecated) result.deprecated = true; - if (acceptsNull) result.type = makeNullableType(prev); - if (!shouldAvoidParsing) { - const examples = getExamples({ - schema, - variant: isResponse ? "parsed" : "original", - validate: true, - }); - if (examples.length) result.examples = examples.slice(); + zodSchema instanceof z.ZodType && + zodSchema.isNullable(); + if (acceptsNull) + Object.assign(jsonSchema, { type: makeNullableType(jsonSchema) }); + const examples = getExamples({ + schema: zodSchema, + variant: isResponse ? "parsed" : "original", + validate: true, + }); + if (examples.length) jsonSchema.examples = examples.slice(); +}; + +/** + * 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? + * */ +const fixReferences = ( + { $defs = {}, ...rest }: JSONSchema.BaseSchema, + ctx: OpenAPIContext, +) => { + const stack: unknown[] = [rest, $defs]; + while (stack.length) { + const entry = stack.shift()!; + if (R.is(Object, entry)) { + if (isReferenceObject(entry)) { + if (entry.$ref === "#" && !$defs[entry.$ref]) { + $defs[entry.$ref] = rest; + return fixReferences({ $defs, $ref: entry.$ref }, ctx); // false root rewriting + } + if (!entry.$ref.startsWith("#/components")) { + const actualName = entry.$ref.split("/").pop()!; + const depiction = $defs[actualName]; + if (depiction) + entry.$ref = ctx.makeRef(depiction, depiction as SchemaObject).$ref; // @todo see below + continue; + } + } + stack.push(...R.values(entry)); + } + if (R.is(Array, entry)) stack.push(...R.values(entry)); } - return result; + return rest as SchemaObject; // @todo ideally, there should be a method to ensure that }; -export const onMissing: SchemaHandler< - SchemaObject | ReferenceObject, - OpenAPIContext, - "last" -> = (schema: z.ZodTypeAny, ctx) => { - throw new DocumentationError( - `Zod type ${schema.constructor.name} is unsupported.`, +// @todo rename? +export const delegate = ( + schema: $ZodType, + { + ctx, + rules = overrides, + }: { ctx: OpenAPIContext; rules?: Record }, +) => + fixReferences( + z.toJSONSchema(schema, { + unrepresentable: "any", + metadata: globalRegistry, + io: ctx.isResponse ? "output" : "input", + override: (zodCtx) => { + const { brand } = + globalRegistry.get(zodCtx.zodSchema)?.[metaSymbol] ?? {}; + rules[ + brand && brand in rules ? brand : zodCtx.zodSchema._zod.def.type + ]?.(zodCtx, ctx); + onEach(zodCtx, ctx); + }, + }), ctx, ); -}; export const excludeParamsFromDepiction = ( subject: SchemaObject | ReferenceObject, @@ -809,6 +557,7 @@ export const excludeParamsFromDepiction = ( required: R.without(names), allOf: subTransformer, oneOf: subTransformer, + anyOf: subTransformer, }; const result: SchemaObject = R.evolve(transformers, subject); return [result, hasRequired || Boolean(result.required?.length)]; @@ -830,7 +579,6 @@ export const depictResponse = ({ hasMultipleStatusCodes, statusCode, brandHandling, - numericRange, description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), @@ -842,11 +590,9 @@ export const depictResponse = ({ }): ResponseObject => { if (!mimeTypes) return { description }; const depictedSchema = excludeExamplesFromDepiction( - walkSchema(schema, { - rules: { ...brandHandling, ...depicters }, - onEach, - onMissing, - ctx: { isResponse: true, makeRef, path, method, numericRange }, + delegate(schema, { + rules: { ...brandHandling, ...overrides }, + ctx: { isResponse: true, makeRef, path, method }, }), ); const media: MediaTypeObject = { @@ -960,18 +706,15 @@ export const depictBody = ({ composition, brandHandling, paramNames, - numericRange, description = `${method.toUpperCase()} ${path} Request body`, }: ReqResHandlingProps & { mimeType: string; paramNames: string[]; }) => { const [withoutParams, hasRequired] = excludeParamsFromDepiction( - walkSchema(schema, { - rules: { ...brandHandling, ...depicters }, - onEach, - onMissing, - ctx: { isResponse: false, makeRef, path, method, numericRange }, + delegate(schema, { + rules: { ...brandHandling, ...overrides }, + ctx: { isResponse: false, makeRef, path, method }, }), paramNames, ); diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 448bdc43b..59375aaab 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -1,4 +1,3 @@ -import type { $ZodType } from "@zod/core"; import { OpenApiBuilder, OperationObject, @@ -17,7 +16,6 @@ import { CommonConfig } from "./config-type"; import { processContainers } from "./logical-container"; import { Method } from "./method"; import { - OpenAPIContext, depictBody, depictRequestParams, depictResponse, @@ -28,11 +26,10 @@ import { reformatParamsInPath, IsHeader, nonEmpty, - NumericRange, + BrandHandling, } from "./documentation-helpers"; import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; -import { HandlingRules } from "./schema-walker"; type Component = | "positiveResponse" @@ -61,21 +58,15 @@ interface DocumentationParams { descriptions?: Partial>; /** @default true */ hasSummaryFromDescription?: boolean; - /** - * @desc Acceptable limits of z.number() that API can handle (default: the limits of JavaScript engine) - * @default {integer:[Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], float:[-Number.MAX_VALUE, Number.MAX_VALUE]} - * @example null — to disable the feature - * @see depictNumber */ - numericRange?: NumericRange | null; /** @default inline */ composition?: "inline" | "components"; /** * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. - * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => ({ type: "object" }) + * @example { MyBrand: ( schema: typeof myBrandSchema, { jsonSchema } ) => ({ type: "object" }) */ - brandHandling?: HandlingRules; + brandHandling?: BrandHandling; /** * @desc Ability to configure recognition of headers among other input data * @desc Only applicable when "headers" is present within inputSources config option @@ -94,22 +85,18 @@ interface DocumentationParams { export class Documentation extends OpenApiBuilder { readonly #lastSecuritySchemaIds = new Map(); readonly #lastOperationIdSuffixes = new Map(); - readonly #references = new Map<$ZodType | (() => $ZodType), string>(); + readonly #references = new Map(); #makeRef( - schema: $ZodType | (() => $ZodType), - subject: - | SchemaObject - | ReferenceObject - | (() => SchemaObject | ReferenceObject), - name = this.#references.get(schema), + key: object, + subject: SchemaObject | ReferenceObject, + name = this.#references.get(key), ): ReferenceObject { if (!name) { - name = `Schema${this.#references.size + 1}`; - this.#references.set(schema, name); - if (typeof subject === "function") subject = subject(); + name = `Schema${Object.keys(this.rootDoc.components?.schemas || {}).length + 1}`; + this.#references.set(key, name); } - if (typeof subject === "object") this.addSchema(name, subject); + this.addSchema(name, subject); return { $ref: `#/components/schemas/${name}` }; } @@ -153,10 +140,9 @@ export class Documentation extends OpenApiBuilder { version, serverUrl, descriptions, - brandHandling, tags, isHeader, - numericRange, + brandHandling, hasSummaryFromDescription = true, composition = "inline", }: DocumentationParams) { @@ -171,7 +157,6 @@ export class Documentation extends OpenApiBuilder { endpoint, composition, brandHandling, - numericRange, makeRef: this.#makeRef.bind(this), }; const { description, shortDescription, scopes, inputSchema } = endpoint; diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index 76db03494..d76fc5036 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -33,7 +33,7 @@ export { EventStreamFactory } from "./sse"; export { ez } from "./proprietary-schemas"; // Convenience types -export type { Depicter } from "./documentation-helpers"; +export type { Overrider } from "./documentation-helpers"; export type { Producer } from "./zts-helpers"; // Interfaces exposed for augmentation 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 e92c540c1..391c58329 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -80,20 +80,6 @@ exports[`Documentation helpers > depictBoolean() > should set type:boolean 1`] = } `; -exports[`Documentation helpers > depictDate > should throw clear error 0 1`] = ` -DocumentationError({ - "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Using z.date() within output schema is forbidden. Please use ez.dateOut() instead. Check out the documentation for details.", -}) -`; - -exports[`Documentation helpers > depictDate > should throw clear error 1 1`] = ` -DocumentationError({ - "cause": "Input schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Using z.date() within input schema is forbidden. Please use ez.dateIn() instead. Check out the documentation for details.", -}) -`; - exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 1`] = ` { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", @@ -135,7 +121,7 @@ exports[`Documentation helpers > depictDefault() > Feature #1706: should overrid { "default": "Today", "format": "date-time", - "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$", + "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$", "type": "string", } `; @@ -217,7 +203,9 @@ exports[`Documentation helpers > depictFile() > should set type:string and forma exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 2 1`] = ` { + "contentEncoding": "base64", "format": "byte", + "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$", "type": "string", } `; @@ -242,9 +230,6 @@ exports[`Documentation helpers > depictIntersection() > should NOT flatten objec { "properties": { "one": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -273,9 +258,6 @@ exports[`Documentation helpers > depictIntersection() > should fall back to allO "allOf": [ { "additionalProperties": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "propertyNames": { @@ -286,9 +268,6 @@ exports[`Documentation helpers > depictIntersection() > should fall back to allO { "properties": { "test": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -305,9 +284,6 @@ exports[`Documentation helpers > depictIntersection() > should fall back to allO { "allOf": [ { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, { @@ -322,9 +298,6 @@ exports[`Documentation helpers > depictIntersection() > should flatten objects w { "properties": { "one": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -346,21 +319,12 @@ exports[`Documentation helpers > depictIntersection() > should flatten three obj ], "properties": { "one": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "three": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "two": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -377,15 +341,9 @@ exports[`Documentation helpers > depictIntersection() > should flatten two objec { "properties": { "one": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "two": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -402,9 +360,6 @@ exports[`Documentation helpers > depictIntersection() > should maintain uniquene "properties": { "test": { "const": 5, - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -429,15 +384,9 @@ exports[`Documentation helpers > depictIntersection() > should merge examples de "test": { "properties": { "a": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "b": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, }, @@ -455,24 +404,6 @@ exports[`Documentation helpers > depictIntersection() > should merge examples de } `; -exports[`Documentation helpers > depictLazy > should handle circular references 0 1`] = ` -{ - "$ref": "#/components/schemas/SomeSchema", -} -`; - -exports[`Documentation helpers > depictLazy > should handle circular references 1 1`] = ` -{ - "$ref": "#/components/schemas/SomeSchema", -} -`; - -exports[`Documentation helpers > depictLazy > should handle circular references 2 1`] = ` -{ - "$ref": "#/components/schemas/SomeSchema", -} -`; - exports[`Documentation helpers > depictLiteral() > should handle multiple values 1`] = ` { "enum": [ @@ -500,17 +431,12 @@ exports[`Documentation helpers > depictLiteral() > should set type and involve c exports[`Documentation helpers > depictLiteral() > should set type and involve const property 2 1`] = ` { - "const": 123n, + "const": 123, "type": "integer", } `; -exports[`Documentation helpers > depictLiteral() > should set type and involve const property 3 1`] = ` -{ - "const": undefined, - "type": undefined, -} -`; +exports[`Documentation helpers > depictLiteral() > should set type and involve const property 3 1`] = `{}`; exports[`Documentation helpers > depictNull() > should give type:null 1`] = ` { @@ -551,81 +477,30 @@ exports[`Documentation helpers > depictNullable() > should not add null type whe } `; -exports[`Documentation helpers > depictNumber() > should not use numericRange when it is null 0 1`] = ` -{ - "format": "double", - "maximum": undefined, - "minimum": undefined, - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should not use numericRange when it is null 1 1`] = ` -{ - "format": "int64", - "maximum": undefined, - "minimum": undefined, - "type": "integer", -} -`; - exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 0 1`] = ` { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", } `; exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 1 1`] = ` { - "format": "int64", - "maximum": 9007199254740991, - "minimum": -9007199254740991, + "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": -9007199254740991, "type": "integer", } `; exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 2 1`] = ` { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 3 1`] = ` -{ - "format": "hacked", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should use numericRange when set 0 1`] = ` -{ - "format": "double", - "maximum": 333.3333333333333, - "minimum": -333.3333333333333, + "exclusiveMaximum": 1.7976931348623157e+308, + "exclusiveMinimum": -1.7976931348623157e+308, "type": "number", } `; -exports[`Documentation helpers > depictNumber() > should use numericRange when set 1 1`] = ` -{ - "format": "int64", - "maximum": 100, - "minimum": -100, - "type": "integer", -} -`; - exports[`Documentation helpers > depictNumber() > should use schema checks for min/max and exclusiveness 0 1`] = ` { - "format": "double", "maximum": 33.333333333333336, "minimum": -33.333333333333336, "type": "number", @@ -634,7 +509,6 @@ exports[`Documentation helpers > depictNumber() > should use schema checks for m exports[`Documentation helpers > depictNumber() > should use schema checks for min/max and exclusiveness 1 1`] = ` { - "format": "int64", "maximum": 100, "minimum": -100, "type": "integer", @@ -645,7 +519,6 @@ exports[`Documentation helpers > depictNumber() > should use schema checks for m { "exclusiveMaximum": 16.666666666666668, "exclusiveMinimum": -16.666666666666668, - "format": "double", "type": "number", } `; @@ -654,7 +527,6 @@ exports[`Documentation helpers > depictNumber() > should use schema checks for m { "exclusiveMaximum": 100, "exclusiveMinimum": -100, - "format": "int64", "type": "integer", } `; @@ -684,9 +556,6 @@ exports[`Documentation helpers > depictObject() > should type:object, properties { "properties": { "a": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "b": { @@ -705,9 +574,6 @@ exports[`Documentation helpers > depictObject() > should type:object, properties { "properties": { "a": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "b": { @@ -726,9 +592,6 @@ exports[`Documentation helpers > depictObject() > should type:object, properties { "properties": { "a": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "b": { @@ -747,9 +610,6 @@ exports[`Documentation helpers > depictObject() > should type:object, properties { "properties": { "a": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "b": { @@ -767,9 +627,6 @@ exports[`Documentation helpers > depictObject() > should type:object, properties { "properties": { "a": { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, "b": { @@ -779,21 +636,13 @@ exports[`Documentation helpers > depictObject() > should type:object, properties ], }, }, + "required": [ + "b", + ], "type": "object", } `; -exports[`Documentation helpers > depictObjectProperties() > should wrap next depicters in a shape of object 1`] = ` -{ - "one": { - "type": "string", - }, - "two": { - "type": "boolean", - }, -} -`; - exports[`Documentation helpers > depictParamExamples() > should pass examples for the given parameter 1`] = ` { "example1": { @@ -835,13 +684,13 @@ exports[`Documentation helpers > depictPipeline > should depict as 'string (prep } `; -exports[`Documentation helpers > depictPipeline > should handle edge cases 1`] = ` +exports[`Documentation helpers > depictPipeline > should handle edge cases 0 1`] = ` { "format": "any", } `; -exports[`Documentation helpers > depictPipeline > should handle edge cases 2`] = ` +exports[`Documentation helpers > depictPipeline > should handle edge cases 1 1`] = ` { "format": "any", } @@ -860,9 +709,8 @@ exports[`Documentation helpers > depictRecord() > should set properties+required "type": "boolean", }, "propertyNames": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": -9007199254740991, + "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": -9007199254740991, "type": "integer", }, "type": "object", @@ -883,50 +731,50 @@ exports[`Documentation helpers > depictRecord() > should set properties+required exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 2 1`] = ` { - "properties": { - "one": { - "type": "boolean", - }, - "two": { - "type": "boolean", - }, + "additionalProperties": { + "type": "boolean", + }, + "propertyNames": { + "enum": [ + "one", + "two", + ], + "type": "string", }, - "required": [ - "one", - "two", - ], "type": "object", } `; exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 3 1`] = ` { - "properties": { - "testing": { - "type": "boolean", - }, + "additionalProperties": { + "type": "boolean", + }, + "propertyNames": { + "const": "testing", + "type": "string", }, - "required": [ - "testing", - ], "type": "object", } `; exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 4 1`] = ` { - "properties": { - "one": { - "type": "boolean", - }, - "two": { - "type": "boolean", - }, + "additionalProperties": { + "type": "boolean", + }, + "propertyNames": { + "anyOf": [ + { + "const": "one", + "type": "string", + }, + { + "const": "two", + "type": "string", + }, + ], }, - "required": [ - "one", - "two", - ], "type": "object", } `; @@ -949,6 +797,7 @@ exports[`Documentation helpers > depictRecord() > should set properties+required "type": "boolean", }, "propertyNames": { + "format": "regex", "pattern": "x-\\w+", "type": "string", }, @@ -1270,13 +1119,14 @@ exports[`Documentation helpers > depictString() > should set format, pattern and "format": "email", "maxLength": 20, "minLength": 10, + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "type": "string", } `; exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 1 1`] = ` { - "format": "url", + "format": "uri", "maxLength": 15, "minLength": 15, "type": "string", @@ -1286,6 +1136,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 2 1`] = ` { "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$", "type": "string", } `; @@ -1293,6 +1144,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 3 1`] = ` { "format": "cuid", + "pattern": "^[cC][^\\s-]{8,}$", "type": "string", } `; @@ -1300,7 +1152,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 4 1`] = ` { "format": "date-time", - "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$", + "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$", "type": "string", } `; @@ -1308,13 +1160,14 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 5 1`] = ` { "format": "date-time", - "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}:\\d{2})|Z)$", + "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z|([+-]\\d{2}:?\\d{2}))$", "type": "string", } `; exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 6 1`] = ` { + "format": "regex", "pattern": "^\\d+.\\d+.\\d+$", "type": "string", } @@ -1323,7 +1176,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 7 1`] = ` { "format": "date", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))$", "type": "string", } `; @@ -1331,7 +1184,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 8 1`] = ` { "format": "time", - "pattern": "^\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?$", "type": "string", } `; @@ -1339,20 +1192,23 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 9 1`] = ` { "format": "duration", + "pattern": "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$", "type": "string", } `; exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 10 1`] = ` { - "format": "cidr", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$", "type": "string", } `; exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 11 1`] = ` { - "format": "ip", + "format": "ipv4", + "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$", "type": "string", } `; @@ -1366,14 +1222,18 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 13 1`] = ` { - "format": "byte", + "contentEncoding": "base64", + "format": "base64", + "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$", "type": "string", } `; exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 14 1`] = ` { + "contentEncoding": "base64url", "format": "base64url", + "pattern": "^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$", "type": "string", } `; @@ -1381,6 +1241,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 15 1`] = ` { "format": "cuid2", + "pattern": "^[0-9a-z]+$", "type": "string", } `; @@ -1388,6 +1249,7 @@ exports[`Documentation helpers > depictString() > should set format, pattern and exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 16 1`] = ` { "format": "ulid", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "type": "string", } `; @@ -1474,10 +1336,7 @@ exports[`Documentation helpers > depictTuple() > should utilize prefixItems and exports[`Documentation helpers > depictUnion() > should wrap next depicters in oneOf prop and set discriminator prop 1`] = ` { - "discriminator": { - "propertyName": "status", - }, - "oneOf": [ + "anyOf": [ { "properties": { "data": { @@ -1490,6 +1349,7 @@ exports[`Documentation helpers > depictUnion() > should wrap next depicters in o }, "required": [ "status", + "data", ], "type": "object", }, @@ -1518,19 +1378,19 @@ exports[`Documentation helpers > depictUnion() > should wrap next depicters in o "type": "object", }, ], + "discriminator": { + "propertyName": "status", + }, } `; exports[`Documentation helpers > depictUnion() > should wrap next depicters into oneOf property 1`] = ` { - "oneOf": [ + "anyOf": [ { "type": "string", }, { - "format": "double", - "maximum": 1.7976931348623157e+308, - "minimum": -1.7976931348623157e+308, "type": "number", }, ], @@ -1553,13 +1413,18 @@ DocumentationError({ exports[`Documentation helpers > depictWrapped() > handle readonly 1`] = ` { + "readOnly": true, "type": "string", } `; exports[`Documentation helpers > depictWrapped() > should handle catch 1`] = ` { - "type": "boolean", + "default": true, + "type": [ + "boolean", + "null", + ], } `; @@ -1606,7 +1471,7 @@ exports[`Documentation helpers > excludeParamsFromDepiction() > should omit spec exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 1 1`] = ` { - "oneOf": [ + "anyOf": [ { "properties": {}, "required": [], @@ -1649,8 +1514,13 @@ exports[`Documentation helpers > excludeParamsFromDepiction() > should omit spec { "allOf": [ { - "properties": {}, - "required": [], + "additionalProperties": { + "type": "string", + }, + "propertyNames": { + "const": "a", + "type": "string", + }, "type": "object", }, { diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 78b9f39c0..665f7aa89 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -20,10 +20,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -35,8 +37,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -89,10 +91,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -104,8 +108,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -132,6 +136,8 @@ paths: application/json: schema: type: object + properties: {} + required: [] responses: "200": description: POST /v1/getSome/thing Positive response @@ -141,10 +147,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -156,8 +164,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -210,10 +218,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -225,8 +235,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -257,10 +267,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -272,8 +284,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -340,16 +352,13 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: num: type: number - format: double - minimum: -1.7976931348623157e+308 - maximum: 1.7976931348623157e+308 required: - num required: @@ -363,8 +372,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -390,6 +399,8 @@ paths: application/json: schema: type: object + properties: {} + required: [] security: - HTTP_1: [] OAUTH2_1: @@ -403,10 +414,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -418,8 +431,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -445,6 +458,8 @@ paths: application/json: schema: type: object + properties: {} + required: [] security: - HTTP_2: [] responses: @@ -456,10 +471,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -471,8 +488,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -542,14 +559,13 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: whatever: type: number - format: double required: - whatever required: @@ -563,8 +579,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -612,14 +628,12 @@ paths: required: true description: GET /v1/getSomething Parameter schema: + minItems: 1 + maxItems: 3 type: array items: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 - minItems: 1 - maxItems: 3 + exclusiveMaximum: 9007199254740991 - name: unlimited in: query required: true @@ -643,19 +657,16 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: literal: - type: string const: something + type: string transformation: type: number - format: double - minimum: -1.7976931348623157e+308 - maximum: 1.7976931348623157e+308 required: - literal - transformation @@ -670,8 +681,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -718,12 +729,12 @@ paths: content: application/json: schema: - oneOf: + anyOf: - type: object properties: type: - type: string const: a + type: string a: type: string required: @@ -732,8 +743,8 @@ paths: - type: object properties: type: - type: string const: b + type: string b: type: string required: @@ -751,15 +762,15 @@ paths: type: object properties: status: - type: string const: success + type: string data: - oneOf: + anyOf: - type: object properties: status: - type: string const: success + type: string data: format: any required: @@ -768,8 +779,8 @@ paths: - type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -793,8 +804,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -865,8 +876,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -875,8 +886,6 @@ paths: properties: five: type: integer - format: int64 - minimum: 0 maximum: 9007199254740991 six: type: string @@ -896,8 +905,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -969,9 +978,7 @@ paths: type: - integer - "null" - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 default: 123 responses: "200": @@ -982,8 +989,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -1004,8 +1011,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1055,16 +1062,14 @@ paths: type: object properties: union: - oneOf: + anyOf: - type: object properties: one: type: string two: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - one - two @@ -1072,9 +1077,7 @@ paths: properties: two: type: integer - format: int64 - minimum: -9007199254740991 - exclusiveMaximum: 0 + exclusiveMinimum: -9007199254740991 three: type: string required: @@ -1092,18 +1095,16 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: or: - oneOf: + anyOf: - type: string - type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - or required: @@ -1117,8 +1118,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1172,6 +1173,7 @@ paths: format: bigint boolean: type: boolean + readOnly: true dateIn: description: YYYY-MM-DDTHH:mm:ss.sssZ type: string @@ -1193,8 +1195,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -1220,8 +1222,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1268,16 +1270,7 @@ paths: content: application/json: schema: - type: object - properties: - name: - type: string - subcategories: - $ref: "#/components/schemas/Schema1" - required: - - name - - subcategories - required: true + $ref: "#/components/schemas/Schema2" responses: "200": description: POST /v1/getSomething Positive response @@ -1287,21 +1280,13 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: zodExample: - type: object - properties: - name: - type: string - subcategories: - $ref: "#/components/schemas/Schema1" - required: - - name - - subcategories + $ref: "#/components/schemas/Schema1" required: - zodExample required: @@ -1315,8 +1300,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1336,17 +1321,29 @@ paths: components: schemas: Schema1: - type: array - items: - type: object - properties: - name: - type: string - subcategories: + type: object + properties: + name: + type: string + subcategories: + type: array + items: $ref: "#/components/schemas/Schema1" - required: - - name - - subcategories + required: + - name + - subcategories + Schema2: + type: object + properties: + name: + type: string + subcategories: + type: array + items: + $ref: "#/components/schemas/Schema2" + required: + - name + - subcategories responses: {} parameters: {} examples: {} @@ -1379,10 +1376,12 @@ paths: type: object properties: status: - type: string const: OK + type: string result: type: object + properties: {} + required: [] required: - status - result @@ -1395,8 +1394,8 @@ paths: type: object properties: status: - type: string const: NOT OK + type: string required: - status components: @@ -1433,49 +1432,30 @@ paths: properties: double: type: number - format: double - minimum: -1.7976931348623157e+308 - maximum: 1.7976931348623157e+308 doublePositive: type: number - format: double - exclusiveMinimum: 0 - maximum: 1.7976931348623157e+308 doubleNegative: type: number - format: double - minimum: -1.7976931348623157e+308 - exclusiveMaximum: 0 doubleLimited: type: number - format: double minimum: -0.5 maximum: 0.5 int: type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 + exclusiveMinimum: -9007199254740991 + exclusiveMaximum: 9007199254740991 intPositive: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 intNegative: type: integer - format: int64 - minimum: -9007199254740991 - exclusiveMaximum: 0 + exclusiveMinimum: -9007199254740991 intLimited: type: integer - format: int64 minimum: -100 maximum: 100 zero: type: integer - format: int64 - minimum: 0 - maximum: 0 required: - double - doublePositive @@ -1495,8 +1475,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -1516,8 +1496,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1581,36 +1561,44 @@ paths: email: type: string format: email + pattern: ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$ uuid: type: string format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$ cuid: type: string format: cuid + pattern: ^[cC][^\\s-]{8,}$ cuid2: type: string format: cuid2 + pattern: ^[0-9a-z]+$ ulid: type: string format: ulid + pattern: ^[0-9A-HJKMNP-TV-Z]{26}$ ip: type: string - format: ip + format: ipv4 + pattern: ^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$ emoji: type: string format: emoji + pattern: ^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$ url: type: string - format: url + format: uri numeric: type: string + format: regex pattern: \\d+ combined: type: string minLength: 1 - pattern: .*@example\\.com maxLength: 90 - format: email + format: regex + pattern: .*@example\\.com required: - regular - min @@ -1636,8 +1624,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -1657,8 +1645,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1708,10 +1696,10 @@ paths: type: object properties: regularEnum: - type: string enum: - ABC - DEF + type: string required: - regularEnum required: true @@ -1724,16 +1712,16 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: nativeEnum: - type: number enum: - 1 - 2 + type: number required: - nativeEnum required: @@ -1747,8 +1735,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1796,6 +1784,8 @@ paths: application/json: schema: type: object + properties: {} + required: [] responses: "200": description: POST /v1/getSomething Positive response @@ -1805,8 +1795,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -1816,13 +1806,13 @@ paths: type: string additionalProperties: type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 + exclusiveMinimum: -9007199254740991 + exclusiveMaximum: 9007199254740991 stringy: type: object propertyNames: type: string + format: regex pattern: "[A-Z]+" additionalProperties: type: boolean @@ -1830,38 +1820,36 @@ paths: type: object propertyNames: type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 + exclusiveMinimum: -9007199254740991 + exclusiveMaximum: 9007199254740991 additionalProperties: type: boolean literal: type: object - properties: - only: - type: boolean - required: - - only + propertyNames: + const: only + type: string + additionalProperties: + type: boolean union: type: object - properties: - option1: - type: boolean - option2: - type: boolean - required: - - option1 - - option2 + propertyNames: + anyOf: + - const: option1 + type: string + - const: option2 + type: string + additionalProperties: + type: boolean enum: type: object - properties: - option1: - type: boolean - option2: - type: boolean - required: - - option1 - - option2 + propertyNames: + enum: + - option1 + - option2 + type: string + additionalProperties: + type: boolean required: - simple - stringy @@ -1880,8 +1868,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -1934,9 +1922,7 @@ paths: type: string two: type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 required: - one - two @@ -1950,8 +1936,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -1970,8 +1956,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2041,9 +2027,7 @@ paths: - type: boolean - type: string - type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 + exclusiveMaximum: 9007199254740991 items: not: {} required: @@ -2060,8 +2044,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -2083,8 +2067,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2142,8 +2126,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -2162,8 +2146,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2217,9 +2201,8 @@ paths: required: true description: GET /v1/getSomething Parameter schema: - format: int64 (preprocessed) - minimum: 0 maximum: 9007199254740991 + format: integer (preprocessed) responses: "200": description: GET /v1/getSomething Positive response @@ -2229,8 +2212,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -2249,8 +2232,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2301,9 +2284,6 @@ paths: properties: test: type: number - format: double - minimum: -1.7976931348623157e+308 - maximum: 1.7976931348623157e+308 required: - test required: true @@ -2316,8 +2296,8 @@ paths: type: object properties: status: - type: string const: ok + type: string data: type: object properties: @@ -2336,8 +2316,8 @@ paths: type: object properties: status: - type: string const: kinda + type: string data: type: object properties: @@ -2353,15 +2333,15 @@ paths: content: application/json: schema: - type: string const: error + type: string "500": description: POST /v1/mtpl Negative response 500 content: application/json: schema: - type: string const: failure + type: string components: schemas: {} responses: {} @@ -2393,12 +2373,14 @@ paths: required: true description: GET /v1/:name Parameter schema: + type: string summary: My custom schema - name: other in: query required: true description: GET /v1/:name Parameter schema: + type: boolean summary: My custom schema - name: regular in: query @@ -2415,12 +2397,13 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: number: + type: number summary: My custom schema required: - number @@ -2435,8 +2418,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2505,8 +2488,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -2525,8 +2508,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2584,8 +2567,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -2604,8 +2587,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2669,10 +2652,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -2684,8 +2669,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2757,10 +2742,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -2772,8 +2759,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2843,10 +2830,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -2858,8 +2847,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2907,10 +2896,10 @@ paths: required: true description: GET /v1/getSomething Parameter schema: + minItems: 1 type: array items: type: string - minItems: 1 responses: "200": description: GET /v1/getSomething Positive response @@ -2920,16 +2909,16 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: arr: + minItems: 1 type: array items: type: string - minItems: 1 required: - arr required: @@ -2943,8 +2932,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -2971,10 +2960,10 @@ paths: type: object properties: arr: + minItems: 1 type: array items: type: string - minItems: 1 required: - arr required: true @@ -2987,16 +2976,16 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: arr: + minItems: 1 type: array items: type: string - minItems: 1 required: - arr required: @@ -3010,8 +2999,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3058,7 +3047,7 @@ paths: content: application/json: schema: - oneOf: + anyOf: - type: object properties: id: @@ -3087,10 +3076,10 @@ paths: type: object properties: status: - type: string const: success + type: string data: - oneOf: + anyOf: - type: object properties: id: @@ -3120,8 +3109,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3171,8 +3160,8 @@ paths: required: true description: GET /v1/getSomething Parameter schema: - type: string deprecated: true + type: string responses: "200": description: GET /v1/getSomething Positive response @@ -3182,10 +3171,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -3197,8 +3188,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3265,8 +3256,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -3306,8 +3297,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3386,10 +3377,12 @@ components: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -3397,8 +3390,8 @@ components: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3456,16 +3449,13 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: num: type: number - format: double - minimum: -1.7976931348623157e+308 - maximum: 1.7976931348623157e+308 required: - num examples: @@ -3487,8 +3477,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3549,8 +3539,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -3577,8 +3567,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3645,8 +3635,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -3673,8 +3663,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3737,8 +3727,8 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: @@ -3765,8 +3755,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3814,8 +3804,8 @@ paths: required: true description: here is the test schema: - type: string description: here is the test + type: string responses: "200": description: GET /v1/getSomething Positive response @@ -3825,17 +3815,15 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object properties: result: - type: integer - format: int64 - exclusiveMinimum: 0 - maximum: 9007199254740991 description: some positive integer + type: integer + exclusiveMaximum: 9007199254740991 required: - result required: @@ -3849,8 +3837,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -3898,11 +3886,11 @@ paths: required: true description: parameter of post /v1/:name schema: - oneOf: - - type: string - const: John - - type: string - const: Jane + anyOf: + - const: John + type: string + - const: Jane + type: string requestBody: description: the body of request content: @@ -3924,10 +3912,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -3939,8 +3929,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -4018,19 +4008,21 @@ paths: components: schemas: ParameterOfPostV1NameName: - oneOf: - - type: string - const: John - - type: string - const: Jane + anyOf: + - const: John + type: string + - const: Jane + type: string SuperPositiveResponseOfV1Name: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -4038,8 +4030,8 @@ components: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -4086,11 +4078,11 @@ paths: required: true description: GET /v1/:name Parameter schema: - oneOf: - - type: string - const: John - - type: string - const: Jane + anyOf: + - const: John + type: string + - const: Jane + type: string - name: other in: query required: true @@ -4106,10 +4098,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -4121,8 +4115,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: @@ -4170,11 +4164,11 @@ paths: required: true description: POST /v1/:name Parameter schema: - oneOf: - - type: string - const: John - - type: string - const: Jane + anyOf: + - const: John + type: string + - const: Jane + type: string requestBody: description: POST /v1/:name Request body content: @@ -4196,10 +4190,12 @@ paths: type: object properties: status: - type: string const: success + type: string data: type: object + properties: {} + required: [] required: - status - data @@ -4211,8 +4207,8 @@ paths: type: object properties: status: - type: string const: error + type: string error: type: object properties: diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 86711cc1d..edcf6cbfe 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -1,81 +1,45 @@ -import type { $ZodType } from "@zod/core"; -import { ReferenceObject } from "openapi3-ts/oas31"; import * as R from "ramda"; import { z } from "zod"; import { ez } from "../src"; import { OpenAPIContext, - depictAny, - depictArray, - depictBigInt, - depictBoolean, - depictWrapped, - depictDate, - depictDateIn, - depictDateOut, - depictDefault, - depictEnum, depictExamples, - depictFile, - depictIntersection, - depictLazy, - depictLiteral, - depictNull, - depictNullable, - depictNumber, - depictObject, - depictObjectProperties, depictParamExamples, - depictPipeline, - depictRecord, depictRequestParams, depictSecurity, depictSecurityRefs, - depictString, depictTags, - depictTuple, - depictUnion, - depictUpload, - depictRaw, - depicters, ensureShortDescription, excludeExamplesFromDepiction, excludeParamsFromDepiction, defaultIsHeader, - onEach, - onMissing, reformatParamsInPath, + delegate, } from "../src/documentation-helpers"; -import { walkSchema } from "../src/schema-walker"; +/** + * @todo all these functions is now the one, and the tests naming is not relevant anymore + * @todo these tests should now be transformed into ones of particular postprocessors and assert exactly what they do. + * @todo So we would not test Zod here, but internal methods only. + */ describe("Documentation helpers", () => { const makeRefMock = vi.fn(); const requestCtx = { - path: "/v1/user/:id", - method: "get", - isResponse: false, - makeRef: makeRefMock, - next: (schema: $ZodType) => - walkSchema(schema, { - rules: depicters, - onEach, - onMissing, - ctx: requestCtx, - }), - } satisfies OpenAPIContext; + ctx: { + path: "/v1/user/:id", + method: "get", + isResponse: false, + makeRef: makeRefMock, + } satisfies OpenAPIContext, + }; const responseCtx = { - path: "/v1/user/:id", - method: "get", - isResponse: true, - makeRef: makeRefMock, - next: (schema: $ZodType) => - walkSchema(schema, { - rules: depicters, - onEach, - onMissing, - ctx: responseCtx, - }), - } satisfies OpenAPIContext; + ctx: { + path: "/v1/user/:id", + method: "get", + isResponse: true, + makeRef: makeRefMock, + } satisfies OpenAPIContext, + }; beforeEach(() => { makeRefMock.mockClear(); @@ -91,12 +55,7 @@ describe("Documentation helpers", () => { z.record(z.string(), z.string()), ), ])("should omit specified params %#", (schema) => { - const depicted = walkSchema(schema, { - ctx: requestCtx, - rules: depicters, - onEach, - onMissing, - }); + const depicted = delegate(schema, requestCtx); const [result, hasRequired] = excludeParamsFromDepiction(depicted, ["a"]); expect(result).toMatchSnapshot(); expect(hasRequired).toMatchSnapshot(); @@ -127,13 +86,11 @@ describe("Documentation helpers", () => { describe("depictDefault()", () => { test("should set default property", () => { - expect( - depictDefault(z.boolean().default(true), requestCtx), - ).toMatchSnapshot(); + expect(delegate(z.boolean().default(true), requestCtx)).toMatchSnapshot(); }); test("Feature #1706: should override the default value by a label from metadata", () => { expect( - depictDefault( + delegate( z.iso .datetime() .default(() => new Date().toISOString()) @@ -146,43 +103,39 @@ describe("Documentation helpers", () => { describe("depictWrapped()", () => { test("should handle catch", () => { - expect( - depictWrapped(z.boolean().catch(true), requestCtx), - ).toMatchSnapshot(); + expect(delegate(z.boolean().catch(true), requestCtx)).toMatchSnapshot(); }); test.each([requestCtx, responseCtx])("should handle optional %#", (ctx) => { - expect(depictWrapped(z.string().optional(), ctx)).toMatchSnapshot(); + expect(delegate(z.string().optional(), ctx)).toMatchSnapshot(); }); test("handle readonly", () => { - expect( - depictWrapped(z.string().readonly(), responseCtx), - ).toMatchSnapshot(); + expect(delegate(z.string().readonly(), responseCtx)).toMatchSnapshot(); }); }); describe("depictAny()", () => { test("should set format:any", () => { - expect(depictAny(z.any(), requestCtx)).toMatchSnapshot(); + expect(delegate(z.any(), requestCtx)).toMatchSnapshot(); }); }); describe("depictRaw()", () => { test("should depict the raw property", () => { expect( - depictRaw(ez.raw({ extra: z.string() }), requestCtx), + delegate(ez.raw({ extra: z.string() }), requestCtx), ).toMatchSnapshot(); }); }); describe("depictUpload()", () => { test("should set format:binary and type:string", () => { - expect(depictUpload(ez.upload(), requestCtx)).toMatchSnapshot(); + expect(delegate(ez.upload(), requestCtx)).toMatchSnapshot(); }); test("should throw when using in response", () => { expect(() => - depictUpload(ez.upload(), responseCtx), + delegate(ez.upload(), responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); @@ -195,20 +148,18 @@ describe("Documentation helpers", () => { ez.file("string"), ez.file("buffer"), ])("should set type:string and format accordingly %#", (schema) => { - expect(depictFile(schema, responseCtx)).toMatchSnapshot(); + expect(delegate(schema, responseCtx)).toMatchSnapshot(); }); }); describe("depictUnion()", () => { test("should wrap next depicters into oneOf property", () => { - expect( - depictUnion(z.string().or(z.number()), requestCtx), - ).toMatchSnapshot(); + expect(delegate(z.string().or(z.number()), requestCtx)).toMatchSnapshot(); }); test("should wrap next depicters in oneOf prop and set discriminator prop", () => { expect( - depictUnion( + delegate( z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.any() }), z.object({ @@ -225,7 +176,7 @@ describe("Documentation helpers", () => { describe("depictIntersection()", () => { test("should flatten two object schemas", () => { expect( - depictIntersection( + delegate( z.intersection( z.object({ one: z.number() }), z.object({ two: z.number() }), @@ -237,7 +188,7 @@ describe("Documentation helpers", () => { test("should flatten objects with same prop of same type", () => { expect( - depictIntersection( + delegate( z.intersection( z.object({ one: z.number() }), z.object({ one: z.number() }), @@ -249,7 +200,7 @@ describe("Documentation helpers", () => { test("should NOT flatten object schemas having conflicting props", () => { expect( - depictIntersection( + delegate( z.intersection( z.object({ one: z.number() }), z.object({ one: z.string() }), @@ -261,7 +212,7 @@ describe("Documentation helpers", () => { test("should merge examples deeply", () => { expect( - depictIntersection( + delegate( z.intersection( z .object({ test: z.object({ a: z.number() }) }) @@ -277,7 +228,7 @@ describe("Documentation helpers", () => { test("should flatten three object schemas with examples", () => { expect( - depictIntersection( + delegate( z.intersection( z.intersection( z.object({ one: z.number() }).example({ one: 123 }), @@ -292,9 +243,9 @@ describe("Documentation helpers", () => { test("should maintain uniqueness in the array of required props", () => { expect( - depictIntersection( + delegate( z.intersection( - z.record(z.literal("test"), z.number()), + z.object({ test: z.number() }), z.object({ test: z.literal(5) }), ), requestCtx, @@ -309,7 +260,7 @@ describe("Documentation helpers", () => { ), z.intersection(z.number(), z.literal(5)), // not objects ])("should fall back to allOf in other cases %#", (schema) => { - expect(depictIntersection(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }); }); @@ -317,14 +268,14 @@ describe("Documentation helpers", () => { test.each([requestCtx, responseCtx])( "should add null to the type %#", (ctx) => { - expect(depictNullable(z.string().nullable(), ctx)).toMatchSnapshot(); + expect(delegate(z.string().nullable(), ctx)).toMatchSnapshot(); }, ); test.each([z.null().nullable(), z.string().nullable().nullable()])( "should not add null type when it's already there %#", (schema) => { - expect(depictNullable(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }, ); }); @@ -337,7 +288,7 @@ describe("Documentation helpers", () => { test.each([z.enum(["one", "two"]), z.enum(Test)])( "should set type and enum properties", (schema) => { - expect(depictEnum(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }, ); }); @@ -346,12 +297,12 @@ describe("Documentation helpers", () => { test.each(["testng", null, BigInt(123), undefined])( "should set type and involve const property %#", (value) => { - expect(depictLiteral(z.literal(value), requestCtx)).toMatchSnapshot(); + expect(delegate(z.literal(value), requestCtx)).toMatchSnapshot(); }, ); test("should handle multiple values", () => { - expect(depictLiteral(z.literal([1, 2, 3]), requestCtx)).toMatchSnapshot(); + expect(delegate(z.literal([1, 2, 3]), requestCtx)).toMatchSnapshot(); }); }); @@ -371,7 +322,7 @@ describe("Documentation helpers", () => { ])( "should type:object, properties and required props %#", ({ shape, ctx }) => { - expect(depictObject(z.object(shape), ctx)).toMatchSnapshot(); + expect(delegate(z.object(shape), ctx)).toMatchSnapshot(); }, ); @@ -381,25 +332,25 @@ describe("Documentation helpers", () => { b: z.coerce.string(), c: z.coerce.string().optional(), }); - expect(depictObject(schema, responseCtx)).toMatchSnapshot(); + expect(delegate(schema, responseCtx)).toMatchSnapshot(); }); }); describe("depictNull()", () => { test("should give type:null", () => { - expect(depictNull(z.null(), requestCtx)).toMatchSnapshot(); + expect(delegate(z.null(), requestCtx)).toMatchSnapshot(); }); }); describe("depictBoolean()", () => { test("should set type:boolean", () => { - expect(depictBoolean(z.boolean(), requestCtx)).toMatchSnapshot(); + expect(delegate(z.boolean(), requestCtx)).toMatchSnapshot(); }); }); describe("depictBigInt()", () => { test("should set type:integer and format:bigint", () => { - expect(depictBigInt(z.bigint(), requestCtx)).toMatchSnapshot(); + expect(delegate(z.bigint(), requestCtx)).toMatchSnapshot(); }); }); @@ -415,14 +366,14 @@ describe("Documentation helpers", () => { ])( "should set properties+required or additionalProperties props %#", (schema) => { - expect(depictRecord(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }, ); }); describe("depictArray()", () => { test("should set type:array and pass items depiction", () => { - expect(depictArray(z.array(z.boolean()), requestCtx)).toMatchSnapshot(); + expect(delegate(z.array(z.boolean()), requestCtx)).toMatchSnapshot(); }); test.each([ @@ -432,14 +383,14 @@ describe("Documentation helpers", () => { z.boolean().array().length(4), z.array(z.boolean()).nonempty(), ])("should reflect min/max/exact length of the array %#", (schema) => { - expect(depictArray(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }); }); describe("depictTuple()", () => { test("should utilize prefixItems and set items:not:{}", () => { expect( - depictTuple( + delegate( z.tuple([z.boolean(), z.string(), z.literal("test")]), requestCtx, ), @@ -447,17 +398,17 @@ describe("Documentation helpers", () => { }); test("should depict rest as items when defined", () => { expect( - depictTuple(z.tuple([z.boolean()]).rest(z.string()), requestCtx), + delegate(z.tuple([z.boolean()]).rest(z.string()), requestCtx), ).toMatchSnapshot(); }); test("should depict empty tuples as is", () => { - expect(depictTuple(z.tuple([]), requestCtx)).toMatchSnapshot(); + expect(delegate(z.tuple([]), requestCtx)).toMatchSnapshot(); }); }); describe("depictString()", () => { test("should set type:string", () => { - expect(depictString(z.string(), requestCtx)).toMatchSnapshot(); + expect(delegate(z.string(), requestCtx)).toMatchSnapshot(); }); test.each([ @@ -479,47 +430,15 @@ describe("Documentation helpers", () => { z.cuid2(), z.ulid(), ])("should set format, pattern and min/maxLength props %#", (schema) => { - expect(depictString(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }); }); describe("depictNumber()", () => { - test.each([ - z.number(), - z.int(), - z.float64(), - R.assocPath(["_zod", "def", "format"], "hacked", z.int()), - ])( + test.each([z.number(), z.int(), z.float64()])( "should set min/max values according to JS capabilities %#", (schema) => { - expect(depictNumber(schema, requestCtx)).toMatchSnapshot(); - }, - ); - - test.each([z.number(), z.int()])( - "should use numericRange when set %#", - (schema) => { - expect( - depictNumber(schema, { - ...requestCtx, - numericRange: { - integer: [-100, 100], - float: [-1000 / 3, 1000 / 3], - }, - }), - ).toMatchSnapshot(); - }, - ); - - test.each([z.number(), z.int()])( - "should not use numericRange when it is null %#", - (schema) => { - expect( - depictNumber(schema, { - ...requestCtx, - numericRange: null, - }), - ).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }, ); @@ -537,32 +456,18 @@ describe("Documentation helpers", () => { ])( "should use schema checks for min/max and exclusiveness %#", (schema) => { - expect(depictNumber(schema, requestCtx)).toMatchSnapshot(); + expect(delegate(schema, requestCtx)).toMatchSnapshot(); }, ); }); - describe("depictObjectProperties()", () => { - test("should wrap next depicters in a shape of object", () => { - expect( - depictObjectProperties( - z.object({ - one: z.string(), - two: z.boolean(), - }), - requestCtx.next, - ), - ).toMatchSnapshot(); - }); - }); - describe("depictPipeline", () => { test.each([ { ctx: responseCtx, expected: "boolean (out)" }, { ctx: requestCtx, expected: "string (in)" }, ])("should depict as $expected", ({ ctx }) => { expect( - depictPipeline(z.string().transform(Boolean).pipe(z.boolean()), ctx), + delegate(z.string().transform(Boolean).pipe(z.boolean()), ctx), ).toMatchSnapshot(); }); @@ -583,14 +488,14 @@ describe("Documentation helpers", () => { expected: "string (preprocess)", }, ])("should depict as $expected", ({ schema, ctx }) => { - expect(depictPipeline(schema, ctx)).toMatchSnapshot(); + expect(delegate(schema, ctx)).toMatchSnapshot(); }); test.each([ z.number().transform((num) => () => num), z.number().transform(() => assert.fail("this should be handled")), - ])("should handle edge cases", (schema) => { - expect(depictPipeline(schema, responseCtx)).toMatchSnapshot(); + ])("should handle edge cases %#", (schema) => { + expect(delegate(schema, responseCtx)).toMatchSnapshot(); }); }); @@ -678,7 +583,7 @@ describe("Documentation helpers", () => { }), inputSources: ["query", "params"], composition: "inline", - ...requestCtx, + ...requestCtx.ctx, }), ).toMatchSnapshot(); }); @@ -692,7 +597,7 @@ describe("Documentation helpers", () => { }), inputSources: ["body", "params"], composition: "inline", - ...requestCtx, + ...requestCtx.ctx, }), ).toMatchSnapshot(); }); @@ -706,7 +611,7 @@ describe("Documentation helpers", () => { }), inputSources: ["body"], composition: "inline", - ...requestCtx, + ...requestCtx.ctx, }), ).toMatchSnapshot(); }); @@ -723,7 +628,7 @@ describe("Documentation helpers", () => { inputSources: ["query", "headers", "params"], composition: "inline", security: [[{ type: "header", name: "secure" }]], - ...requestCtx, + ...requestCtx.ctx, }), ).toMatchSnapshot(); }); @@ -743,61 +648,26 @@ describe("Documentation helpers", () => { describe("depictDateIn", () => { test("should set type:string, pattern and format", () => { - expect(depictDateIn(ez.dateIn(), requestCtx)).toMatchSnapshot(); + expect(delegate(ez.dateIn(), requestCtx)).toMatchSnapshot(); }); test("should throw when ZodDateIn in response", () => { expect(() => - depictDateIn(ez.dateIn(), responseCtx), + delegate(ez.dateIn(), responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); describe("depictDateOut", () => { test("should set type:string, description and format", () => { - expect(depictDateOut(ez.dateOut(), responseCtx)).toMatchSnapshot(); + expect(delegate(ez.dateOut(), responseCtx)).toMatchSnapshot(); }); test("should throw when ZodDateOut in request", () => { expect(() => - depictDateOut(ez.dateOut(), requestCtx), + delegate(ez.dateOut(), requestCtx), ).toThrowErrorMatchingSnapshot(); }); }); - describe("depictDate", () => { - test.each([responseCtx, requestCtx])( - "should throw clear error %#", - (ctx) => { - expect(() => depictDate(z.date(), ctx)).toThrowErrorMatchingSnapshot(); - }, - ); - }); - - describe("depictLazy", () => { - const recursiveArray: z.ZodLazy = z.lazy(() => recursiveArray.array()); - const directlyRecursive: z.ZodLazy = z.lazy(() => directlyRecursive); - const recursiveObject: z.ZodLazy = z.lazy(() => - z.object({ prop: recursiveObject }), - ); - - test.each([recursiveArray, directlyRecursive, recursiveObject])( - "should handle circular references %#", - (schema) => { - makeRefMock.mockImplementationOnce( - (): ReferenceObject => ({ - $ref: "#/components/schemas/SomeSchema", - }), - ); - expect(makeRefMock).not.toHaveBeenCalled(); - expect(depictLazy(schema, responseCtx)).toMatchSnapshot(); - expect(makeRefMock).toHaveBeenCalledTimes(1); - expect(makeRefMock).toHaveBeenCalledWith( - schema._zod.def.getter, - expect.any(Function), - ); - }, - ); - }); - describe("depictSecurity()", () => { test("should handle Basic, Bearer and Header Securities", () => { expect( diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 9151593b6..d1598c949 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1,7 +1,6 @@ import camelize from "camelize-ts"; import snakify from "snakify-ts"; import { - Depicter, Documentation, DocumentationError, EndpointsFactory, @@ -10,9 +9,10 @@ import { defaultEndpointsFactory, ez, ResultHandler, + Overrider, } from "../src"; import { contentTypes } from "../src/content-type"; -import { globalRegistry, z } from "zod"; +import { z } from "zod"; import { givePort } from "../../tools/ports"; describe("Documentation", () => { @@ -38,7 +38,6 @@ describe("Documentation", () => { }), }, }, - numericRange: null, config: sampleConfig, version: "3.4.5", title: "Testing DELETE request without body", @@ -496,7 +495,7 @@ describe("Documentation", () => { method: "post", input: category, output: z.object({ - zodExample: category, + zodExample: category, // @todo consider external registry to deduplicate it }), handler: async () => ({ zodExample: { @@ -521,49 +520,6 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); - test.each([ - z.undefined(), - z.map(z.any(), z.any()), - z.set(z.any()), - z.promise(z.any()), - z.nan(), - z.symbol(), - z.unknown(), - z.never(), - z.void(), - ])("should throw on unsupported types %#", (zodType) => { - expect( - () => - new Documentation({ - config: sampleConfig, - routing: { - v1: { - getSomething: defaultEndpointsFactory.build({ - method: "post", - input: z.object({ - property: zodType, - }), - output: z.object({}), - handler: async () => ({}), - }), - }, - }, - version: "3.4.5", - title: "Testing unsupported types", - serverUrl: "https://example.com", - }), - ).toThrow( - new DocumentationError( - `Zod type ${zodType.constructor.name} is unsupported.`, - { - method: "post", - path: "/v1/getSomething", - isResponse: false, - }, - ), - ); - }); - test("should ensure uniq security schema names", () => { const mw1 = new Middleware({ security: { @@ -1228,13 +1184,7 @@ describe("Documentation", () => { describe("Feature #1470: Custom brands", () => { test("should be handled accordingly in request, response and params", () => { const deep = Symbol("DEEP"); - const rule: Depicter = ( - schema: ReturnType, - { next }, - ) => { - globalRegistry.remove(schema); - return next(schema); - }; + const rule: Overrider = ({ jsonSchema }) => (jsonSchema.type = "boolean"); const spec = new Documentation({ config: sampleConfig, routing: { @@ -1253,9 +1203,7 @@ describe("Documentation", () => { }, }, brandHandling: { - CUSTOM: () => ({ - summary: "My custom schema", - }), + CUSTOM: ({ jsonSchema }) => (jsonSchema.summary = "My custom schema"), [deep]: rule, }, version: "3.4.5", diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index 0d35bd957..e3dba8809 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -1,5 +1,7 @@ +import type { $ZodType, JSONSchema } from "@zod/core"; import { IRouter } from "express"; import ts from "typescript"; +import { expectTypeOf } from "vitest"; import { z } from "zod"; import * as entrypoint from "../src"; import { @@ -10,7 +12,7 @@ import { CommonConfig, CookieSecurity, HeaderSecurity, - Depicter, + Overrider, FlatObject, IOSchema, InputSecurity, @@ -37,9 +39,9 @@ describe("Index Entrypoint", () => { }); test("Convenience types should be exposed", () => { - expectTypeOf(() => ({ - type: "number" as const, - })).toExtend(); + expectTypeOf( + ({}: { zodSchema: $ZodType; jsonSchema: JSONSchema.BaseSchema }) => {}, + ).toExtend(); expectTypeOf(() => ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), ).toExtend(); From 1bba5f1ea0ed2e5d4c9358065b42de578245d771 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 20 Apr 2025 20:48:54 +0200 Subject: [PATCH 012/187] Helper to ensure valid `type` (#2553) Addressing todo from #2547 and improving `makeSupportedType` --- express-zod-api/src/documentation-helpers.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 8048e8633..664215b60 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -98,7 +98,7 @@ const samples = { object: {}, null: null, array: [], -} satisfies Record, unknown>; +} satisfies Record; export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); @@ -197,18 +197,14 @@ const onNullable: Overrider = ({ jsonSchema }) => { delete jsonSchema.anyOf; }; +const isSupportedType = (subject: string): subject is SchemaObjectType => + subject in samples; + const getSupportedType = (value: unknown): SchemaObjectType | undefined => { const detected = R.toLower(R.type(value)); // toLower is typed well unlike .toLowerCase() - const isSupported = - detected === "number" || - detected === "string" || - detected === "boolean" || - detected === "object" || - detected === "null" || - detected === "array"; return typeof value === "bigint" ? "integer" - : isSupported + : isSupportedType(detected) ? detected : undefined; }; @@ -277,7 +273,8 @@ const makeNullableType = ({ | SchemaObjectType | SchemaObjectType[] => { if (type === "null") return type; - if (typeof type === "string") return [type as SchemaObjectType, "null"]; // @todo make method instead of "as" + if (typeof type === "string") + return isSupportedType(type) ? [type, "null"] : "null"; return type ? [...new Set(type).add("null")] : "null"; }; From 694aa3ef87fdde6c4d46f49cff9f60688c130daf Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 21 Apr 2025 11:21:06 +0200 Subject: [PATCH 013/187] Removing overrides for enums and literals (#2557) `type` is not needed when you have `const` or `enum` entries. So this one can be fully delegated to Zod 4. --- example/example.documentation.yaml | 16 --- express-zod-api/src/documentation-helpers.ts | 30 +---- .../documentation-helpers.spec.ts.snap | 15 --- .../__snapshots__/documentation.spec.ts.snap | 115 ------------------ 4 files changed, 1 insertion(+), 175 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b729d5ca5..d33adc958 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -30,7 +30,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -59,7 +58,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -177,7 +175,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -215,7 +212,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -260,7 +256,6 @@ paths: properties: status: const: created - type: string data: type: object properties: @@ -281,7 +276,6 @@ paths: properties: status: const: created - type: string data: type: object properties: @@ -302,7 +296,6 @@ paths: properties: status: const: error - type: string reason: type: string required: @@ -317,7 +310,6 @@ paths: properties: status: const: exists - type: string id: type: integer exclusiveMinimum: -9007199254740991 @@ -334,7 +326,6 @@ paths: properties: status: const: error - type: string reason: type: string required: @@ -465,7 +456,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -502,7 +492,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -542,7 +531,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -563,7 +551,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -608,7 +595,6 @@ paths: exclusiveMaximum: 9007199254740991 event: const: time - type: string id: type: string retry: @@ -660,7 +646,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -681,7 +666,6 @@ paths: properties: status: const: error - type: string error: type: object properties: diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 664215b60..524e8368a 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,11 +1,4 @@ -import type { - $ZodEnum, - $ZodLiteral, - $ZodPipe, - $ZodTuple, - $ZodType, - JSONSchema, -} from "@zod/core"; +import type { $ZodPipe, $ZodTuple, $ZodType, JSONSchema } from "@zod/core"; import { ExamplesObject, isReferenceObject, @@ -200,25 +193,6 @@ const onNullable: Overrider = ({ jsonSchema }) => { const isSupportedType = (subject: string): subject is SchemaObjectType => subject in samples; -const getSupportedType = (value: unknown): SchemaObjectType | undefined => { - const detected = R.toLower(R.type(value)); // toLower is typed well unlike .toLowerCase() - return typeof value === "bigint" - ? "integer" - : isSupportedType(detected) - ? detected - : undefined; -}; - -const onEnum: Overrider = ({ zodSchema, jsonSchema }) => - (jsonSchema.type = getSupportedType( - Object.values((zodSchema as $ZodEnum)._zod.def.entries)[0], - )); - -const onLiteral: Overrider = ({ zodSchema, jsonSchema }) => - (jsonSchema.type = getSupportedType( - Object.values((zodSchema as $ZodLiteral)._zod.def.values)[0], - )); - const onDateIn: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); @@ -443,10 +417,8 @@ const overrides: Partial> = default: onDefault, any: onAny, union: onUnion, - enum: onEnum, bigint: onBigInt, intersection: onIntersection, - literal: onLiteral, tuple: onTuple, pipe: onPipeline, [ezDateInBrand]: onDateIn, 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 391c58329..3adfb5c3a 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -139,7 +139,6 @@ exports[`Documentation helpers > depictEnum() > should set type and enum propert "one", "two", ], - "type": "string", } `; @@ -149,7 +148,6 @@ exports[`Documentation helpers > depictEnum() > should set type and enum propert "ONE", "TWO", ], - "type": "string", } `; @@ -288,7 +286,6 @@ exports[`Documentation helpers > depictIntersection() > should fall back to allO }, { "const": 5, - "type": "number", }, ], } @@ -411,28 +408,24 @@ exports[`Documentation helpers > depictLiteral() > should handle multiple values 2, 3, ], - "type": "number", } `; exports[`Documentation helpers > depictLiteral() > should set type and involve const property 0 1`] = ` { "const": "testng", - "type": "string", } `; exports[`Documentation helpers > depictLiteral() > should set type and involve const property 1 1`] = ` { "const": null, - "type": "null", } `; exports[`Documentation helpers > depictLiteral() > should set type and involve const property 2 1`] = ` { "const": 123, - "type": "integer", } `; @@ -739,7 +732,6 @@ exports[`Documentation helpers > depictRecord() > should set properties+required "one", "two", ], - "type": "string", }, "type": "object", } @@ -752,7 +744,6 @@ exports[`Documentation helpers > depictRecord() > should set properties+required }, "propertyNames": { "const": "testing", - "type": "string", }, "type": "object", } @@ -767,11 +758,9 @@ exports[`Documentation helpers > depictRecord() > should set properties+required "anyOf": [ { "const": "one", - "type": "string", }, { "const": "two", - "type": "string", }, ], }, @@ -1327,7 +1316,6 @@ exports[`Documentation helpers > depictTuple() > should utilize prefixItems and }, { "const": "test", - "type": "string", }, ], "type": "array", @@ -1344,7 +1332,6 @@ exports[`Documentation helpers > depictUnion() > should wrap next depicters in o }, "status": { "const": "success", - "type": "string", }, }, "required": [ @@ -1368,7 +1355,6 @@ exports[`Documentation helpers > depictUnion() > should wrap next depicters in o }, "status": { "const": "error", - "type": "string", }, }, "required": [ @@ -1519,7 +1505,6 @@ exports[`Documentation helpers > excludeParamsFromDepiction() > should omit spec }, "propertyNames": { "const": "a", - "type": "string", }, "type": "object", }, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 665f7aa89..a8444b55a 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -21,7 +21,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -38,7 +37,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -92,7 +90,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -109,7 +106,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -148,7 +144,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -165,7 +160,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -219,7 +213,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -236,7 +229,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -268,7 +260,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -285,7 +276,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -353,7 +343,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -373,7 +362,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -415,7 +403,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -432,7 +419,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -472,7 +458,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -489,7 +474,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -560,7 +544,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -580,7 +563,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -658,13 +640,11 @@ paths: properties: status: const: success - type: string data: type: object properties: literal: const: something - type: string transformation: type: number required: @@ -682,7 +662,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -734,7 +713,6 @@ paths: properties: type: const: a - type: string a: type: string required: @@ -744,7 +722,6 @@ paths: properties: type: const: b - type: string b: type: string required: @@ -763,14 +740,12 @@ paths: properties: status: const: success - type: string data: anyOf: - type: object properties: status: const: success - type: string data: format: any required: @@ -780,7 +755,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -805,7 +779,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -877,7 +850,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -906,7 +878,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -990,7 +961,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1012,7 +982,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1096,7 +1065,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1119,7 +1087,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1196,7 +1163,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1223,7 +1189,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1281,7 +1246,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1301,7 +1265,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1377,7 +1340,6 @@ paths: properties: status: const: OK - type: string result: type: object properties: {} @@ -1395,7 +1357,6 @@ paths: properties: status: const: NOT OK - type: string required: - status components: @@ -1476,7 +1437,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1497,7 +1457,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1625,7 +1584,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1646,7 +1604,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1699,7 +1656,6 @@ paths: enum: - ABC - DEF - type: string required: - regularEnum required: true @@ -1713,7 +1669,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1721,7 +1676,6 @@ paths: enum: - 1 - 2 - type: number required: - nativeEnum required: @@ -1736,7 +1690,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1796,7 +1749,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1828,7 +1780,6 @@ paths: type: object propertyNames: const: only - type: string additionalProperties: type: boolean union: @@ -1836,9 +1787,7 @@ paths: propertyNames: anyOf: - const: option1 - type: string - const: option2 - type: string additionalProperties: type: boolean enum: @@ -1847,7 +1796,6 @@ paths: enum: - option1 - option2 - type: string additionalProperties: type: boolean required: @@ -1869,7 +1817,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -1937,7 +1884,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -1957,7 +1903,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2045,7 +1990,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2068,7 +2012,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2127,7 +2070,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2147,7 +2089,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2213,7 +2154,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2233,7 +2173,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2297,7 +2236,6 @@ paths: properties: status: const: ok - type: string data: type: object properties: @@ -2317,7 +2255,6 @@ paths: properties: status: const: kinda - type: string data: type: object properties: @@ -2334,14 +2271,12 @@ paths: application/json: schema: const: error - type: string "500": description: POST /v1/mtpl Negative response 500 content: application/json: schema: const: failure - type: string components: schemas: {} responses: {} @@ -2398,7 +2333,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2419,7 +2353,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2489,7 +2422,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2509,7 +2441,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2568,7 +2499,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2588,7 +2518,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2653,7 +2582,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -2670,7 +2598,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2743,7 +2670,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -2760,7 +2686,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2831,7 +2756,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -2848,7 +2772,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2910,7 +2833,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -2933,7 +2855,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -2977,7 +2898,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3000,7 +2920,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3077,7 +2996,6 @@ paths: properties: status: const: success - type: string data: anyOf: - type: object @@ -3110,7 +3028,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3172,7 +3089,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -3189,7 +3105,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3257,7 +3172,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3298,7 +3212,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3378,7 +3291,6 @@ components: properties: status: const: success - type: string data: type: object properties: {} @@ -3391,7 +3303,6 @@ components: properties: status: const: error - type: string error: type: object properties: @@ -3450,7 +3361,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3478,7 +3388,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3540,7 +3449,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3568,7 +3476,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3636,7 +3543,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3664,7 +3570,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3728,7 +3633,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3756,7 +3660,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3816,7 +3719,6 @@ paths: properties: status: const: success - type: string data: type: object properties: @@ -3838,7 +3740,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -3888,9 +3789,7 @@ paths: schema: anyOf: - const: John - type: string - const: Jane - type: string requestBody: description: the body of request content: @@ -3913,7 +3812,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -3930,7 +3828,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -4010,15 +3907,12 @@ components: ParameterOfPostV1NameName: anyOf: - const: John - type: string - const: Jane - type: string SuperPositiveResponseOfV1Name: type: object properties: status: const: success - type: string data: type: object properties: {} @@ -4031,7 +3925,6 @@ components: properties: status: const: error - type: string error: type: object properties: @@ -4080,9 +3973,7 @@ paths: schema: anyOf: - const: John - type: string - const: Jane - type: string - name: other in: query required: true @@ -4099,7 +3990,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -4116,7 +4006,6 @@ paths: properties: status: const: error - type: string error: type: object properties: @@ -4166,9 +4055,7 @@ paths: schema: anyOf: - const: John - type: string - const: Jane - type: string requestBody: description: POST /v1/:name Request body content: @@ -4191,7 +4078,6 @@ paths: properties: status: const: success - type: string data: type: object properties: {} @@ -4208,7 +4094,6 @@ paths: properties: status: const: error - type: string error: type: object properties: From e3984ed588f3e752492f1fe65995a56ea621700e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 21 Apr 2025 11:39:56 +0200 Subject: [PATCH 014/187] Removing overrides for `any` (#2559) Unnecessary complication --- example/example.documentation.yaml | 3 +-- express-zod-api/src/documentation-helpers.ts | 5 ---- .../documentation-helpers.spec.ts.snap | 26 ++++--------------- .../__snapshots__/documentation.spec.ts.snap | 9 +++---- 4 files changed, 9 insertions(+), 34 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index d33adc958..4714e3d26 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -472,8 +472,7 @@ paths: type: object propertyNames: type: string - additionalProperties: - format: any + additionalProperties: {} required: - name - size diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 524e8368a..ff7400d35 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -101,8 +101,6 @@ const onDefault: Overrider = ({ zodSchema, jsonSchema }) => globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? jsonSchema.default); -const onAny: Overrider = ({ jsonSchema }) => (jsonSchema.format = "any"); - const onUpload: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.upload() only for input.", ctx); @@ -280,8 +278,6 @@ const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { Object.assign(jsonSchema, { type: targetType as "number" | "string" | "boolean", }); - } else { - onAny({ zodSchema, jsonSchema }, ctx); } } } @@ -415,7 +411,6 @@ const overrides: Partial> = { nullable: onNullable, default: onDefault, - any: onAny, union: onUnion, bigint: onBigInt, intersection: onIntersection, 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 3adfb5c3a..8e2737f50 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -1,10 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Documentation helpers > depictAny() > should set format:any 1`] = ` -{ - "format": "any", -} -`; +exports[`Documentation helpers > depictAny() > should set format:any 1`] = `{}`; exports[`Documentation helpers > depictArray() > should reflect min/max/exact length of the array 0 1`] = ` { @@ -677,17 +673,9 @@ exports[`Documentation helpers > depictPipeline > should depict as 'string (prep } `; -exports[`Documentation helpers > depictPipeline > should handle edge cases 0 1`] = ` -{ - "format": "any", -} -`; +exports[`Documentation helpers > depictPipeline > should handle edge cases 0 1`] = `{}`; -exports[`Documentation helpers > depictPipeline > should handle edge cases 1 1`] = ` -{ - "format": "any", -} -`; +exports[`Documentation helpers > depictPipeline > should handle edge cases 1 1`] = `{}`; exports[`Documentation helpers > depictRaw() > should depict the raw property 1`] = ` { @@ -770,9 +758,7 @@ exports[`Documentation helpers > depictRecord() > should set properties+required exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 5 1`] = ` { - "additionalProperties": { - "format": "any", - }, + "additionalProperties": {}, "propertyNames": { "type": "string", }, @@ -1327,9 +1313,7 @@ exports[`Documentation helpers > depictUnion() > should wrap next depicters in o "anyOf": [ { "properties": { - "data": { - "format": "any", - }, + "data": {}, "status": { "const": "success", }, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index a8444b55a..41404ac4d 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -746,8 +746,7 @@ paths: properties: status: const: success - data: - format: any + data: {} required: - status - data @@ -2058,8 +2057,7 @@ paths: in: query required: false description: GET /v1/getSomething Parameter - schema: - format: any + schema: {} responses: "200": description: GET /v1/getSomething Positive response @@ -2073,8 +2071,7 @@ paths: data: type: object properties: - any: - format: any + any: {} required: - any required: From 45c34f01adedf34b58bf803624d058befbcfa6e3 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 21 Apr 2025 13:56:37 +0200 Subject: [PATCH 015/187] Tests for overriders and minor adjustments (#2556) Instead of testing Zod's depiction, transforming current tests into the ones testing actions of overriders individually. --- express-zod-api/src/documentation-helpers.ts | 32 +- .../documentation-helpers.spec.ts.snap | 1257 ++++------------- .../__snapshots__/documentation.spec.ts.snap | 6 +- .../tests/documentation-helpers.spec.ts | 612 +++----- express-zod-api/tests/env.spec.ts | 22 + 5 files changed, 512 insertions(+), 1417 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ff7400d35..ef5945f6a 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -96,18 +96,18 @@ const samples = { export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -const onDefault: Overrider = ({ zodSchema, jsonSchema }) => +export const onDefault: Overrider = ({ zodSchema, jsonSchema }) => (jsonSchema.default = globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? jsonSchema.default); -const onUpload: Overrider = ({ jsonSchema }, ctx) => { +export const onUpload: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.upload() only for input.", ctx); Object.assign(jsonSchema, { type: "string", format: "binary" }); }; -const onFile: Overrider = ({ jsonSchema }) => { +export const onFile: Overrider = ({ jsonSchema }) => { delete jsonSchema.anyOf; // undo default Object.assign(jsonSchema, { type: "string", @@ -120,7 +120,7 @@ const onFile: Overrider = ({ jsonSchema }) => { }); }; -const onUnion: Overrider = ({ zodSchema, jsonSchema }) => { +export const onUnion: Overrider = ({ zodSchema, jsonSchema }) => { if (!zodSchema._zod.disc) return; const propertyName = Array.from(zodSchema._zod.disc.keys()).pop(); jsonSchema.discriminator ??= { propertyName }; @@ -172,7 +172,7 @@ const intersect = R.tryCatch( (_err, allOf): JSONSchema.BaseSchema => ({ allOf }), ); -const onIntersection: Overrider = ({ jsonSchema }) => { +export const onIntersection: Overrider = ({ jsonSchema }) => { if (!jsonSchema.allOf) return; const attempt = intersect(jsonSchema.allOf); delete jsonSchema.allOf; // undo default @@ -180,7 +180,7 @@ const onIntersection: Overrider = ({ jsonSchema }) => { }; /** @since OAS 3.1 nullable replaced with type array having null */ -const onNullable: Overrider = ({ jsonSchema }) => { +export const onNullable: Overrider = ({ jsonSchema }) => { if (!jsonSchema.anyOf) return; const original = jsonSchema.anyOf[0]; Object.assign(original, { type: makeNullableType(original) }); @@ -191,7 +191,7 @@ const onNullable: Overrider = ({ jsonSchema }) => { const isSupportedType = (subject: string): subject is SchemaObjectType => subject in samples; -const onDateIn: Overrider = ({ jsonSchema }, ctx) => { +export const onDateIn: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); delete jsonSchema.anyOf; // undo default @@ -206,7 +206,7 @@ const onDateIn: Overrider = ({ jsonSchema }, ctx) => { }); }; -const onDateOut: Overrider = ({ jsonSchema }, ctx) => { +export const onDateOut: Overrider = ({ jsonSchema }, ctx) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); Object.assign(jsonSchema, { @@ -219,14 +219,18 @@ const onDateOut: Overrider = ({ jsonSchema }, ctx) => { }); }; -const onBigInt: Overrider = ({ jsonSchema }) => - Object.assign(jsonSchema, { type: "integer", format: "bigint" }); +export const onBigInt: Overrider = ({ jsonSchema }) => + Object.assign(jsonSchema, { + type: "string", + format: "bigint", + pattern: /^-?\d+$/.source, + }); /** * @since OAS 3.1 using prefixItems for depicting tuples * @since 17.5.0 added rest handling, fixed tuple type */ -const onTuple: Overrider = ({ zodSchema, jsonSchema }) => { +export const onTuple: Overrider = ({ zodSchema, jsonSchema }) => { if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return; // does not appear to support items:false, so not:{} is a recommended alias jsonSchema.items = { not: {} }; @@ -250,7 +254,7 @@ const makeNullableType = ({ return type ? [...new Set(type).add("null")] : "null"; }; -const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { +export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { const target = (zodSchema as $ZodPipe)._zod.def[ ctx.isResponse ? "out" : "in" ]; @@ -284,7 +288,7 @@ const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { } }; -const onRaw: Overrider = ({ jsonSchema }) => { +export const onRaw: Overrider = ({ jsonSchema }) => { Object.assign( jsonSchema, (jsonSchema as JSONSchema.ObjectSchema).properties!.raw, @@ -479,7 +483,7 @@ const fixReferences = ( }; // @todo rename? -export const delegate = ( +const delegate = ( schema: $ZodType, { ctx, 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 8e2737f50..75d62610c 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -1,634 +1,36 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Documentation helpers > depictAny() > should set format:any 1`] = `{}`; - -exports[`Documentation helpers > depictArray() > should reflect min/max/exact length of the array 0 1`] = ` -{ - "items": { - "type": "boolean", - }, - "minItems": 3, - "type": "array", -} -`; - -exports[`Documentation helpers > depictArray() > should reflect min/max/exact length of the array 1 1`] = ` -{ - "items": { - "type": "boolean", - }, - "maxItems": 5, - "type": "array", -} -`; - -exports[`Documentation helpers > depictArray() > should reflect min/max/exact length of the array 2 1`] = ` -{ - "items": { - "type": "boolean", - }, - "maxItems": 5, - "minItems": 3, - "type": "array", -} -`; - -exports[`Documentation helpers > depictArray() > should reflect min/max/exact length of the array 3 1`] = ` -{ - "items": { - "type": "boolean", - }, - "maxItems": 4, - "minItems": 4, - "type": "array", -} -`; - -exports[`Documentation helpers > depictArray() > should reflect min/max/exact length of the array 4 1`] = ` -{ - "items": { - "type": "boolean", - }, - "minItems": 1, - "type": "array", -} -`; - -exports[`Documentation helpers > depictArray() > should set type:array and pass items depiction 1`] = ` -{ - "items": { - "type": "boolean", - }, - "type": "array", -} -`; - -exports[`Documentation helpers > depictBigInt() > should set type:integer and format:bigint 1`] = ` -{ - "format": "bigint", - "type": "integer", -} -`; - -exports[`Documentation helpers > depictBoolean() > should set type:boolean 1`] = ` -{ - "type": "boolean", -} -`; - -exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 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 throw when ZodDateIn in response 1`] = ` -DocumentationError({ - "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Please use ez.dateOut() for output.", -}) -`; - -exports[`Documentation helpers > depictDateOut > should set type:string, description and format 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 throw when ZodDateOut in request 1`] = ` -DocumentationError({ - "cause": "Input schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Please use ez.dateIn() for input.", -}) -`; - -exports[`Documentation helpers > depictDefault() > Feature #1706: should override the default value by a label from metadata 1`] = ` -{ - "default": "Today", - "format": "date-time", - "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$", - "type": "string", -} -`; - -exports[`Documentation helpers > depictDefault() > should set default property 1`] = ` -{ - "default": true, - "type": "boolean", -} -`; - -exports[`Documentation helpers > depictEnum() > should set type and enum properties 1`] = ` -{ - "enum": [ - "one", - "two", - ], -} -`; - -exports[`Documentation helpers > depictEnum() > should set type and enum properties 2`] = ` -{ - "enum": [ - "ONE", - "TWO", - ], -} -`; - -exports[`Documentation helpers > depictExamples() > should 'pass' examples in case of 'request' 1`] = ` -{ - "example1": { - "value": { - "one": "test", - "two": 123, - }, - }, - "example2": { - "value": { - "one": "test2", - "two": 456, - }, - }, -} -`; - -exports[`Documentation helpers > depictExamples() > should 'transform' examples in case of 'response' 1`] = ` -{ - "example1": { - "value": { - "one": 4, - "two": "123", - }, - }, - "example2": { - "value": { - "one": 5, - "two": "456", - }, - }, -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 0 1`] = ` -{ - "format": "file", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 1 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 2 1`] = ` -{ - "contentEncoding": "base64", - "format": "byte", - "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 3 1`] = ` -{ - "format": "file", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 4 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > depictIntersection() > should NOT flatten object schemas having conflicting props 1`] = ` -{ - "allOf": [ - { - "properties": { - "one": { - "type": "number", - }, - }, - "required": [ - "one", - ], - "type": "object", - }, - { - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", - }, - ], -} -`; - -exports[`Documentation helpers > depictIntersection() > should fall back to allOf in other cases 0 1`] = ` -{ - "allOf": [ - { - "additionalProperties": { - "type": "number", - }, - "propertyNames": { - "type": "string", - }, - "type": "object", - }, - { - "properties": { - "test": { - "type": "number", - }, - }, - "required": [ - "test", - ], - "type": "object", - }, - ], -} -`; - -exports[`Documentation helpers > depictIntersection() > should fall back to allOf in other cases 1 1`] = ` -{ - "allOf": [ - { - "type": "number", - }, - { - "const": 5, - }, - ], -} -`; - -exports[`Documentation helpers > depictIntersection() > should flatten objects with same prop of same type 1`] = ` -{ - "properties": { - "one": { - "type": "number", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictIntersection() > should flatten three object schemas with examples 1`] = ` -{ - "examples": [ - { - "one": 123, - "three": 789, - "two": 456, - }, - ], - "properties": { - "one": { - "type": "number", - }, - "three": { - "type": "number", - }, - "two": { - "type": "number", - }, - }, - "required": [ - "one", - "two", - "three", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictIntersection() > should flatten two object schemas 1`] = ` -{ - "properties": { - "one": { - "type": "number", - }, - "two": { - "type": "number", - }, - }, - "required": [ - "one", - "two", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictIntersection() > should maintain uniqueness in the array of required props 1`] = ` -{ - "properties": { - "test": { - "const": 5, - "type": "number", - }, - }, - "required": [ - "test", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictIntersection() > should merge examples deeply 1`] = ` -{ - "examples": [ - { - "test": { - "a": 123, - "b": 456, - }, - }, - ], - "properties": { - "test": { - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "number", - }, - }, - "required": [ - "a", - "b", - ], - "type": "object", - }, - }, - "required": [ - "test", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictLiteral() > should handle multiple values 1`] = ` -{ - "enum": [ - 1, - 2, - 3, - ], -} -`; - -exports[`Documentation helpers > depictLiteral() > should set type and involve const property 0 1`] = ` -{ - "const": "testng", -} -`; - -exports[`Documentation helpers > depictLiteral() > should set type and involve const property 1 1`] = ` -{ - "const": null, -} -`; - -exports[`Documentation helpers > depictLiteral() > should set type and involve const property 2 1`] = ` -{ - "const": 123, -} -`; - -exports[`Documentation helpers > depictLiteral() > should set type and involve const property 3 1`] = `{}`; - -exports[`Documentation helpers > depictNull() > should give type:null 1`] = ` -{ - "type": "null", -} -`; - -exports[`Documentation helpers > depictNullable() > should add null to the type 0 1`] = ` -{ - "type": [ - "string", - "null", - ], -} -`; - -exports[`Documentation helpers > depictNullable() > should add null to the type 1 1`] = ` -{ - "type": [ - "string", - "null", - ], -} -`; - -exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 0 1`] = ` -{ - "type": "null", -} -`; - -exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1 1`] = ` -{ - "type": [ - "string", - "null", - ], -} -`; - -exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 0 1`] = ` -{ - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 1 1`] = ` -{ - "exclusiveMaximum": 9007199254740991, - "exclusiveMinimum": -9007199254740991, - "type": "integer", -} -`; - -exports[`Documentation helpers > depictNumber() > should set min/max values according to JS capabilities 2 1`] = ` -{ - "exclusiveMaximum": 1.7976931348623157e+308, - "exclusiveMinimum": -1.7976931348623157e+308, - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should use schema checks for min/max and exclusiveness 0 1`] = ` -{ - "maximum": 33.333333333333336, - "minimum": -33.333333333333336, - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should use schema checks for min/max and exclusiveness 1 1`] = ` -{ - "maximum": 100, - "minimum": -100, - "type": "integer", -} -`; - -exports[`Documentation helpers > depictNumber() > should use schema checks for min/max and exclusiveness 2 1`] = ` -{ - "exclusiveMaximum": 16.666666666666668, - "exclusiveMinimum": -16.666666666666668, - "type": "number", -} -`; - -exports[`Documentation helpers > depictNumber() > should use schema checks for min/max and exclusiveness 3 1`] = ` -{ - "exclusiveMaximum": 100, - "exclusiveMinimum": -100, - "type": "integer", -} -`; - -exports[`Documentation helpers > depictObject() > Bug #758 1`] = ` -{ - "properties": { - "a": { - "type": "string", - }, - "b": { - "type": "string", - }, - "c": { - "type": "string", - }, - }, - "required": [ - "a", - "b", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictObject() > should type:object, properties and required props 0 1`] = ` -{ - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "string", - }, - }, - "required": [ - "a", - "b", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictObject() > should type:object, properties and required props 1 1`] = ` +exports[`Documentation helpers > depictExamples() > should 'pass' examples in case of 'request' 1`] = ` { - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "string", + "example1": { + "value": { + "one": "test", + "two": 123, }, }, - "required": [ - "a", - "b", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictObject() > should type:object, properties and required props 2 1`] = ` -{ - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "string", + "example2": { + "value": { + "one": "test2", + "two": 456, }, }, - "required": [ - "a", - "b", - ], - "type": "object", } `; -exports[`Documentation helpers > depictObject() > should type:object, properties and required props 3 1`] = ` +exports[`Documentation helpers > depictExamples() > should 'transform' examples in case of 'response' 1`] = ` { - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "string", + "example1": { + "value": { + "one": 4, + "two": "123", }, }, - "required": [ - "a", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictObject() > should type:object, properties and required props 4 1`] = ` -{ - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": [ - "string", - "null", - ], + "example2": { + "value": { + "one": 5, + "two": "456", }, }, - "required": [ - "b", - ], - "type": "object", } `; @@ -643,143 +45,6 @@ exports[`Documentation helpers > depictParamExamples() > should pass examples fo } `; -exports[`Documentation helpers > depictPipeline > should depict as 'boolean (out)' 1`] = ` -{ - "type": "boolean", -} -`; - -exports[`Documentation helpers > depictPipeline > should depict as 'number (out)' 1`] = ` -{ - "type": "number", -} -`; - -exports[`Documentation helpers > depictPipeline > should depict as 'string (in)' 1`] = ` -{ - "type": "string", -} -`; - -exports[`Documentation helpers > depictPipeline > should depict as 'string (in)' 2`] = ` -{ - "type": "string", -} -`; - -exports[`Documentation helpers > depictPipeline > should depict as 'string (preprocess)' 1`] = ` -{ - "format": "string (preprocessed)", -} -`; - -exports[`Documentation helpers > depictPipeline > should handle edge cases 0 1`] = `{}`; - -exports[`Documentation helpers > depictPipeline > should handle edge cases 1 1`] = `{}`; - -exports[`Documentation helpers > depictRaw() > should depict the raw property 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 0 1`] = ` -{ - "additionalProperties": { - "type": "boolean", - }, - "propertyNames": { - "exclusiveMaximum": 9007199254740991, - "exclusiveMinimum": -9007199254740991, - "type": "integer", - }, - "type": "object", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 1 1`] = ` -{ - "additionalProperties": { - "type": "boolean", - }, - "propertyNames": { - "type": "string", - }, - "type": "object", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 2 1`] = ` -{ - "additionalProperties": { - "type": "boolean", - }, - "propertyNames": { - "enum": [ - "one", - "two", - ], - }, - "type": "object", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 3 1`] = ` -{ - "additionalProperties": { - "type": "boolean", - }, - "propertyNames": { - "const": "testing", - }, - "type": "object", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 4 1`] = ` -{ - "additionalProperties": { - "type": "boolean", - }, - "propertyNames": { - "anyOf": [ - { - "const": "one", - }, - { - "const": "two", - }, - ], - }, - "type": "object", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 5 1`] = ` -{ - "additionalProperties": {}, - "propertyNames": { - "type": "string", - }, - "type": "object", -} -`; - -exports[`Documentation helpers > depictRecord() > should set properties+required or additionalProperties props 6 1`] = ` -{ - "additionalProperties": { - "type": "boolean", - }, - "propertyNames": { - "format": "regex", - "pattern": "x-\\w+", - "type": "string", - }, - "type": "object", -} -`; - exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: should depict header params when enabled 1`] = ` [ { @@ -1089,420 +354,376 @@ exports[`Documentation helpers > depictSecurityRefs() > should populate the scop ] `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 0 1`] = ` -{ - "format": "email", - "maxLength": 20, - "minLength": 10, - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "type": "string", -} +exports[`Documentation helpers > depictTags() > should accept objects with URLs 1`] = ` +[ + { + "description": "Everything about users", + "name": "users", + }, + { + "description": "Everything about files processing", + "externalDocs": { + "url": "https://example.com", + }, + "name": "files", + }, +] `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 1 1`] = ` -{ - "format": "uri", - "maxLength": 15, - "minLength": 15, - "type": "string", -} +exports[`Documentation helpers > depictTags() > should accept plain descriptions 1`] = ` +[ + { + "description": "Everything about users", + "name": "users", + }, + { + "description": "Everything about files processing", + "name": "files", + }, +] `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 2 1`] = ` +exports[`Documentation helpers > excludeExamplesFromDepiction() > should remove example property of supplied object 1`] = ` { - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$", + "description": "test", "type": "string", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 3 1`] = ` +exports[`Documentation helpers > excludeParamsFromDepiction() > should handle the ReferenceObject 1`] = ` { - "format": "cuid", - "pattern": "^[cC][^\\s-]{8,}$", - "type": "string", + "$ref": "test", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 4 1`] = ` +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 0 1`] = ` { - "format": "date-time", - "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$", - "type": "string", + "properties": { + "b": { + "type": "string", + }, + }, + "required": [ + "b", + ], + "type": "object", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 5 1`] = ` +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 0 2`] = `true`; + +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 1 1`] = ` { - "format": "date-time", - "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z|([+-]\\d{2}:?\\d{2}))$", - "type": "string", + "anyOf": [ + { + "properties": {}, + "required": [], + "type": "object", + }, + { + "properties": { + "b": { + "type": "string", + }, + }, + "required": [ + "b", + ], + "type": "object", + }, + ], } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 6 1`] = ` +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 1 2`] = `true`; + +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 2 1`] = ` { - "format": "regex", - "pattern": "^\\d+.\\d+.\\d+$", - "type": "string", + "properties": { + "b": { + "type": "string", + }, + }, + "required": [ + "b", + ], + "type": "object", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 7 1`] = ` +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 2 2`] = `true`; + +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 3 1`] = ` { - "format": "date", - "pattern": "^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))$", - "type": "string", + "allOf": [ + { + "additionalProperties": { + "type": "string", + }, + "propertyNames": { + "const": "a", + }, + "type": "object", + }, + { + "additionalProperties": { + "type": "string", + }, + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + ], } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 8 1`] = ` -{ - "format": "time", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?$", - "type": "string", -} -`; +exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 3 2`] = `false`; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 9 1`] = ` +exports[`Documentation helpers > onBigInt() > should set type:string and format:bigint 1`] = ` { - "format": "duration", - "pattern": "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$", + "format": "bigint", + "pattern": "^-?\\d+$", "type": "string", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 10 1`] = ` +exports[`Documentation helpers > onDateIn > should set type:string, pattern and format 1`] = ` { - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$", + "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 > depictString() > should set format, pattern and min/maxLength props 11 1`] = ` -{ - "format": "ipv4", - "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$", - "type": "string", -} +exports[`Documentation helpers > onDateIn > should throw when ZodDateIn in response 1`] = ` +DocumentationError({ + "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", + "message": "Please use ez.dateOut() for output.", +}) `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 12 1`] = ` +exports[`Documentation helpers > onDateOut > should set type:string, description and format 1`] = ` { - "format": "jwt", + "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 > depictString() > should set format, pattern and min/maxLength props 13 1`] = ` -{ - "contentEncoding": "base64", - "format": "base64", - "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$", - "type": "string", -} +exports[`Documentation helpers > onDateOut > should throw when ZodDateOut in request 1`] = ` +DocumentationError({ + "cause": "Input schema of an Endpoint assigned to GET method of /v1/user/:id path.", + "message": "Please use ez.dateIn() for input.", +}) `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 14 1`] = ` +exports[`Documentation helpers > onDefault() > Feature #1706: should override the default value by a label from metadata 1`] = ` { - "contentEncoding": "base64url", - "format": "base64url", - "pattern": "^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$", - "type": "string", + "default": "Today", + "format": "date-time", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 15 1`] = ` +exports[`Documentation helpers > onFile() > should set type:string and format accordingly 0 1`] = ` { - "format": "cuid2", - "pattern": "^[0-9a-z]+$", + "format": "file", "type": "string", } `; -exports[`Documentation helpers > depictString() > should set format, pattern and min/maxLength props 16 1`] = ` +exports[`Documentation helpers > onFile() > should set type:string and format accordingly 1 1`] = ` { - "format": "ulid", - "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "format": "binary", "type": "string", } `; -exports[`Documentation helpers > depictString() > should set type:string 1`] = ` +exports[`Documentation helpers > onFile() > should set type:string and format accordingly 2 1`] = ` { + "format": "byte", "type": "string", } `; -exports[`Documentation helpers > depictTags() > should accept objects with URLs 1`] = ` -[ - { - "description": "Everything about users", - "name": "users", - }, - { - "description": "Everything about files processing", - "externalDocs": { - "url": "https://example.com", - }, - "name": "files", - }, -] -`; - -exports[`Documentation helpers > depictTags() > should accept plain descriptions 1`] = ` -[ - { - "description": "Everything about users", - "name": "users", - }, - { - "description": "Everything about files processing", - "name": "files", - }, -] -`; - -exports[`Documentation helpers > depictTuple() > should depict empty tuples as is 1`] = ` -{ - "items": { - "not": {}, - }, - "prefixItems": [], - "type": "array", -} -`; - -exports[`Documentation helpers > depictTuple() > should depict rest as items when defined 1`] = ` +exports[`Documentation helpers > onFile() > should set type:string and format accordingly 3 1`] = ` { - "items": { - "type": "string", - }, - "prefixItems": [ - { - "type": "boolean", - }, - ], - "type": "array", + "format": "file", + "type": "string", } `; -exports[`Documentation helpers > depictTuple() > should utilize prefixItems and set items:not:{} 1`] = ` +exports[`Documentation helpers > onFile() > should set type:string and format accordingly 4 1`] = ` { - "items": { - "not": {}, - }, - "prefixItems": [ - { - "type": "boolean", - }, - { - "type": "string", - }, - { - "const": "test", - }, - ], - "type": "array", + "format": "binary", + "type": "string", } `; -exports[`Documentation helpers > depictUnion() > should wrap next depicters in oneOf prop and set discriminator prop 1`] = ` +exports[`Documentation helpers > onIntersection() > should NOT flatten object schemas having conflicting props 1`] = ` { - "anyOf": [ + "allOf": [ { "properties": { - "data": {}, - "status": { - "const": "success", + "one": { + "type": "number", }, }, - "required": [ - "status", - "data", - ], "type": "object", }, { "properties": { - "error": { - "properties": { - "message": { - "type": "string", - }, - }, - "required": [ - "message", - ], - "type": "object", - }, - "status": { - "const": "error", + "one": { + "type": "string", }, }, - "required": [ - "status", - "error", - ], "type": "object", }, ], - "discriminator": { - "propertyName": "status", +} +`; + +exports[`Documentation helpers > onIntersection() > should flatten objects with same prop of same type 1`] = ` +{ + "properties": { + "one": { + "type": "number", + }, }, + "type": "object", } `; -exports[`Documentation helpers > depictUnion() > should wrap next depicters into oneOf property 1`] = ` +exports[`Documentation helpers > onIntersection() > should flatten two object schemas 1`] = ` { - "anyOf": [ - { - "type": "string", + "properties": { + "one": { + "type": "number", }, - { + "two": { "type": "number", }, - ], + }, + "type": "object", } `; -exports[`Documentation helpers > depictUpload() > should set format:binary and type:string 1`] = ` +exports[`Documentation helpers > onIntersection() > should maintain uniqueness in the array of required props 1`] = ` { - "format": "binary", - "type": "string", + "properties": { + "test": { + "const": 5, + "type": "number", + }, + }, + "type": "object", } `; -exports[`Documentation helpers > depictUpload() > should throw when using in response 1`] = ` -DocumentationError({ - "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Please use ez.upload() only for input.", -}) +exports[`Documentation helpers > onIntersection() > should merge examples deeply 1`] = ` +{ + "examples": [ + { + "a": 123, + "b": 456, + }, + ], + "properties": { + "a": { + "type": "number", + }, + "b": { + "type": "number", + }, + }, + "type": "object", +} `; -exports[`Documentation helpers > depictWrapped() > handle readonly 1`] = ` +exports[`Documentation helpers > onNullable() > should add null type to the first of anyOf 0 1`] = ` { - "readOnly": true, - "type": "string", + "type": [ + "string", + "null", + ], } `; -exports[`Documentation helpers > depictWrapped() > should handle catch 1`] = ` +exports[`Documentation helpers > onNullable() > should add null type to the first of anyOf 1 1`] = ` { - "default": true, "type": [ - "boolean", + "string", "null", ], } `; -exports[`Documentation helpers > depictWrapped() > should handle optional 0 1`] = ` +exports[`Documentation helpers > onNullable() > should not add null type when it's already there 1`] = ` { - "type": "string", + "type": "null", } `; -exports[`Documentation helpers > depictWrapped() > should handle optional 1 1`] = ` +exports[`Documentation helpers > onPipeline > should depict as 'number (out)' 1`] = ` { - "type": "string", + "type": "number", } `; -exports[`Documentation helpers > excludeExamplesFromDepiction() > should remove example property of supplied object 1`] = ` +exports[`Documentation helpers > onPipeline > should depict as 'string (preprocess)' 1`] = ` { - "description": "test", - "type": "string", + "format": "string (preprocessed)", } `; -exports[`Documentation helpers > excludeParamsFromDepiction() > should handle the ReferenceObject 1`] = ` +exports[`Documentation helpers > onRaw() > should extract the raw property 1`] = ` { - "$ref": "test", + "format": "binary", + "type": "string", } `; -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 0 1`] = ` +exports[`Documentation helpers > onTuple() > should add items:not:{} when no rest 0 1`] = ` { - "properties": { - "b": { - "type": "string", - }, + "items": { + "not": {}, }, - "required": [ - "b", - ], - "type": "object", } `; -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 0 2`] = `true`; - -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 1 1`] = ` +exports[`Documentation helpers > onTuple() > should add items:not:{} when no rest 1 1`] = ` { - "anyOf": [ - { - "properties": {}, - "required": [], - "type": "object", - }, - { - "properties": { - "b": { - "type": "string", - }, - }, - "required": [ - "b", - ], - "type": "object", - }, - ], + "items": { + "not": {}, + }, } `; -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 1 2`] = `true`; - -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 2 1`] = ` +exports[`Documentation helpers > onUnion() > should set discriminator prop for such union 1`] = ` { - "properties": { - "b": { - "type": "string", - }, + "discriminator": { + "propertyName": "status", }, - "required": [ - "b", - ], - "type": "object", } `; -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 2 2`] = `true`; - -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 3 1`] = ` +exports[`Documentation helpers > onUpload() > should set format:binary and type:string 1`] = ` { - "allOf": [ - { - "additionalProperties": { - "type": "string", - }, - "propertyNames": { - "const": "a", - }, - "type": "object", - }, - { - "additionalProperties": { - "type": "string", - }, - "propertyNames": { - "type": "string", - }, - "type": "object", - }, - ], + "format": "binary", + "type": "string", } `; -exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 3 2`] = `false`; +exports[`Documentation helpers > onUpload() > should throw when using in response 1`] = ` +DocumentationError({ + "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", + "message": "Please use ez.upload() only for input.", +}) +`; diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 41404ac4d..df2297a41 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1135,8 +1135,9 @@ paths: type: object properties: bigint: - type: integer + type: string format: bigint + pattern: ^-?\\d+$ boolean: type: boolean readOnly: true @@ -1440,8 +1441,9 @@ paths: type: object properties: bigint: - type: integer + type: string format: bigint + pattern: ^-?\\d+$ required: - bigint required: diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index edcf6cbfe..777649163 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -1,6 +1,7 @@ +import { JSONSchema } from "@zod/core"; +import { SchemaObject } from "openapi3-ts/oas31"; import * as R from "ramda"; import { z } from "zod"; -import { ez } from "../src"; import { OpenAPIContext, depictExamples, @@ -14,48 +15,80 @@ import { excludeParamsFromDepiction, defaultIsHeader, reformatParamsInPath, - delegate, + onNullable, + onDefault, + onRaw, + onUpload, + onFile, + onUnion, + onIntersection, + onBigInt, + onTuple, + onPipeline, + onDateIn, + onDateOut, } from "../src/documentation-helpers"; -/** - * @todo all these functions is now the one, and the tests naming is not relevant anymore - * @todo these tests should now be transformed into ones of particular postprocessors and assert exactly what they do. - * @todo So we would not test Zod here, but internal methods only. - */ describe("Documentation helpers", () => { const makeRefMock = vi.fn(); const requestCtx = { - ctx: { - path: "/v1/user/:id", - method: "get", - isResponse: false, - makeRef: makeRefMock, - } satisfies OpenAPIContext, - }; + path: "/v1/user/:id", + method: "get", + isResponse: false, + makeRef: makeRefMock, + } satisfies OpenAPIContext; const responseCtx = { - ctx: { - path: "/v1/user/:id", - method: "get", - isResponse: true, - makeRef: makeRefMock, - } satisfies OpenAPIContext, - }; + path: "/v1/user/:id", + method: "get", + isResponse: true, + makeRef: makeRefMock, + } satisfies OpenAPIContext; beforeEach(() => { makeRefMock.mockClear(); }); describe("excludeParamsFromDepiction()", () => { - test.each([ - z.object({ a: z.string(), b: z.string() }), - z.object({ a: z.string() }).or(z.object({ b: z.string() })), - z.intersection(z.object({ a: z.string() }), z.object({ b: z.string() })), // flattened - z.intersection( - z.record(z.literal("a"), z.string()), - z.record(z.string(), z.string()), - ), - ])("should omit specified params %#", (schema) => { - const depicted = delegate(schema, requestCtx); + test.each([ + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + required: ["a", "b"], + }, + { + anyOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + }, + { + type: "object", + properties: { b: { type: "string" } }, + required: ["b"], + }, + ], + }, + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + required: ["a", "b"], + }, + { + allOf: [ + { + type: "object", + propertyNames: { const: "a" }, + additionalProperties: { type: "string" }, + }, + { + type: "object", + propertyNames: { type: "string" }, + additionalProperties: { type: "string" }, + }, + ], + }, + ])("should omit specified params %#", (depicted) => { const [result, hasRequired] = excludeParamsFromDepiction(depicted, ["a"]); expect(result).toMatchSnapshot(); expect(hasRequired).toMatchSnapshot(); @@ -84,418 +117,227 @@ describe("Documentation helpers", () => { }); }); - describe("depictDefault()", () => { - test("should set default property", () => { - expect(delegate(z.boolean().default(true), requestCtx)).toMatchSnapshot(); - }); + describe("onDefault()", () => { test("Feature #1706: should override the default value by a label from metadata", () => { - expect( - delegate( - z.iso - .datetime() - .default(() => new Date().toISOString()) - .label("Today"), - responseCtx, - ), - ).toMatchSnapshot(); + const zodSchema = z.iso + .datetime() + .default(() => new Date().toISOString()) + .label("Today"); + const jsonSchema: JSONSchema.BaseSchema = { + default: "2025-05-21", + format: "date-time", + }; + onDefault({ zodSchema, jsonSchema }, responseCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictWrapped()", () => { - test("should handle catch", () => { - expect(delegate(z.boolean().catch(true), requestCtx)).toMatchSnapshot(); - }); - - test.each([requestCtx, responseCtx])("should handle optional %#", (ctx) => { - expect(delegate(z.string().optional(), ctx)).toMatchSnapshot(); - }); - - test("handle readonly", () => { - expect(delegate(z.string().readonly(), responseCtx)).toMatchSnapshot(); - }); - }); - - describe("depictAny()", () => { - test("should set format:any", () => { - expect(delegate(z.any(), requestCtx)).toMatchSnapshot(); + describe("onRaw()", () => { + test("should extract the raw property", () => { + const jsonSchema: JSONSchema.ObjectSchema = { + type: "object", + properties: { raw: { format: "binary", type: "string" } }, + }; + onRaw({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictRaw()", () => { - test("should depict the raw property", () => { - expect( - delegate(ez.raw({ extra: z.string() }), requestCtx), - ).toMatchSnapshot(); - }); - }); - - describe("depictUpload()", () => { + describe("onUpload()", () => { + const jsonSchema: JSONSchema.BaseSchema = {}; + const zodSchema = z.never(); test("should set format:binary and type:string", () => { - expect(delegate(ez.upload(), requestCtx)).toMatchSnapshot(); + onUpload({ zodSchema, jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should throw when using in response", () => { expect(() => - delegate(ez.upload(), responseCtx), + onUpload({ zodSchema, jsonSchema }, responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); - describe("depictFile()", () => { - test.each([ - ez.file(), - ez.file("binary"), - ez.file("base64"), - ez.file("string"), - ez.file("buffer"), - ])("should set type:string and format accordingly %#", (schema) => { - expect(delegate(schema, responseCtx)).toMatchSnapshot(); + describe("onFile()", () => { + test.each([ + { type: "string" }, + { anyOf: [{}, { type: "string" }] }, + { type: "string", format: "base64" }, + { anyOf: [], type: "string" }, + {}, + ])("should set type:string and format accordingly %#", (jsonSchema) => { + onFile({ zodSchema: z.never(), jsonSchema }, responseCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictUnion()", () => { - test("should wrap next depicters into oneOf property", () => { - expect(delegate(z.string().or(z.number()), requestCtx)).toMatchSnapshot(); - }); - - test("should wrap next depicters in oneOf prop and set discriminator prop", () => { - expect( - delegate( - z.discriminatedUnion("status", [ - z.object({ status: z.literal("success"), data: z.any() }), - z.object({ - status: z.literal("error"), - error: z.object({ message: z.string() }), - }), - ]), - requestCtx, - ), - ).toMatchSnapshot(); + describe("onUnion()", () => { + test("should set discriminator prop for such union", () => { + const zodSchema = z.discriminatedUnion([ + z.object({ status: z.literal("success"), data: z.any() }), + z.object({ + status: z.literal("error"), + error: z.object({ message: z.string() }), + }), + ]); + const jsonSchema: JSONSchema.BaseSchema = {}; + onUnion({ zodSchema, jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictIntersection()", () => { + describe("onIntersection()", () => { test("should flatten two object schemas", () => { - expect( - delegate( - z.intersection( - z.object({ one: z.number() }), - z.object({ two: z.number() }), - ), - requestCtx, - ), - ).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = { + allOf: [ + { type: "object", properties: { one: { type: "number" } } }, + { type: "object", properties: { two: { type: "number" } } }, + ], + }; + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should flatten objects with same prop of same type", () => { - expect( - delegate( - z.intersection( - z.object({ one: z.number() }), - z.object({ one: z.number() }), - ), - requestCtx, - ), - ).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = { + allOf: [ + { type: "object", properties: { one: { type: "number" } } }, + { type: "object", properties: { one: { type: "number" } } }, + ], + }; + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should NOT flatten object schemas having conflicting props", () => { - expect( - delegate( - z.intersection( - z.object({ one: z.number() }), - z.object({ one: z.string() }), - ), - requestCtx, - ), - ).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = { + allOf: [ + { type: "object", properties: { one: { type: "number" } } }, + { type: "object", properties: { one: { type: "string" } } }, + ], + }; + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should merge examples deeply", () => { - expect( - delegate( - z.intersection( - z - .object({ test: z.object({ a: z.number() }) }) - .example({ test: { a: 123 } }), - z - .object({ test: z.object({ b: z.number() }) }) - .example({ test: { b: 456 } }), - ), - requestCtx, - ), - ).toMatchSnapshot(); - }); - - test("should flatten three object schemas with examples", () => { - expect( - delegate( - z.intersection( - z.intersection( - z.object({ one: z.number() }).example({ one: 123 }), - z.object({ two: z.number() }).example({ two: 456 }), - ), - z.object({ three: z.number() }).example({ three: 789 }), - ), - requestCtx, - ), - ).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = { + allOf: [ + { + type: "object", + properties: { a: { type: "number" } }, + examples: [{ a: 123 }], + }, + { + type: "object", + properties: { b: { type: "number" } }, + examples: [{ b: 456 }], + }, + ], + }; + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should maintain uniqueness in the array of required props", () => { - expect( - delegate( - z.intersection( - z.object({ test: z.number() }), - z.object({ test: z.literal(5) }), - ), - requestCtx, - ), - ).toMatchSnapshot(); - }); - - test.each([ - z.intersection( - z.record(z.string(), z.number()), // has additionalProperties - z.object({ test: z.number() }), - ), - z.intersection(z.number(), z.literal(5)), // not objects - ])("should fall back to allOf in other cases %#", (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = { + allOf: [ + { type: "object", properties: { test: { type: "number" } } }, + { type: "object", properties: { test: { const: 5 } } }, + ], + }; + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); - }); - describe("depictNullable()", () => { - test.each([requestCtx, responseCtx])( - "should add null to the type %#", - (ctx) => { - expect(delegate(z.string().nullable(), ctx)).toMatchSnapshot(); - }, - ); - - test.each([z.null().nullable(), z.string().nullable().nullable()])( - "should not add null type when it's already there %#", - (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); - }, - ); - }); - - describe("depictEnum()", () => { - enum Test { - one = "ONE", - two = "TWO", - } - test.each([z.enum(["one", "two"]), z.enum(Test)])( - "should set type and enum properties", - (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); - }, - ); - }); - - describe("depictLiteral()", () => { - test.each(["testng", null, BigInt(123), undefined])( - "should set type and involve const property %#", - (value) => { - expect(delegate(z.literal(value), requestCtx)).toMatchSnapshot(); - }, - ); - - test("should handle multiple values", () => { - expect(delegate(z.literal([1, 2, 3]), requestCtx)).toMatchSnapshot(); - }); - }); - - describe("depictObject()", () => { - test.each([ - { ctx: requestCtx, shape: { a: z.number(), b: z.string() } }, - { ctx: responseCtx, shape: { a: z.number(), b: z.string() } }, + test.each([ { - ctx: responseCtx, - shape: { a: z.coerce.number(), b: z.coerce.string() }, + allOf: [ + { + additionalProperties: { type: "number" }, // can not handle + propertyNames: { type: "string" }, + type: "object", + }, + { + properties: { test: { type: "number" } }, + required: ["test"], + type: "object", + }, + ], }, - { ctx: responseCtx, shape: { a: z.number(), b: z.string().optional() } }, { - ctx: requestCtx, - shape: { a: z.number().optional(), b: z.coerce.string() }, + allOf: [{ type: "number" }, { const: 5 }], // not objects }, - ])( - "should type:object, properties and required props %#", - ({ shape, ctx }) => { - expect(delegate(z.object(shape), ctx)).toMatchSnapshot(); - }, - ); - - test("Bug #758", () => { - const schema = z.object({ - a: z.string(), - b: z.coerce.string(), - c: z.coerce.string().optional(), - }); - expect(delegate(schema, responseCtx)).toMatchSnapshot(); - }); - }); - - describe("depictNull()", () => { - test("should give type:null", () => { - expect(delegate(z.null(), requestCtx)).toMatchSnapshot(); - }); - }); - - describe("depictBoolean()", () => { - test("should set type:boolean", () => { - expect(delegate(z.boolean(), requestCtx)).toMatchSnapshot(); - }); - }); - - describe("depictBigInt()", () => { - test("should set type:integer and format:bigint", () => { - expect(delegate(z.bigint(), requestCtx)).toMatchSnapshot(); + ])("should fall back to allOf in other cases %#", (jsonSchema) => { + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toHaveProperty("allOf"); }); }); - describe("depictRecord()", () => { - test.each([ - z.record(z.int(), z.boolean()), - z.record(z.string(), z.boolean()), - z.record(z.enum(["one", "two"]), z.boolean()), - z.record(z.literal("testing"), z.boolean()), - z.record(z.literal("one").or(z.literal("two")), z.boolean()), - z.record(z.string(), z.any()), // Issue #900 - z.record(z.string().regex(/x-\w+/), z.boolean()), - ])( - "should set properties+required or additionalProperties props %#", - (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); + describe("onNullable()", () => { + test.each([requestCtx, responseCtx])( + "should add null type to the first of anyOf %#", + (ctx) => { + const jsonSchema: JSONSchema.BaseSchema = { + anyOf: [{ type: "string" }, { type: "null" }], + }; + onNullable({ zodSchema: z.never(), jsonSchema }, ctx); + expect(jsonSchema).toMatchSnapshot(); }, ); - }); - describe("depictArray()", () => { - test("should set type:array and pass items depiction", () => { - expect(delegate(z.array(z.boolean()), requestCtx)).toMatchSnapshot(); - }); - - test.each([ - z.boolean().array().min(3), - z.boolean().array().max(5), - z.boolean().array().min(3).max(5), - z.boolean().array().length(4), - z.array(z.boolean()).nonempty(), - ])("should reflect min/max/exact length of the array %#", (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); + test("should not add null type when it's already there", () => { + const jsonSchema: JSONSchema.BaseSchema = { + anyOf: [{ type: "null" }, { type: "null" }], + }; + onNullable({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictTuple()", () => { - test("should utilize prefixItems and set items:not:{}", () => { - expect( - delegate( - z.tuple([z.boolean(), z.string(), z.literal("test")]), - requestCtx, - ), - ).toMatchSnapshot(); - }); - test("should depict rest as items when defined", () => { - expect( - delegate(z.tuple([z.boolean()]).rest(z.string()), requestCtx), - ).toMatchSnapshot(); - }); - test("should depict empty tuples as is", () => { - expect(delegate(z.tuple([]), requestCtx)).toMatchSnapshot(); + describe("onBigInt()", () => { + test("should set type:string and format:bigint", () => { + const jsonSchema: JSONSchema.BaseSchema = {}; + onBigInt({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictString()", () => { - test("should set type:string", () => { - expect(delegate(z.string(), requestCtx)).toMatchSnapshot(); - }); - + describe("onTuple()", () => { test.each([ - z.email().min(10).max(20), - z.url().length(15), - z.uuid(), - z.cuid(), - z.iso.datetime(), - z.iso.datetime({ offset: true }), - z.string().regex(/^\d+.\d+.\d+$/), - z.iso.date(), - z.iso.time(), - z.iso.duration(), - z.cidrv4(), - z.ipv4(), - z.jwt(), - z.base64(), - z.base64url(), - z.cuid2(), - z.ulid(), - ])("should set format, pattern and min/maxLength props %#", (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); + z.tuple([z.boolean(), z.string(), z.literal("test")]), + z.tuple([]), + ])("should add items:not:{} when no rest %#", (zodSchema) => { + const jsonSchema: JSONSchema.BaseSchema = {}; + onTuple({ zodSchema, jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); }); - describe("depictNumber()", () => { - test.each([z.number(), z.int(), z.float64()])( - "should set min/max values according to JS capabilities %#", - (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); - }, - ); - - test.each([ - z - .number() - .min(-100 / 3) - .max(100 / 3), - z.number().int().min(-100).max(100), - z - .number() - .gt(-100 / 6) - .lt(100 / 6), - z.number().int().gt(-100).lt(100), - ])( - "should use schema checks for min/max and exclusiveness %#", - (schema) => { - expect(delegate(schema, requestCtx)).toMatchSnapshot(); - }, - ); - }); - - describe("depictPipeline", () => { - test.each([ - { ctx: responseCtx, expected: "boolean (out)" }, - { ctx: requestCtx, expected: "string (in)" }, - ])("should depict as $expected", ({ ctx }) => { - expect( - delegate(z.string().transform(Boolean).pipe(z.boolean()), ctx), - ).toMatchSnapshot(); - }); - + describe("onPipeline", () => { test.each([ { - schema: z.string().transform((v) => parseInt(v, 10)), + zodSchema: z.string().transform((v) => parseInt(v, 10)), ctx: responseCtx, expected: "number (out)", }, { - schema: z.string().transform((v) => parseInt(v, 10)), - ctx: requestCtx, - expected: "string (in)", - }, - { - schema: z.preprocess((v) => parseInt(`${v}`, 10), z.string()), + zodSchema: z.preprocess((v) => parseInt(`${v}`, 10), z.string()), ctx: requestCtx, expected: "string (preprocess)", }, - ])("should depict as $expected", ({ schema, ctx }) => { - expect(delegate(schema, ctx)).toMatchSnapshot(); + ])("should depict as $expected", ({ zodSchema, ctx }) => { + const jsonSchema: JSONSchema.BaseSchema = {}; + onPipeline({ zodSchema, jsonSchema }, ctx); + expect(jsonSchema).toMatchSnapshot(); }); test.each([ z.number().transform((num) => () => num), z.number().transform(() => assert.fail("this should be handled")), - ])("should handle edge cases %#", (schema) => { - expect(delegate(schema, responseCtx)).toMatchSnapshot(); + ])("should handle edge cases %#", (zodSchema) => { + const jsonSchema: JSONSchema.BaseSchema = {}; + onPipeline({ zodSchema, jsonSchema }, responseCtx); + expect(jsonSchema).toEqual({}); }); }); @@ -583,7 +425,7 @@ describe("Documentation helpers", () => { }), inputSources: ["query", "params"], composition: "inline", - ...requestCtx.ctx, + ...requestCtx, }), ).toMatchSnapshot(); }); @@ -597,7 +439,7 @@ describe("Documentation helpers", () => { }), inputSources: ["body", "params"], composition: "inline", - ...requestCtx.ctx, + ...requestCtx, }), ).toMatchSnapshot(); }); @@ -611,7 +453,7 @@ describe("Documentation helpers", () => { }), inputSources: ["body"], composition: "inline", - ...requestCtx.ctx, + ...requestCtx, }), ).toMatchSnapshot(); }); @@ -628,7 +470,7 @@ describe("Documentation helpers", () => { inputSources: ["query", "headers", "params"], composition: "inline", security: [[{ type: "header", name: "secure" }]], - ...requestCtx.ctx, + ...requestCtx, }), ).toMatchSnapshot(); }); @@ -646,24 +488,28 @@ describe("Documentation helpers", () => { }); }); - describe("depictDateIn", () => { + describe("onDateIn", () => { test("should set type:string, pattern and format", () => { - expect(delegate(ez.dateIn(), requestCtx)).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = { anyOf: [] }; + onDateIn({ zodSchema: z.never(), jsonSchema }, requestCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should throw when ZodDateIn in response", () => { expect(() => - delegate(ez.dateIn(), responseCtx), + onDateIn({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); - describe("depictDateOut", () => { + describe("onDateOut", () => { test("should set type:string, description and format", () => { - expect(delegate(ez.dateOut(), responseCtx)).toMatchSnapshot(); + const jsonSchema: JSONSchema.BaseSchema = {}; + onDateOut({ zodSchema: z.never(), jsonSchema }, responseCtx); + expect(jsonSchema).toMatchSnapshot(); }); test("should throw when ZodDateOut in request", () => { expect(() => - delegate(ez.dateOut(), requestCtx), + onDateOut({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), ).toThrowErrorMatchingSnapshot(); }); }); diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 46639c95f..7c48b0a84 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -41,6 +41,28 @@ describe("Environment checks", () => { }); }); + describe("Zod imperfections", () => { + test("discriminated unions are not depicted well", () => { + expect( + z.toJSONSchema( + z.discriminatedUnion([ + z.object({ status: z.literal("success"), data: z.any() }), + z.object({ + status: z.literal("error"), + error: z.object({ message: z.string() }), + }), + ]), + ), + ).not.toHaveProperty("discriminator"); + }); + + test("bigint is not representable", () => { + expect(z.toJSONSchema(z.bigint(), { unrepresentable: "any" })).toEqual( + {}, + ); + }); + }); + describe("Vitest error comparison", () => { test("should distinguish error instances of different classes", () => { expect(createHttpError(500, "some message")).not.toEqual( From 1a7b50291bfc67c8a63f28c80597fcdb7891729e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 21 Apr 2025 18:07:42 +0200 Subject: [PATCH 016/187] Support `ZodInterface` (#2560) The new recommended way to describe circular schemas and objects having optional properties https://v4.zod.dev/v4#zinterface --- express-zod-api/src/io-schema.ts | 9 +++ express-zod-api/src/zts-helpers.ts | 1 + express-zod-api/src/zts.ts | 18 +++++ .../__snapshots__/documentation.spec.ts.snap | 2 +- .../__snapshots__/integration.spec.ts.snap | 71 ++++++++++++++++++- .../__snapshots__/io-schema.spec.ts.snap | 17 +++++ .../tests/__snapshots__/zts.spec.ts.snap | 1 + express-zod-api/tests/documentation.spec.ts | 9 +-- express-zod-api/tests/env.spec.ts | 11 +++ express-zod-api/tests/integration.spec.ts | 59 ++++++++------- express-zod-api/tests/io-schema.spec.ts | 18 +++++ express-zod-api/tests/zts.spec.ts | 8 +++ 12 files changed, 193 insertions(+), 31 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 53bd80a92..7d44e8194 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -36,6 +36,15 @@ export const getFinalEndpointInputSchema = < export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { if (subject instanceof z.ZodObject) return subject; + if (subject instanceof z.ZodInterface) { + const { optional } = subject._zod.def; + const mask = R.zipObj(optional, Array(optional.length).fill(true)); + const partial = subject.pick(mask); + const required = subject.omit(mask); + return z + .object(required._zod.def.shape) + .extend(z.object(partial._zod.def.shape).partial()); + } if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index 4faba4ebf..32eacb554 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -9,6 +9,7 @@ export interface ZTSContext extends FlatObject { schema: $ZodType | (() => $ZodType), produce: () => ts.TypeNode, ) => ts.TypeNode; + // @todo remove it in favor of z.interface optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index e45900ee0..d76f98ab4 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -66,6 +66,23 @@ const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => { return values.length === 1 ? values[0] : f.createUnionTypeNode(values); }; +const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) => + makeAlias(int, () => { + const members = Object.entries(int._zod.def.shape).map( + ([key, value]) => { + const isOptional = int._zod.def.optional.includes(key); + const { description: comment, deprecated: isDeprecated } = + globalRegistry.get(value) || {}; + return makeInterfaceProp(key, next(value), { + comment, + isDeprecated, + isOptional, + }); + }, + ); + return f.createTypeLiteralNode(members); + }); + const onObject: Producer = ( { _zod: { def } }: z.ZodObject, { @@ -233,6 +250,7 @@ const producers: HandlingRules< tuple: onTuple, record: onRecord, object: onObject, + interface: onInterface, literal: onLiteral, intersection: onIntersection, union: onSomeUnion, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index df2297a41..a8b2818c6 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1221,7 +1221,7 @@ servers: " `; -exports[`Documentation > Basic cases > should handle circular schemas via z.lazy() 1`] = ` +exports[`Documentation > Basic cases > should handle circular schemas via z.interface() 1`] = ` "openapi: 3.1.0 info: title: Testing Lazy diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index c227fc318..74210082a 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -586,7 +586,76 @@ export type Request = keyof Input; " `; -exports[`Integration > Should support types variant and handle recursive schemas 1`] = ` +exports[`Integration > Should support types variant and handle recursive schemas 0 1`] = ` +"type Type1 = { + name: string; + features: Type1; +}; + +type SomeOf = T[keyof T]; + +/** post /v1/test */ +type PostV1TestInput = { + features: Type1; +}; + +/** post /v1/test */ +type PostV1TestPositiveVariant1 = { + status: "success"; + data: {}; +}; + +/** post /v1/test */ +interface PostV1TestPositiveResponseVariants { + 200: PostV1TestPositiveVariant1; +} + +/** post /v1/test */ +type PostV1TestNegativeVariant1 = { + status: "error"; + error: { + message: string; + }; +}; + +/** post /v1/test */ +interface PostV1TestNegativeResponseVariants { + 400: PostV1TestNegativeVariant1; +} + +export type Path = "/v1/test"; + +export type Method = "get" | "post" | "put" | "delete" | "patch"; + +export interface Input { + /** @deprecated */ + "post /v1/test": PostV1TestInput; +} + +export interface PositiveResponse { + /** @deprecated */ + "post /v1/test": SomeOf; +} + +export interface NegativeResponse { + /** @deprecated */ + "post /v1/test": SomeOf; +} + +export interface EncodedResponse { + /** @deprecated */ + "post /v1/test": PostV1TestPositiveResponseVariants & PostV1TestNegativeResponseVariants; +} + +export interface Response { + /** @deprecated */ + "post /v1/test": PositiveResponse["post /v1/test"] | NegativeResponse["post /v1/test"]; +} + +export type Request = keyof Input;" +`; + +exports[`Integration > Should support types variant and handle recursive schemas 1 1`] = ` "type Type1 = { name: string; features: Type1; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index e8b3816fe..31da5d7f7 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -28,6 +28,23 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869: } `; +exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should handle interfaces with optional props 1`] = ` +{ + "properties": { + "one": { + "type": "boolean", + }, + "two": { + "type": "boolean", + }, + }, + "required": [ + "one", + ], + "type": "object", +} +`; + exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = ` IOSchemaError({ "cause": { diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 18a29ea51..4213a12e0 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -9,6 +9,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` }[]; boolean: boolean; circular: SomeType; + circular2: SomeType; union: { number: number; } | "hi"; diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index d1598c949..fa95fedfb 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -481,11 +481,12 @@ describe("Documentation", () => { expect(boolean.parse(null)).toBe(false); }); - // @todo switch to z.interface for that - test("should handle circular schemas via z.lazy()", () => { - const category: z.ZodObject = z.object({ + test("should handle circular schemas via z.interface()", () => { + const category = z.interface({ name: z.string(), - subcategories: z.lazy(() => category.array()), + get subcategories() { + return z.array(category); + }, }); const spec = new Documentation({ config: sampleConfig, diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 7c48b0a84..42ef58360 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -63,6 +63,17 @@ describe("Environment checks", () => { }); }); + describe("Zod new features", () => { + test("interface shape does not contain question marks, but there is a list of them", () => { + const schema = z.interface({ + one: z.boolean(), + "two?": z.boolean(), + }); + expect(Object.keys(schema._zod.def.shape)).toEqual(["one", "two"]); + expect(schema._zod.def.optional).toEqual(["two"]); + }); + }); + describe("Vitest error comparison", () => { test("should distinguish error instances of different classes", () => { expect(createHttpError(500, "some message")).not.toEqual( diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 063ce099d..c71ce30b5 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -9,33 +9,42 @@ import { } from "../src"; describe("Integration", () => { - test("Should support types variant and handle recursive schemas", () => { - const recursiveSchema: z.ZodTypeAny = z.lazy(() => - z.object({ - name: z.string(), - features: recursiveSchema, - }), - ); + const recursive1: z.ZodTypeAny = z.lazy(() => + z.object({ + name: z.string(), + features: recursive1, + }), + ); + const recursive2 = z.interface({ + name: z.string(), + get features() { + return recursive2; + }, + }); - const client = new Integration({ - variant: "types", - routing: { - v1: { - test: defaultEndpointsFactory - .build({ - method: "post", - input: z.object({ - features: recursiveSchema, - }), - output: z.object({}), - handler: async () => ({}), - }) - .deprecated(), + test.each([recursive1, recursive2])( + "Should support types variant and handle recursive schemas %#", + (recursiveSchema) => { + const client = new Integration({ + variant: "types", + routing: { + v1: { + test: defaultEndpointsFactory + .build({ + method: "post", + input: z.object({ + features: recursiveSchema, + }), + output: z.object({}), + handler: async () => ({}), + }) + .deprecated(), + }, }, - }, - }); - expect(client.print()).toMatchSnapshot(); - }); + }); + expect(client.print()).toMatchSnapshot(); + }, + ); test("Should treat optionals the same way as z.infer() by default", async () => { const client = new Integration({ diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 4fc63f548..9560fa9a6 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,3 +1,4 @@ +import { expectTypeOf } from "vitest"; import { z } from "zod"; import { IOSchema, Middleware, ez } from "../src"; import { @@ -15,6 +16,15 @@ describe("I/O Schema and related helpers", () => { expectTypeOf(z.object({}).strict()).toExtend(); expectTypeOf(z.object({}).loose()).toExtend(); }); + test("accepts interface", () => { + expectTypeOf(z.interface({})).toExtend(); + expectTypeOf(z.interface({ "some?": z.string() })).toExtend(); + expectTypeOf(z.interface({}).strip()).toExtend(); + expectTypeOf(z.interface({}).loose()).toExtend(); + expectTypeOf(z.looseInterface({})).toExtend(); + expectTypeOf(z.interface({}).strict()).toExtend(); + expectTypeOf(z.strictInterface({})).toExtend(); + }); test("accepts ez.raw()", () => { expectTypeOf(ez.raw()).toExtend(); expectTypeOf(ez.raw({ something: z.any() })).toExtend(); @@ -340,6 +350,14 @@ describe("I/O Schema and related helpers", () => { }); describe("Zod 4", () => { + test("should handle interfaces with optional props", () => { + expect( + extractObjectSchema( + z.interface({ one: z.boolean(), "two?": z.boolean() }), + ), + ).toMatchSnapshot(); + }); + test("should throw for incompatible ones", () => { expect(() => extractObjectSchema(z.string() as unknown as IOSchema), diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index a71d77298..002ef4350 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -99,6 +99,13 @@ describe("zod-to-ts", () => { }), ); + const circular2 = z.interface({ + name: z.string(), + get subcategories() { + return z.array(circular2); + }, + }); + const example = z.object({ string: z.string(), number: z.number(), @@ -109,6 +116,7 @@ describe("zod-to-ts", () => { ), boolean: z.boolean(), circular, + circular2, union: z.union([z.object({ number: z.number() }), z.literal("hi")]), enum: z.enum(["hi", "bye"]), intersectionWithTransform: z From 81724af4a25750eba07798c507b256f2424c8e75 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 21 Apr 2025 18:20:57 +0200 Subject: [PATCH 017/187] Drop `optionalPropStyle` from `Integration` (#2561) In favor of using Zod 4 `z.interface` --- CHANGELOG.md | 2 + README.md | 1 - express-zod-api/src/integration.ts | 22 +- express-zod-api/src/zts-helpers.ts | 2 - express-zod-api/src/zts.ts | 25 +- .../__snapshots__/integration.spec.ts.snap | 447 ------------------ express-zod-api/tests/integration.spec.ts | 24 - express-zod-api/tests/zts.spec.ts | 1 - 8 files changed, 10 insertions(+), 514 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9553a1aa7..c0b8e604a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - The `numericRange` option removed from `Documentation` class constructor argument; - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type changed to `Overrider` having different signature; +- The `optionalPropStyle` option removed from `Integration` class constructor: + - Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface. ## Version 23 diff --git a/README.md b/README.md index 4c41b2bbe..22308a68c 100644 --- a/README.md +++ b/README.md @@ -1269,7 +1269,6 @@ import { Integration } from "express-zod-api"; const client = new Integration({ routing, variant: "client", // <— optional, see also "types" for a DIY solution - optionalPropStyle: { withQuestionMark: true, withUndefined: true }, // optional }); const prettierFormattedTypescriptCode = await client.printFormatted(); // or just .print() for unformatted diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 7ee6f5a8d..509c8be57 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -41,22 +41,6 @@ interface IntegrationParams { * @default https://example.com * */ serverUrl?: string; - /** - * @desc configures the style of object's optional properties - * @default { withQuestionMark: true, withUndefined: true } - */ - optionalPropStyle?: { - /** - * @desc add question mark to the optional property definition - * @example { someProp?: boolean } - * */ - withQuestionMark?: boolean; - /** - * @desc add undefined to the property union type - * @example { someProp: boolean | undefined } - */ - withUndefined?: boolean; - }; /** * @desc The schema to use for responses without body such as 204 * @default z.undefined() @@ -110,14 +94,10 @@ export class Integration extends IntegrationBase { clientClassName = "Client", subscriptionClassName = "Subscription", serverUrl = "https://example.com", - optionalPropStyle = { withQuestionMark: true, withUndefined: true }, noContent = z.undefined(), }: IntegrationParams) { super(serverUrl); - const commons = { - makeAlias: this.#makeAlias.bind(this), - optionalPropStyle, - }; + const commons = { makeAlias: this.#makeAlias.bind(this) }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; const onEndpoint: OnEndpoint = (endpoint, path, method) => { diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index 32eacb554..53261c7d7 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -9,8 +9,6 @@ export interface ZTSContext extends FlatObject { schema: $ZodType | (() => $ZodType), produce: () => ts.TypeNode, ) => ts.TypeNode; - // @todo remove it in favor of z.interface - optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } export type Producer = SchemaHandler; diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index d76f98ab4..8d8335cb2 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -85,11 +85,7 @@ const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) => const onObject: Producer = ( { _zod: { def } }: z.ZodObject, - { - isResponse, - next, - optionalPropStyle: { withQuestionMark: hasQuestionMark }, - }, + { isResponse, next }, ) => { const members = Object.entries(def.shape).map( ([key, value]) => { @@ -103,7 +99,7 @@ const onObject: Producer = ( return makeInterfaceProp(key, next(value), { comment, isDeprecated, - isOptional: isOptional && hasQuestionMark, + isOptional, }); }, ); @@ -131,18 +127,11 @@ const onSomeUnion: Producer = ( const makeSample = (produced: ts.TypeNode) => samples?.[produced.kind as keyof typeof samples]; -const onOptional: Producer = ( - { _zod: { def } }: $ZodOptional, - { next, optionalPropStyle: { withUndefined: hasUndefined } }, -) => { - const actualTypeNode = next(def.innerType); - return hasUndefined - ? f.createUnionTypeNode([ - actualTypeNode, - ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), - ]) - : actualTypeNode; -}; +const onOptional: Producer = ({ _zod: { def } }: $ZodOptional, { next }) => + f.createUnionTypeNode([ + next(def.innerType), + ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), + ]); const onNullable: Producer = ({ _zod: { def } }: $ZodNullable, { next }) => f.createUnionTypeNode([next(def.innerType), makeLiteralType(null)]); diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 74210082a..38e79e133 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -1,452 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Integration > Feature #945: should have configurable treatment of optionals 0 1`] = ` -"type SomeOf = T[keyof T]; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesInput = { - opt?: string; -}; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesPositiveVariant1 = { - status: "success"; - data: { - similar?: number; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesPositiveResponseVariants { - 200: PostV1TestWithDashesPositiveVariant1; -} - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesNegativeVariant1 = { - status: "error"; - error: { - message: string; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesNegativeResponseVariants { - 400: PostV1TestWithDashesNegativeVariant1; -} - -export type Path = "/v1/test-with-dashes"; - -export type Method = "get" | "post" | "put" | "delete" | "patch"; - -export interface Input { - "post /v1/test-with-dashes": PostV1TestWithDashesInput; -} - -export interface PositiveResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface NegativeResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface EncodedResponse { - "post /v1/test-with-dashes": PostV1TestWithDashesPositiveResponseVariants & - PostV1TestWithDashesNegativeResponseVariants; -} - -export interface Response { - "post /v1/test-with-dashes": - | PositiveResponse["post /v1/test-with-dashes"] - | NegativeResponse["post /v1/test-with-dashes"]; -} - -export type Request = keyof Input; - -export const endpointTags = { "post /v1/test-with-dashes": [] }; - -const parseRequest = (request: string) => - request.split(/ (.+)/, 2) as [Method, Path]; - -const substitute = (path: string, params: Record) => { - const rest = { ...params }; - for (const key in params) { - path = path.replace(\`:\${key}\`, () => { - delete rest[key]; - return params[key]; - }); - } - return [path, rest] as const; -}; - -export type Implementation = ( - method: Method, - path: string, - params: Record, - ctx?: T, -) => Promise; - -const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); - const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; - const response = await fetch( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - { - method: method.toUpperCase(), - headers: hasBody ? { "Content-Type": "application/json" } : undefined, - body: hasBody ? JSON.stringify(params) : undefined, - }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType) return; - const isJSON = contentType.startsWith("application/json"); - return response[isJSON ? "json" : "text"](); -}; - -export class Client { - public constructor( - protected readonly implementation: Implementation = defaultImplementation, - ) {} - public provide( - request: K, - params: Input[K], - ctx?: T, - ): Promise { - const [method, path] = parseRequest(request); - return this.implementation(method, ...substitute(path, params), ctx); - } -} - -export class Subscription< - K extends Extract, - R extends Extract, -> { - public source: EventSource; - public constructor(request: K, params: Input[K]) { - const [path, rest] = substitute(parseRequest(request)[1], params); - const searchParams = \`?\${new URLSearchParams(rest)}\`; - this.source = new EventSource( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - ); - } - public on( - event: E, - handler: (data: Extract["data"]) => void | Promise, - ) { - this.source.addEventListener(event, (msg) => - handler(JSON.parse((msg as MessageEvent).data)), - ); - return this; - } -} - -// Usage example: -/* -const client = new Client(); -client.provide("get /v1/user/retrieve", { id: "10" }); -new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); -*/ -" -`; - -exports[`Integration > Feature #945: should have configurable treatment of optionals 1 1`] = ` -"type SomeOf = T[keyof T]; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesInput = { - opt: string | undefined; -}; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesPositiveVariant1 = { - status: "success"; - data: { - similar: number | undefined; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesPositiveResponseVariants { - 200: PostV1TestWithDashesPositiveVariant1; -} - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesNegativeVariant1 = { - status: "error"; - error: { - message: string; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesNegativeResponseVariants { - 400: PostV1TestWithDashesNegativeVariant1; -} - -export type Path = "/v1/test-with-dashes"; - -export type Method = "get" | "post" | "put" | "delete" | "patch"; - -export interface Input { - "post /v1/test-with-dashes": PostV1TestWithDashesInput; -} - -export interface PositiveResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface NegativeResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface EncodedResponse { - "post /v1/test-with-dashes": PostV1TestWithDashesPositiveResponseVariants & - PostV1TestWithDashesNegativeResponseVariants; -} - -export interface Response { - "post /v1/test-with-dashes": - | PositiveResponse["post /v1/test-with-dashes"] - | NegativeResponse["post /v1/test-with-dashes"]; -} - -export type Request = keyof Input; - -export const endpointTags = { "post /v1/test-with-dashes": [] }; - -const parseRequest = (request: string) => - request.split(/ (.+)/, 2) as [Method, Path]; - -const substitute = (path: string, params: Record) => { - const rest = { ...params }; - for (const key in params) { - path = path.replace(\`:\${key}\`, () => { - delete rest[key]; - return params[key]; - }); - } - return [path, rest] as const; -}; - -export type Implementation = ( - method: Method, - path: string, - params: Record, - ctx?: T, -) => Promise; - -const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); - const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; - const response = await fetch( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - { - method: method.toUpperCase(), - headers: hasBody ? { "Content-Type": "application/json" } : undefined, - body: hasBody ? JSON.stringify(params) : undefined, - }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType) return; - const isJSON = contentType.startsWith("application/json"); - return response[isJSON ? "json" : "text"](); -}; - -export class Client { - public constructor( - protected readonly implementation: Implementation = defaultImplementation, - ) {} - public provide( - request: K, - params: Input[K], - ctx?: T, - ): Promise { - const [method, path] = parseRequest(request); - return this.implementation(method, ...substitute(path, params), ctx); - } -} - -export class Subscription< - K extends Extract, - R extends Extract, -> { - public source: EventSource; - public constructor(request: K, params: Input[K]) { - const [path, rest] = substitute(parseRequest(request)[1], params); - const searchParams = \`?\${new URLSearchParams(rest)}\`; - this.source = new EventSource( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - ); - } - public on( - event: E, - handler: (data: Extract["data"]) => void | Promise, - ) { - this.source.addEventListener(event, (msg) => - handler(JSON.parse((msg as MessageEvent).data)), - ); - return this; - } -} - -// Usage example: -/* -const client = new Client(); -client.provide("get /v1/user/retrieve", { id: "10" }); -new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); -*/ -" -`; - -exports[`Integration > Feature #945: should have configurable treatment of optionals 2 1`] = ` -"type SomeOf = T[keyof T]; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesInput = { - opt: string; -}; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesPositiveVariant1 = { - status: "success"; - data: { - similar: number; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesPositiveResponseVariants { - 200: PostV1TestWithDashesPositiveVariant1; -} - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesNegativeVariant1 = { - status: "error"; - error: { - message: string; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesNegativeResponseVariants { - 400: PostV1TestWithDashesNegativeVariant1; -} - -export type Path = "/v1/test-with-dashes"; - -export type Method = "get" | "post" | "put" | "delete" | "patch"; - -export interface Input { - "post /v1/test-with-dashes": PostV1TestWithDashesInput; -} - -export interface PositiveResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface NegativeResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface EncodedResponse { - "post /v1/test-with-dashes": PostV1TestWithDashesPositiveResponseVariants & - PostV1TestWithDashesNegativeResponseVariants; -} - -export interface Response { - "post /v1/test-with-dashes": - | PositiveResponse["post /v1/test-with-dashes"] - | NegativeResponse["post /v1/test-with-dashes"]; -} - -export type Request = keyof Input; - -export const endpointTags = { "post /v1/test-with-dashes": [] }; - -const parseRequest = (request: string) => - request.split(/ (.+)/, 2) as [Method, Path]; - -const substitute = (path: string, params: Record) => { - const rest = { ...params }; - for (const key in params) { - path = path.replace(\`:\${key}\`, () => { - delete rest[key]; - return params[key]; - }); - } - return [path, rest] as const; -}; - -export type Implementation = ( - method: Method, - path: string, - params: Record, - ctx?: T, -) => Promise; - -const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); - const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; - const response = await fetch( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - { - method: method.toUpperCase(), - headers: hasBody ? { "Content-Type": "application/json" } : undefined, - body: hasBody ? JSON.stringify(params) : undefined, - }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType) return; - const isJSON = contentType.startsWith("application/json"); - return response[isJSON ? "json" : "text"](); -}; - -export class Client { - public constructor( - protected readonly implementation: Implementation = defaultImplementation, - ) {} - public provide( - request: K, - params: Input[K], - ctx?: T, - ): Promise { - const [method, path] = parseRequest(request); - return this.implementation(method, ...substitute(path, params), ctx); - } -} - -export class Subscription< - K extends Extract, - R extends Extract, -> { - public source: EventSource; - public constructor(request: K, params: Input[K]) { - const [path, rest] = substitute(parseRequest(request)[1], params); - const searchParams = \`?\${new URLSearchParams(rest)}\`; - this.source = new EventSource( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - ); - } - public on( - event: E, - handler: (data: Extract["data"]) => void | Promise, - ) { - this.source.addEventListener(event, (msg) => - handler(JSON.parse((msg as MessageEvent).data)), - ); - return this; - } -} - -// Usage example: -/* -const client = new Client(); -client.provide("get /v1/user/retrieve", { id: "10" }); -new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); -*/ -" -`; - exports[`Integration > Feature #1470: Custom brands > should by handled accordingly 1`] = ` "type SomeOf = T[keyof T]; diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index c71ce30b5..c4a6088b4 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -66,30 +66,6 @@ describe("Integration", () => { expect(await client.printFormatted()).toMatchSnapshot(); }); - test.each([{ withQuestionMark: true }, { withUndefined: true }, {}])( - "Feature #945: should have configurable treatment of optionals %#", - async (optionalPropStyle) => { - const client = new Integration({ - optionalPropStyle, - routing: { - v1: { - "test-with-dashes": defaultEndpointsFactory.build({ - method: "post", - input: z.object({ - opt: z.string().optional(), - }), - output: z.object({ - similar: z.number().optional(), - }), - handler: async () => ({}), - }), - }, - }, - }); - expect(await client.printFormatted()).toMatchSnapshot(); - }, - ); - test("Should support multiple response schemas depending on status code", async () => { const factory = new EndpointsFactory( new ResultHandler({ diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 002ef4350..2c263900c 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -11,7 +11,6 @@ describe("zod-to-ts", () => { const ctx: ZTSContext = { isResponse: false, makeAlias: vi.fn(() => f.createTypeReferenceNode("SomeType")), - optionalPropStyle: { withQuestionMark: true, withUndefined: true }, }; describe("z.array()", () => { From 32283c649163bb70a22e365397ff1804f5bff7df Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 08:11:10 +0200 Subject: [PATCH 018/187] Env test on transformation output examples missing. --- .../tests/__snapshots__/env.spec.ts.snap | 11 +++++++++++ express-zod-api/tests/env.spec.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index e17955906..0b3b0b235 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -167,3 +167,14 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri }, } `; + +exports[`Environment checks > Zod imperfections > input examples of transformations 1`] = ` +{ + "examples": [ + "test", + ], + "type": "string", +} +`; + +exports[`Environment checks > Zod imperfections > output examples of transformations 1`] = `{}`; diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 42ef58360..62cd3b300 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -61,6 +61,24 @@ describe("Environment checks", () => { {}, ); }); + + /** + * output examples would be possiblity fixed by this: + * @link https://github.com/colinhacks/zod/pull/4074/commits/818cfe78b0341e9e8cfbda248bff4268bd8352e8#diff-44530efe91e850ac97367f36dbb2eb83f8a567087a4dbbec1c262e177ac4d085R540 + */ + test.each(["input", "output"] as const)( + "%s examples of transformations", + (io) => { + const schema = z + .string() + .meta({ examples: ["test"] }) + .transform(Number) + .meta({ examples: [4] }); + expect( + z.toJSONSchema(schema, { io, unrepresentable: "any" }), + ).toMatchSnapshot(); + }, + ); }); describe("Zod new features", () => { From cde792d4a7c04b715d0e4d335655cdbdebca8f12 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 08:30:45 +0200 Subject: [PATCH 019/187] env test for behaviour of .meta() that does not merge but overrides. --- express-zod-api/tests/__snapshots__/env.spec.ts.snap | 6 ++++++ express-zod-api/tests/env.spec.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 0b3b0b235..14b167ae0 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -177,4 +177,10 @@ exports[`Environment checks > Zod imperfections > input examples of transformati } `; +exports[`Environment checks > Zod imperfections > meta overrides, does not merge 1`] = ` +{ + "title": "last", +} +`; + exports[`Environment checks > Zod imperfections > output examples of transformations 1`] = `{}`; diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 62cd3b300..967d6d450 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -79,6 +79,15 @@ describe("Environment checks", () => { ).toMatchSnapshot(); }, ); + + test("meta overrides, does not merge", () => { + const schema = z + .string() + .meta({ examples: ["test"] }) + .meta({ description: "some" }) + .meta({ title: "last" }); + expect(schema.meta()).toMatchSnapshot(); + }); }); describe("Zod new features", () => { From ef841e952f5cf85c64fbadbcb971eb1d0345227f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 09:17:32 +0200 Subject: [PATCH 020/187] Todo for examples. --- express-zod-api/src/metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index cca360202..489324d03 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -5,7 +5,7 @@ import * as R from "ramda"; export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { - examples: unknown[]; + examples: unknown[]; // @todo try z.$input[] instead /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; From 6bc1fa96acca91f02311c6f89cc2eb34565e8a31 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 22 Apr 2025 09:36:40 +0200 Subject: [PATCH 021/187] Brand is the only the metadata that withstands refinements (#2564) Withstanding was necessary for brand only, for deep checks. But it does not make sense for examples and other stuff that should rather be placed properly. --- CHANGELOG.md | 4 +++- express-zod-api/src/zod-plugin.ts | 5 ++++- express-zod-api/tests/zod-plugin.spec.ts | 23 ++++++++++++----------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b8e604a..5c04cf92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type changed to `Overrider` having different signature; - The `optionalPropStyle` option removed from `Integration` class constructor: - - Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface. + - Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface; +- Changes to the plugin: + - Brand is the only kind of metadata that withstands refinements and checks. ## Version 23 diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index e6d893c4e..0526ea467 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -148,7 +148,10 @@ if (!(metaSymbol in globalThis)) { ) { /** @link https://v4.zod.dev/metadata#register */ return originalCheck.apply(this, args).register(globalRegistry, { - [metaSymbol]: this.meta()?.[metaSymbol], + [metaSymbol]: { + examples: [], + brand: this.meta()?.[metaSymbol]?.brand, + }, }); }; }, diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index d10f90bb4..df92accdc 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -44,17 +44,6 @@ describe("Zod Runtime Plugin", () => { "test3", ]); }); - - test("should withstand refinements", () => { - const schema = z.string(); - const schemaWithMeta = schema.example("test"); - expect(schemaWithMeta.meta()?.[metaSymbol]?.examples).toEqual(["test"]); - expect( - schemaWithMeta.regex(/@example.com$/).meta()?.[metaSymbol], - ).toEqual({ - examples: ["test"], - }); - }); }); describe(".deprecated()", () => { @@ -89,6 +78,18 @@ describe("Zod Runtime Plugin", () => { "test", ); }); + + test("should withstand refinements", () => { + const schema = z.string(); + const schemaWithMeta = schema.brand("test"); + expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty( + "brand", + "test", + ); + expect( + schemaWithMeta.regex(/@example.com$/).meta()?.[metaSymbol], + ).toHaveProperty("brand", "test"); + }); }); describe(".remap()", () => { From fa1a3a653c10fbe2b938239fa007124f5949646e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 09:51:33 +0200 Subject: [PATCH 022/187] Todo regarding copyMeta() fn. --- express-zod-api/src/metadata.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 489324d03..830d0f1bb 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -11,6 +11,7 @@ export interface Metadata { brand?: string | number | symbol; } +// @todo this should be renamed to copyExamples or mixinExamples or something similar export const copyMeta = ( src: A, dest: B, From 3a6e17f7d23c9f2bcd9316069725c5eadc7c50ed Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 22 Apr 2025 10:38:34 +0200 Subject: [PATCH 023/187] Metadata preservation fix (#2565) All those setters should keep all the possible user's metadata intact --- express-zod-api/src/io-schema.ts | 6 ++--- express-zod-api/src/metadata.ts | 34 +++++++++++++------------- express-zod-api/src/zod-plugin.ts | 24 ++++++++++-------- express-zod-api/tests/metadata.spec.ts | 20 ++++++++------- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 7d44e8194..b0cca8fda 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,7 +1,7 @@ import * as R from "ramda"; import { z } from "zod"; import { IOSchemaError } from "./errors"; -import { copyMeta } from "./metadata"; +import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; type Base = object & { [Symbol.iterator]?: never }; @@ -14,7 +14,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 - * @see copyMeta + * @see mixExamples */ export const getFinalEndpointInputSchema = < MIN extends IOSchema, @@ -29,7 +29,7 @@ export const getFinalEndpointInputSchema = < z.intersection(acc, schema), ); return allSchemas.reduce( - (acc, schema) => copyMeta(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 830d0f1bb..6455382d4 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -11,26 +11,26 @@ export interface Metadata { brand?: string | number | symbol; } -// @todo this should be renamed to copyExamples or mixinExamples or something similar -export const copyMeta = ( +export const mixExamples = ( src: A, dest: B, ): B => { - const srcMeta = src.meta()?.[metaSymbol]; - const destMeta = dest.meta()?.[metaSymbol]; - if (!srcMeta) return dest; // ensure metadata in src below + 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 || [], + ([destExample, srcExample]) => + typeof destExample === "object" && + typeof srcExample === "object" && + destExample && + srcExample + ? R.mergeDeepRight(destExample, srcExample) + : srcExample, // not supposed to be called on non-object schemas + ); return dest.meta({ - description: dest.description, - [metaSymbol]: { - ...destMeta, - examples: combinations( - destMeta?.examples || [], - srcMeta.examples || [], - ([destExample, srcExample]) => - typeof destExample === "object" && typeof srcExample === "object" - ? R.mergeDeepRight({ ...destExample }, { ...srcExample }) - : srcExample, // not supposed to be called on non-object schemas - ), - }, + ...destMeta, + [metaSymbol]: { ...destMeta?.[metaSymbol], examples }, }); }; diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 0526ea467..cdb7b753b 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -6,6 +6,7 @@ * @desc Enables .label() on ZodDefault * @desc Enables .remap() on ZodObject * @desc Stores the argument supplied to .brand() on all schema (runtime distinguishable branded types) + * @desc Ensures that the brand withstands additional refinements or checks * */ import * as R from "ramda"; import { z, globalRegistry } from "zod"; @@ -55,20 +56,19 @@ declare module "zod" { } const exampleSetter = function (this: z.ZodType, value: z.input) { - const { examples, ...rest } = this.meta()?.[metaSymbol] || { examples: [] }; - const copy = examples.slice(); + const { [metaSymbol]: internal, ...rest } = this.meta() || {}; + const copy = internal?.examples.slice() || []; copy.push(value); return this.meta({ - description: this.description, - [metaSymbol]: { ...rest, examples: copy }, + ...rest, + [metaSymbol]: { ...internal, examples: copy }, }); }; const deprecationSetter = function (this: z.ZodType) { return this.meta({ - description: this.description, + ...this.meta(), deprecated: true, - [metaSymbol]: this.meta()?.[metaSymbol], }); }; @@ -76,9 +76,11 @@ const labelSetter = function ( this: z.ZodDefault, defaultLabel: string, ) { + const { [metaSymbol]: internal = { examples: [] }, ...rest } = + this.meta() || {}; return this.meta({ - description: this.description, - [metaSymbol]: { examples: [], ...this.meta()?.[metaSymbol], defaultLabel }, + ...rest, + [metaSymbol]: { ...internal, defaultLabel }, }); }; @@ -86,9 +88,11 @@ const brandSetter = function ( this: z.ZodType, brand?: string | number | symbol, ) { + const { [metaSymbol]: internal = { examples: [] }, ...rest } = + this.meta() || {}; return this.meta({ - description: this.description, - [metaSymbol]: { examples: [], ...this.meta()?.[metaSymbol], brand }, + ...rest, + [metaSymbol]: { ...internal, brand }, }); }; diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts index 36b32d4c2..40ac7dd5c 100644 --- a/express-zod-api/tests/metadata.spec.ts +++ b/express-zod-api/tests/metadata.spec.ts @@ -1,24 +1,26 @@ import { z } from "zod"; -import { copyMeta, metaSymbol } from "../src/metadata"; +import { mixExamples, metaSymbol } from "../src/metadata"; describe("Metadata", () => { - describe("copyMeta()", () => { + describe("mixExamples()", () => { test("should return the same dest schema in case src one has no meta", () => { const src = z.string(); const dest = z.number(); - const result = copyMeta(src, dest); + const result = mixExamples(src, dest); expect(result).toEqual(dest); expect(result.meta()?.[metaSymbol]).toBeFalsy(); expect(dest.meta()?.[metaSymbol]).toBeFalsy(); }); test("should copy meta from src to dest in case meta is defined", () => { - const src = z.string().example("some"); - const dest = z.number(); - const result = copyMeta(src, dest); + 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.description).toBe("another"); // preserves it }); test("should merge the meta from src to dest", () => { @@ -31,7 +33,7 @@ describe("Metadata", () => { .example({ b: 123 }) .example({ b: 456 }) .example({ b: 789 }); - const result = copyMeta(src, dest); + const result = mixExamples(src, dest); expect(result.meta()?.[metaSymbol]).toBeTruthy(); expect(result.meta()?.[metaSymbol]?.examples).toEqual([ { a: "some", b: 123 }, @@ -53,7 +55,7 @@ describe("Metadata", () => { .example({ a: { c: 123 } }) .example({ a: { c: 456 } }) .example({ a: { c: 789 } }); - const result = copyMeta(src, dest); + const result = mixExamples(src, dest); expect(result.meta()?.[metaSymbol]).toBeTruthy(); expect(result.meta()?.[metaSymbol]?.examples).toEqual([ { a: { b: "some", c: 123 } }, @@ -70,7 +72,7 @@ describe("Metadata", () => { const dest = z .object({ items: z.array(z.string()) }) .example({ items: ["e", "f", "g"] }); - const result = copyMeta(src, dest); + const result = mixExamples(src, dest); expect(result.meta()?.[metaSymbol]?.examples).toEqual(["a", "b"]); }); }); From bce730ff66215cf9cbcddba0d26261c9e96a2822 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 11:37:21 +0200 Subject: [PATCH 024/187] Add explicit assertion to metadata test. --- express-zod-api/tests/metadata.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts index 40ac7dd5c..c099d0eac 100644 --- a/express-zod-api/tests/metadata.spec.ts +++ b/express-zod-api/tests/metadata.spec.ts @@ -20,6 +20,7 @@ describe("Metadata", () => { expect(result.meta()?.[metaSymbol]?.examples).toEqual( src.meta()?.[metaSymbol]?.examples, ); + expect(result.meta()?.[metaSymbol]?.examples).toEqual(["some"]); expect(result.description).toBe("another"); // preserves it }); From 3b4566eea9ed59fea548155dd7c9ff7a1a39e425 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 19:19:41 +0200 Subject: [PATCH 025/187] Switching ReqResHandlingProps to core type argument. --- express-zod-api/src/documentation-helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ef5945f6a..9410dc3e4 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -71,7 +71,7 @@ export type IsHeader = ( export type BrandHandling = Record; -interface ReqResHandlingProps +interface ReqResHandlingProps extends Omit { schema: S; composition: "inline" | "components"; @@ -308,7 +308,7 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => : undefined; export const depictExamples = ( - schema: z.ZodType, + schema: $ZodType, isResponse: boolean, omitProps: string[] = [], ): ExamplesObject | undefined => { @@ -550,7 +550,7 @@ export const depictResponse = ({ description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), -}: ReqResHandlingProps & { +}: ReqResHandlingProps<$ZodType> & { mimeTypes: ReadonlyArray | null; variant: ResponseVariant; statusCode: number; From eac5c9464df25ffc65148f85bb612be54f1489e4 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 19:21:45 +0200 Subject: [PATCH 026/187] Ref: reusing BrandHandling type. --- express-zod-api/src/documentation-helpers.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 9410dc3e4..0276fb6f6 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -485,10 +485,7 @@ const fixReferences = ( // @todo rename? const delegate = ( schema: $ZodType, - { - ctx, - rules = overrides, - }: { ctx: OpenAPIContext; rules?: Record }, + { ctx, rules = overrides }: { ctx: OpenAPIContext; rules?: BrandHandling }, ) => fixReferences( z.toJSONSchema(schema, { From a4928e4290859e3e543fbdc5aafd0088fec8efb7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 22 Apr 2025 20:00:55 +0200 Subject: [PATCH 027/187] Removing todo that didn't work. --- 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 fa95fedfb..02176b2d2 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -496,7 +496,7 @@ describe("Documentation", () => { method: "post", input: category, output: z.object({ - zodExample: category, // @todo consider external registry to deduplicate it + zodExample: category, }), handler: async () => ({ zodExample: { From 6711a1ff54c9efb44cd66ce1e75890d987cfaca0 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 23 Apr 2025 08:10:26 +0200 Subject: [PATCH 028/187] Avoiding root references by wrapping the delegation subject (#2572) --- express-zod-api/src/documentation-helpers.ts | 41 +++++++++----------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 0276fb6f6..38d33ff7b 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -455,40 +455,36 @@ const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { * @todo is there a less hacky way to do that? * */ const fixReferences = ( - { $defs = {}, ...rest }: JSONSchema.BaseSchema, + subject: JSONSchema.BaseSchema, + defs: Record, ctx: OpenAPIContext, ) => { - const stack: unknown[] = [rest, $defs]; + const stack: unknown[] = [subject, defs]; while (stack.length) { const entry = stack.shift()!; if (R.is(Object, entry)) { - if (isReferenceObject(entry)) { - if (entry.$ref === "#" && !$defs[entry.$ref]) { - $defs[entry.$ref] = rest; - return fixReferences({ $defs, $ref: entry.$ref }, ctx); // false root rewriting - } - if (!entry.$ref.startsWith("#/components")) { - const actualName = entry.$ref.split("/").pop()!; - const depiction = $defs[actualName]; - if (depiction) - entry.$ref = ctx.makeRef(depiction, depiction as SchemaObject).$ref; // @todo see below - continue; - } + if (isReferenceObject(entry) && !entry.$ref.startsWith("#/components")) { + const actualName = entry.$ref.split("/").pop()!; + const depiction = defs[actualName]; + if (depiction) + entry.$ref = ctx.makeRef(depiction, depiction as SchemaObject).$ref; // @todo see below + continue; } stack.push(...R.values(entry)); } if (R.is(Array, entry)) stack.push(...R.values(entry)); } - return rest as SchemaObject; // @todo ideally, there should be a method to ensure that + return subject as SchemaObject; // @todo ideally, there should be a method to ensure that }; // @todo rename? const delegate = ( - schema: $ZodType, + subject: $ZodType, { ctx, rules = overrides }: { ctx: OpenAPIContext; rules?: BrandHandling }, -) => - fixReferences( - z.toJSONSchema(schema, { +) => { + const { $defs = {}, properties = {} } = z.toJSONSchema( + z.object({ subject }), // avoiding "document root" references + { unrepresentable: "any", metadata: globalRegistry, io: ctx.isResponse ? "output" : "input", @@ -500,9 +496,10 @@ const delegate = ( ]?.(zodCtx, ctx); onEach(zodCtx, ctx); }, - }), - ctx, - ); + }, + ) as JSONSchema.ObjectSchema; + return fixReferences(properties["subject"], $defs, ctx); +}; export const excludeParamsFromDepiction = ( subject: SchemaObject | ReferenceObject, From cd1b15ea810729f3dd0d410778b560aa7c04a4f8 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 23 Apr 2025 10:14:59 +0200 Subject: [PATCH 029/187] Changing the type of internal examples to `z.$input` symbol (#2567) More specific type for internal examples featured by Zod 4 --- express-zod-api/src/metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 6455382d4..062f45d48 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -5,7 +5,7 @@ import * as R from "ramda"; export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { - examples: unknown[]; // @todo try z.$input[] instead + examples: z.$input[]; /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; From 67bb005e4175640d23ce01693d49076c2f6d610e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 23 Apr 2025 11:15:58 +0200 Subject: [PATCH 030/187] Test for the brand withstanding describing. --- express-zod-api/tests/zod-plugin.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index df92accdc..587456728 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -90,6 +90,11 @@ describe("Zod Runtime Plugin", () => { schemaWithMeta.regex(/@example.com$/).meta()?.[metaSymbol], ).toHaveProperty("brand", "test"); }); + + test("should withstand describing", () => { + const schema = z.string().brand("test").describe("something"); + expect(schema.meta()?.[metaSymbol]?.brand).toBe("test"); + }); }); describe(".remap()", () => { From 0f6b451ca6ef319acfaeea6f3dfdac3d458a011c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 23 Apr 2025 14:16:28 +0200 Subject: [PATCH 031/187] minor: renaming internal method. --- express-zod-api/src/documentation-helpers.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 38d33ff7b..e8eb102cf 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -262,7 +262,7 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { ctx.isResponse ? "in" : "out" ]; if (target instanceof z.ZodTransform) { - const opposingDepiction = delegate(opposite, { ctx, rules: overrides }); + const opposingDepiction = depict(opposite, { ctx, rules: overrides }); if (isSchemaObject(opposingDepiction)) { if (!ctx.isResponse) { const { type: opposingType, ...rest } = opposingDepiction; @@ -389,7 +389,7 @@ export const depictRequestParams = ({ ? "query" : undefined; if (!location) return acc; - const depicted = delegate(paramSchema, { + const depicted = depict(paramSchema, { rules: { ...brandHandling, ...overrides }, ctx: { isResponse: false, makeRef, path, method }, }); @@ -477,8 +477,7 @@ const fixReferences = ( return subject as SchemaObject; // @todo ideally, there should be a method to ensure that }; -// @todo rename? -const delegate = ( +const depict = ( subject: $ZodType, { ctx, rules = overrides }: { ctx: OpenAPIContext; rules?: BrandHandling }, ) => { @@ -552,7 +551,7 @@ export const depictResponse = ({ }): ResponseObject => { if (!mimeTypes) return { description }; const depictedSchema = excludeExamplesFromDepiction( - delegate(schema, { + depict(schema, { rules: { ...brandHandling, ...overrides }, ctx: { isResponse: true, makeRef, path, method }, }), @@ -674,7 +673,7 @@ export const depictBody = ({ paramNames: string[]; }) => { const [withoutParams, hasRequired] = excludeParamsFromDepiction( - delegate(schema, { + depict(schema, { rules: { ...brandHandling, ...overrides }, ctx: { isResponse: false, makeRef, path, method }, }), From 4988336eda5cec990f11f74b9f4d8c19591c12a2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 23 Apr 2025 14:17:28 +0200 Subject: [PATCH 032/187] rm redundant argument in onPipeline. --- 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 e8eb102cf..6398800da 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -262,7 +262,7 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { ctx.isResponse ? "in" : "out" ]; if (target instanceof z.ZodTransform) { - const opposingDepiction = depict(opposite, { ctx, rules: overrides }); + const opposingDepiction = depict(opposite, { ctx }); if (isSchemaObject(opposingDepiction)) { if (!ctx.isResponse) { const { type: opposingType, ...rest } = opposingDepiction; From 83dfadd5f64fa5f4446a8f03ae5cdc9e2a5339b3 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 23 Apr 2025 19:52:14 +0200 Subject: [PATCH 033/187] a couple todos. --- express-zod-api/src/documentation-helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 6398800da..1c9110584 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -429,6 +429,7 @@ const overrides: Partial> = const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { const { description, deprecated } = globalRegistry.get(zodSchema) ?? {}; + // @todo check if this still required after updating the core if (description) jsonSchema.description ??= description; if (deprecated) jsonSchema.deprecated = true; const shouldAvoidParsing = @@ -485,7 +486,7 @@ const depict = ( z.object({ subject }), // avoiding "document root" references { unrepresentable: "any", - metadata: globalRegistry, + metadata: globalRegistry, // @todo might be redundant io: ctx.isResponse ? "output" : "input", override: (zodCtx) => { const { brand } = From 0c2b4001a20c04d4ad05eac090d1af8066d31462 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 23 Apr 2025 20:58:59 +0200 Subject: [PATCH 034/187] Improve handling of `ZodError` by `ensureError` (#2573) Addition to #2537 --- express-zod-api/src/common-helpers.ts | 2 +- .../tests/__snapshots__/system.spec.ts.snap | 29 +++++++------------ express-zod-api/tests/common-helpers.spec.ts | 6 ++-- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index ef2f86a16..0e3e9b760 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -67,7 +67,7 @@ export const ensureError = (subject: unknown): Error => subject instanceof Error ? subject : subject instanceof z.ZodError - ? new Error(subject.message) + ? new Error(getMessageFromError(subject), { cause: subject }) : new Error(String(subject)); export const getMessageFromError = (error: Error): string => { diff --git a/express-zod-api/tests/__snapshots__/system.spec.ts.snap b/express-zod-api/tests/__snapshots__/system.spec.ts.snap index e63625583..c22eb5407 100644 --- a/express-zod-api/tests/__snapshots__/system.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/system.spec.ts.snap @@ -122,24 +122,17 @@ exports[`App in production mode > Validation > Problem 787: Should NOT treat Zod "Server side error", { "error": InternalServerError({ - "cause": Error({ - "message": "[ - { - "expected": "number", - "code": "invalid_type", - "path": [], - "message": "Invalid input: expected number, received string" - } -]", - }), - "message": "[ - { - "expected": "number", - "code": "invalid_type", - "path": [], - "message": "Invalid input: expected number, received string" - } -]", + "cause": ZodError { + "issues": [ + { + "code": "invalid_type", + "expected": "number", + "message": "Invalid input: expected number, received string", + "path": [], + }, + ], + }, + "message": "Invalid input: expected number, received string", }), "payload": { "key": "123", diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index ef097d358..d697b3bca 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -349,13 +349,11 @@ describe("Common Helpers", () => { code: "invalid_type", expected: "string", input: 123, - path: [""], + path: [], message: "invalid type", }, ]), - `[\n {\n "code": "invalid_type",\n "expected": "string",\n` + - ` "input": 123,\n "path": [\n ""\n` + - ` ],\n "message": "invalid type"\n }\n]`, + "invalid type", ], [createHttpError(500, "Internal Server Error"), "Internal Server Error"], [undefined, "undefined"], From 7bb39cbef7f7337c93f23689e531f032eaefe594 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 23 Apr 2025 21:40:54 +0200 Subject: [PATCH 035/187] Improving coverage for onIntersection. --- express-zod-api/src/documentation-helpers.ts | 8 ++++---- .../__snapshots__/documentation-helpers.spec.ts.snap | 1 + express-zod-api/tests/documentation-helpers.spec.ts | 6 +++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 1c9110584..358c48676 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -138,10 +138,10 @@ const approaches = { required: ({ required: left = [] }, { required: right = [] }) => R.union(left, right), examples: ({ examples: left = [] }, { examples: right = [] }) => - combinations(left, right, ([a, b]) => - typeof a === "object" && typeof b === "object" - ? R.mergeDeepRight({ ...a }, { ...b }) - : a, + combinations( + left.filter((entry) => typeof entry === "object"), + right.filter((entry) => typeof entry === "object"), + ([a, b]) => R.mergeDeepRight({ ...a }, { ...b }), ), description: ({ description: left }, { description: right }) => left || right, } satisfies { 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 75d62610c..6d1045761 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -603,6 +603,7 @@ exports[`Documentation helpers > onIntersection() > should flatten objects with exports[`Documentation helpers > onIntersection() > should flatten two object schemas 1`] = ` { + "description": "some", "properties": { "one": { "type": "number", diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 777649163..446bc8250 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -189,7 +189,11 @@ describe("Documentation helpers", () => { test("should flatten two object schemas", () => { const jsonSchema: JSONSchema.BaseSchema = { allOf: [ - { type: "object", properties: { one: { type: "number" } } }, + { + type: "object", + description: "some", + properties: { one: { type: "number" } }, + }, { type: "object", properties: { two: { type: "number" } } }, ], }; From 5e790deb61848cf3a93da5709f0f7bedfe5e9911 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 09:13:30 +0200 Subject: [PATCH 036/187] Switching circular schema example to z.interface(). --- example/endpoints/retrieve-user.ts | 14 +++++--------- example/example.client.ts | 9 +++------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 0c0746b00..78796cbe2 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -4,16 +4,12 @@ import { z } from "zod"; import { defaultEndpointsFactory } from "express-zod-api"; import { methodProviderMiddleware } from "../middlewares"; -// Demonstrating circular schemas using z.lazy() -// @todo switch to z.interface for that -interface Feature { - title: string; - features: Feature[]; -} - -const feature: z.ZodType = z.object({ +// Demonstrating circular schemas using z.interface() +const feature = z.interface({ title: z.string(), - features: z.lazy(() => feature.array()), + get features() { + return z.array(feature); + }, }); export const retrieveUserEndpoint = defaultEndpointsFactory diff --git a/example/example.client.ts b/example/example.client.ts index 77c4449bb..469c54a13 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -1,7 +1,7 @@ type Type1 = { title: string; - features: Type1; -}[]; + features: Type1[]; +}; type SomeOf = T[keyof T]; @@ -17,10 +17,7 @@ type GetV1UserRetrievePositiveVariant1 = { data: { id: number; name: string; - features: { - title: string; - features: Type1; - }[]; + features: Type1[]; }; }; From 26a1ab9ab53e63da9b49101e0ba53472088ac423 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 09:17:34 +0200 Subject: [PATCH 037/187] Also demonstrating optional reference. --- example/endpoints/retrieve-user.ts | 8 ++++---- example/example.client.ts | 2 +- example/example.documentation.yaml | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 78796cbe2..523288195 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -7,7 +7,7 @@ import { methodProviderMiddleware } from "../middlewares"; // Demonstrating circular schemas using z.interface() const feature = z.interface({ title: z.string(), - get features() { + get "features?"() { return z.array(feature); }, }); @@ -39,14 +39,14 @@ export const retrieveUserEndpoint = defaultEndpointsFactory id, name, features: [ - { title: "Tall", features: [{ title: "Above 180cm", features: [] }] }, - { title: "Young", features: [] }, + { title: "Tall", features: [{ title: "Above 180cm" }] }, + { title: "Young" }, { title: "Cute", features: [ { title: "Tells funny jokes", - features: [{ title: "About Typescript", features: [] }], + features: [{ title: "About Typescript" }], }, ], }, diff --git a/example/example.client.ts b/example/example.client.ts index 469c54a13..027b5945e 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -1,6 +1,6 @@ type Type1 = { title: string; - features: Type1[]; + features?: Type1[]; }; type SomeOf = T[keyof T]; diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 4714e3d26..b6b425874 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -694,7 +694,6 @@ components: $ref: "#/components/schemas/Schema1" required: - title - - features responses: {} parameters: {} examples: {} From 372e3acb242deab943cb94e66825c7df96ee72e1 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 09:36:28 +0200 Subject: [PATCH 038/187] Fixed snapshots. --- example/__snapshots__/index.spec.ts.snap | 3 --- example/index.spec.ts | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/example/__snapshots__/index.spec.ts.snap b/example/__snapshots__/index.spec.ts.snap index 7ca641c63..e75af3e97 100644 --- a/example/__snapshots__/index.spec.ts.snap +++ b/example/__snapshots__/index.spec.ts.snap @@ -17,14 +17,12 @@ exports[`Example > Client > Should perform the request with a positive response { "features": [ { - "features": [], "title": "Above 180cm", }, ], "title": "Tall", }, { - "features": [], "title": "Young", }, { @@ -32,7 +30,6 @@ exports[`Example > Client > Should perform the request with a positive response { "features": [ { - "features": [], "title": "About Typescript", }, ], diff --git a/example/index.spec.ts b/example/index.spec.ts index 002c88931..3cb579217 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -111,15 +111,15 @@ describe("Example", async () => { features: [ { title: "Tall", - features: [{ title: "Above 180cm", features: [] }], + features: [{ title: "Above 180cm" }], }, - { title: "Young", features: [] }, + { title: "Young" }, { title: "Cute", features: [ { title: "Tells funny jokes", - features: [{ title: "About Typescript", features: [] }], + features: [{ title: "About Typescript" }], }, ], }, From 8d00dc7399ae558a47971f4d8c06cc3e6b4a9547 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 24 Apr 2025 18:52:34 +0200 Subject: [PATCH 039/187] Removing arguments from schema types (#2575) Instead of #2574 (partially) Due to defaults implemented by Zod 4 --- express-zod-api/src/zod-plugin.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index cdb7b753b..5b78cf2d1 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -72,10 +72,7 @@ const deprecationSetter = function (this: z.ZodType) { }); }; -const labelSetter = function ( - this: z.ZodDefault, - defaultLabel: string, -) { +const labelSetter = function (this: z.ZodDefault, defaultLabel: string) { const { [metaSymbol]: internal = { examples: [] }, ...rest } = this.meta() || {}; return this.meta({ @@ -165,9 +162,9 @@ if (!(metaSymbol in globalThis)) { Object.defineProperty( z.ZodDefault.prototype, - "label" satisfies keyof z.ZodDefault, + "label" satisfies keyof z.ZodDefault, { - get(): z.ZodDefault["label"] { + get(): z.ZodDefault["label"] { return labelSetter.bind(this); }, }, From b849e9e1e5712a842cad7cf5387d62c32218ef16 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 20:45:50 +0200 Subject: [PATCH 040/187] Updatign Zod, core 0.9.0, several issues fix, _ref added to JSONSchema. --- example/example.documentation.yaml | 26 +++++----- express-zod-api/src/documentation-helpers.ts | 17 ++++--- .../__snapshots__/documentation.spec.ts.snap | 50 +++++++++---------- .../tests/__snapshots__/env.spec.ts.snap | 17 +++++-- .../tests/documentation-helpers.spec.ts | 10 ++-- express-zod-api/tests/env.spec.ts | 4 +- package.json | 2 +- yarn.lock | 18 +++---- 8 files changed, 79 insertions(+), 65 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b6b425874..c7eeb03d8 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -16,10 +16,10 @@ paths: required: true description: a numeric string containing the id of the user schema: + description: a numeric string containing the id of the user type: string format: regex pattern: \d+ - description: a numeric string containing the id of the user responses: "200": description: GET /v1/user/retrieve Positive response @@ -85,10 +85,10 @@ paths: required: true description: numeric string schema: + description: numeric string type: string format: regex pattern: \d+ - description: numeric string responses: "204": description: DELETE /v1/user/:id/remove Positive response @@ -107,10 +107,10 @@ paths: required: true description: PATCH /v1/user/:id Parameter schema: - type: string - minLength: 1 examples: - "1234567890" + type: string + minLength: 1 examples: example1: value: "1234567890" @@ -119,9 +119,9 @@ paths: required: true description: PATCH /v1/user/:id Parameter schema: - type: string examples: - "12" + type: string examples: example1: value: "12" @@ -133,15 +133,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 @@ -179,9 +179,9 @@ paths: type: object properties: name: - type: string examples: - John Doe + type: string createdAt: description: YYYY-MM-DDTHH:mm:ss.sssZ type: string @@ -312,8 +312,8 @@ paths: const: exists id: type: integer - exclusiveMinimum: -9007199254740991 - exclusiveMaximum: 9007199254740991 + minimum: -9007199254740991 + maximum: 9007199254740991 required: - status - id @@ -578,9 +578,9 @@ paths: required: false description: for testing error response schema: - type: string - description: for testing error response deprecated: true + description: for testing error response + type: string responses: "200": description: GET /v1/events/stream Positive response diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 358c48676..939e33a2d 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -158,6 +158,7 @@ const canMerge = R.pipe( const intersect = R.tryCatch( (children: Array): JSONSchema.ObjectSchema => { const [left, right] = children + .map(({ _ref, ...rest }) => (_ref ? { ...rest, ..._ref } : rest)) .filter( (schema): schema is JSONSchema.ObjectSchema => schema.type === "object", ) @@ -194,7 +195,7 @@ const isSupportedType = (subject: string): subject is SchemaObjectType => export const onDateIn: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); - delete jsonSchema.anyOf; // undo default + delete jsonSchema._ref; // undo default Object.assign(jsonSchema, { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", @@ -288,13 +289,15 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { } }; +// @todo THIS does not work well due to _ref export const onRaw: Overrider = ({ jsonSchema }) => { - Object.assign( - jsonSchema, - (jsonSchema as JSONSchema.ObjectSchema).properties!.raw, - ); - delete jsonSchema.properties; // undo default - delete jsonSchema.required; + if (!jsonSchema._ref) return; + if (jsonSchema._ref.type !== "object") return; + const objSchema = jsonSchema._ref as JSONSchema.ObjectSchema; + if (!objSchema.properties) return; + if (!("raw" in objSchema.properties)) return; + delete jsonSchema._ref; // undo + Object.assign(jsonSchema, objSchema.properties.raw); }; const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index a8b2818c6..b60546165 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -930,8 +930,8 @@ paths: required: false description: GET /v1/getSomething Parameter schema: - type: string default: test + type: string - name: nullish in: query required: false @@ -945,11 +945,11 @@ paths: required: false description: GET /v1/getSomething Parameter schema: + default: 123 type: - integer - "null" exclusiveMaximum: 9007199254740991 - default: 123 responses: "200": description: GET /v1/getSomething Positive response @@ -1139,8 +1139,8 @@ paths: format: bigint pattern: ^-?\\d+$ boolean: - type: boolean readOnly: true + type: boolean dateIn: description: YYYY-MM-DDTHH:mm:ss.sssZ type: string @@ -1403,8 +1403,8 @@ paths: maximum: 0.5 int: type: integer - exclusiveMinimum: -9007199254740991 - exclusiveMaximum: 9007199254740991 + minimum: -9007199254740991 + maximum: 9007199254740991 intPositive: type: integer exclusiveMaximum: 9007199254740991 @@ -1759,8 +1759,8 @@ paths: type: string additionalProperties: type: integer - exclusiveMinimum: -9007199254740991 - exclusiveMaximum: 9007199254740991 + minimum: -9007199254740991 + maximum: 9007199254740991 stringy: type: object propertyNames: @@ -1773,8 +1773,8 @@ paths: type: object propertyNames: type: integer - exclusiveMinimum: -9007199254740991 - exclusiveMaximum: 9007199254740991 + minimum: -9007199254740991 + maximum: 9007199254740991 additionalProperties: type: boolean literal: @@ -2307,15 +2307,15 @@ paths: required: true description: GET /v1/:name Parameter schema: - type: string summary: My custom schema + type: string - name: other in: query required: true description: GET /v1/:name Parameter schema: - type: boolean summary: My custom schema + type: boolean - name: regular in: query required: true @@ -2336,8 +2336,8 @@ paths: type: object properties: number: - type: number summary: My custom schema + type: number required: - number required: @@ -3172,6 +3172,11 @@ paths: status: const: success data: + examples: + - a: first + b: prefix_first + - a: second + b: prefix_second type: object properties: a: @@ -3181,11 +3186,6 @@ paths: required: - a - b - examples: - - a: first - b: prefix_first - - a: second - b: prefix_second required: - status - data @@ -3282,9 +3282,9 @@ paths: components: schemas: GetHrisEmployeesParameterCursor: - type: string description: An optional cursor string used for pagination. This can be retrieved from the \`next\` property of the previous page response. + type: string GetHrisEmployeesPositiveResponse: type: object properties: @@ -3361,14 +3361,14 @@ paths: status: const: success data: + examples: + - num: 123 type: object properties: num: type: number required: - num - examples: - - num: 123 required: - status - data @@ -3449,14 +3449,14 @@ paths: status: const: success data: + examples: + - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr - examples: - - numericStr: "123" required: - status - data @@ -3543,14 +3543,14 @@ paths: status: const: success data: + examples: + - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr - examples: - - numericStr: "123" required: - status - data @@ -3616,9 +3616,9 @@ paths: required: true description: GET /v1/getSomething Parameter schema: - type: string examples: - "123" + type: string examples: example1: value: "123" diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 14b167ae0..aecb4dcc3 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -42,6 +42,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb { "computed": { "format": "safeint", + "inclusive": true, "maximum": 9007199254740991, "minimum": -9007199254740991, "pattern": /\\^\\\\d\\+\\$/, @@ -50,8 +51,8 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "def": { "checks": [ { - "exclusiveMaximum": 9007199254740991, - "exclusiveMinimum": -9007199254740991, + "maximum": 9007199254740991, + "minimum": -9007199254740991, "type": "integer", }, ], @@ -75,6 +76,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "check": [Function], "computed": { "format": "safeint", + "inclusive": true, "maximum": 9007199254740991, "minimum": -9007199254740991, "pattern": /\\^\\\\d\\+\\$/, @@ -109,6 +111,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "check": [Function], "computed": { "format": "int32", + "inclusive": true, "maximum": 2147483647, "minimum": -2147483648, "pattern": /\\^\\\\d\\+\\$/, @@ -171,7 +174,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri exports[`Environment checks > Zod imperfections > input examples of transformations 1`] = ` { "examples": [ - "test", + 4, ], "type": "string", } @@ -183,4 +186,10 @@ exports[`Environment checks > Zod imperfections > meta overrides, does not merge } `; -exports[`Environment checks > Zod imperfections > output examples of transformations 1`] = `{}`; +exports[`Environment checks > Zod imperfections > output examples of transformations 1`] = ` +{ + "examples": [ + 4, + ], +} +`; diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 446bc8250..ea7180597 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -134,9 +134,11 @@ describe("Documentation helpers", () => { describe("onRaw()", () => { test("should extract the raw property", () => { - const jsonSchema: JSONSchema.ObjectSchema = { - type: "object", - properties: { raw: { format: "binary", type: "string" } }, + const jsonSchema: JSONSchema.BaseSchema = { + _ref: { + type: "object", + properties: { raw: { format: "binary", type: "string" } }, + }, }; onRaw({ zodSchema: z.never(), jsonSchema }, requestCtx); expect(jsonSchema).toMatchSnapshot(); @@ -494,7 +496,7 @@ describe("Documentation helpers", () => { describe("onDateIn", () => { test("should set type:string, pattern and format", () => { - const jsonSchema: JSONSchema.BaseSchema = { anyOf: [] }; + const jsonSchema: JSONSchema.BaseSchema = { _ref: { anyOf: [] } }; onDateIn({ zodSchema: z.never(), jsonSchema }, requestCtx); expect(jsonSchema).toMatchSnapshot(); }); diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 967d6d450..89d17638e 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -63,8 +63,8 @@ describe("Environment checks", () => { }); /** - * output examples would be possiblity fixed by this: - * @link https://github.com/colinhacks/zod/pull/4074/commits/818cfe78b0341e9e8cfbda248bff4268bd8352e8#diff-44530efe91e850ac97367f36dbb2eb83f8a567087a4dbbec1c262e177ac4d085R540 + * output examples would be possibly fixed by this: + * @todo fixed, move out of imperfections */ test.each(["input", "output"] as const)( "%s examples of transformations", diff --git a/package.json b/package.json index 3a40995f5..f8946d5a4 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.31.0", "vitest": "^3.1.2", - "zod": "^4.0.0-beta.20250420T053007" + "zod": "^4.0.0-beta.20250424T163858" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 56ae6f1e7..a6f70110f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,10 +825,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.8.1": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.8.1.tgz#8d1c245677a80805d183e8b493dd37f459674416" - integrity sha512-djj8hPhxIHcG8ptxITaw/Bout5HJZ9NyRbKr95Eilqwt9R0kvITwUQGDU+n+MVdsBIka5KwztmZSLti22F+P0A== +"@zod/core@0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.9.0.tgz#53dfa0a61916cf7e53980ecbd9681e57362edcd1" + integrity sha512-bVfPiV2kDUkAJ4ArvV4MHcPZA8y3xOX6/SjzSy2kX2ACopbaaAP4wk6hd/byRmfi9MLNai+4SFJMmcATdOyclg== accepts@^1.3.7: version "1.3.8" @@ -3079,9 +3079,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250420T053007: - version "4.0.0-beta.20250420T053007" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250420T053007.tgz#ab64663e7835d5db5d61d708f4cb5c5ab97623db" - integrity sha512-5pp8Q0PNDaNcUptGiBE9akyioJh3RJpagIxrLtAVMR9IxwcSZiOsJD/1/98CyhItdTlI2H91MfhhLzRlU+fifA== +zod@^4.0.0-beta.20250424T163858: + version "4.0.0-beta.20250424T163858" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250424T163858.tgz#182eed707f41dc1aa3524ad79b171ce780cd926a" + integrity sha512-fKhW+lEJnfUGo0fvQjmam39zUytARR2UdCEh7/OXJSBbKScIhD343K74nW+UUHu/r6dkzN6Uc/GqwogFjzpCXg== dependencies: - "@zod/core" "0.8.1" + "@zod/core" "0.9.0" From 836e4e12da766ab9cd8f75dc884a4512b7211039 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 20:49:27 +0200 Subject: [PATCH 041/187] rm todo that i fixed. --- express-zod-api/src/documentation-helpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 939e33a2d..60708b5ef 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -289,7 +289,6 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { } }; -// @todo THIS does not work well due to _ref export const onRaw: Overrider = ({ jsonSchema }) => { if (!jsonSchema._ref) return; if (jsonSchema._ref.type !== "object") return; From db4a988d39c2847ebe70cac51c4201974fbc332c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 20:52:07 +0200 Subject: [PATCH 042/187] Removing description and deprecation settets in onEach - fixed in core. --- express-zod-api/src/documentation-helpers.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 60708b5ef..f2e415e86 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -430,10 +430,6 @@ const overrides: Partial> = }; const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { - const { description, deprecated } = globalRegistry.get(zodSchema) ?? {}; - // @todo check if this still required after updating the core - if (description) jsonSchema.description ??= description; - if (deprecated) jsonSchema.deprecated = true; const shouldAvoidParsing = zodSchema._zod.def.type === "lazy" || zodSchema._zod.def.type === "promise"; const hasTypePropertyInDepiction = jsonSchema.type !== undefined; From cc525a0c2669f46f47aa65dfbc65987223317570 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 20:53:44 +0200 Subject: [PATCH 043/187] Removing explicit registry in depict(). --- express-zod-api/src/documentation-helpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index f2e415e86..65e20c8cb 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -484,7 +484,6 @@ const depict = ( z.object({ subject }), // avoiding "document root" references { unrepresentable: "any", - metadata: globalRegistry, // @todo might be redundant io: ctx.isResponse ? "output" : "input", override: (zodCtx) => { const { brand } = From 19933baac82e472be83b70264c14ec885dbade12 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 20:56:34 +0200 Subject: [PATCH 044/187] Adjusting the reason on env test. --- express-zod-api/tests/env.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 89d17638e..51a9718f9 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -62,10 +62,7 @@ describe("Environment checks", () => { ); }); - /** - * output examples would be possibly fixed by this: - * @todo fixed, move out of imperfections - */ + /** now input examples are broken */ test.each(["input", "output"] as const)( "%s examples of transformations", (io) => { From c1f502a35f839ffc60ba647fc3d550f13f48b26d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 24 Apr 2025 21:47:13 +0200 Subject: [PATCH 045/187] Migrating deprecated methods to shorthands in dateIn schema, with a fix for DTS build. --- express-zod-api/src/date-in-schema.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index 0e29e4ad7..19ec6c94a 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -4,10 +4,10 @@ export const ezDateInBrand = Symbol("DateIn"); export const dateIn = () => { const schema = z.union([ - z.string().date(), - z.string().datetime(), - z.string().datetime({ local: true }), - ]); + z.iso.date(), + z.iso.datetime(), + z.iso.datetime({ local: true }), + ]) as z.ZodUnion<[z.ZodString, z.ZodString, z.ZodString]>; // this fixes DTS build for ez export return schema .transform((str) => new Date(str)) From 3689d520c39107fa9236364df8c86872b07a35aa Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 25 Apr 2025 08:46:25 +0200 Subject: [PATCH 046/187] Moving try..catch from intersect() to onIntersection. --- express-zod-api/src/documentation-helpers.ts | 41 ++++++++++---------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 65e20c8cb..ebc480e98 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -155,29 +155,30 @@ const canMerge = R.pipe( R.isEmpty, ); -const intersect = R.tryCatch( - (children: Array): JSONSchema.ObjectSchema => { - const [left, right] = children - .map(({ _ref, ...rest }) => (_ref ? { ...rest, ..._ref } : rest)) - .filter( - (schema): schema is JSONSchema.ObjectSchema => schema.type === "object", - ) - .filter(canMerge); - if (!left || !right) throw new Error("Can not flatten objects"); - const suitable: typeof approaches = R.pickBy( - (_, prop) => (left[prop] || right[prop]) !== undefined, - approaches, - ); - return R.map((fn) => fn(left, right), suitable); - }, - (_err, allOf): JSONSchema.BaseSchema => ({ allOf }), -); +const intersect = ( + children: Array, +): JSONSchema.ObjectSchema => { + const [left, right] = children + .map(({ _ref, ...rest }) => (_ref ? { ...rest, ..._ref } : rest)) + .filter( + (schema): schema is JSONSchema.ObjectSchema => schema.type === "object", + ) + .filter(canMerge); + if (!left || !right) throw new Error("Can not flatten objects"); + const suitable: typeof approaches = R.pickBy( + (_, prop) => (left[prop] || right[prop]) !== undefined, + approaches, + ); + return R.map((fn) => fn(left, right), suitable); +}; export const onIntersection: Overrider = ({ jsonSchema }) => { if (!jsonSchema.allOf) return; - const attempt = intersect(jsonSchema.allOf); - delete jsonSchema.allOf; // undo default - Object.assign(jsonSchema, attempt); + try { + const attempt = intersect(jsonSchema.allOf); + delete jsonSchema.allOf; // undo default + Object.assign(jsonSchema, attempt); + } catch {} }; /** @since OAS 3.1 nullable replaced with type array having null */ From 2c5635adc9f2ae968e72153bb760e57a02e9379a Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 25 Apr 2025 18:23:26 +0200 Subject: [PATCH 047/187] Removing deprecated alias `ZodTypeAny` (#2578) https://v4.zod.dev/v4/changelog > The need for z.ZodTypeAny has been eliminated; just use z.ZodType instead. Part of rethinking #2574 --- express-zod-api/src/api-response.ts | 4 ++-- express-zod-api/src/integration.ts | 2 +- express-zod-api/src/result-handler.ts | 2 +- express-zod-api/src/sse.ts | 4 ++-- express-zod-api/src/zts.ts | 2 +- express-zod-api/tests/index.spec.ts | 4 +--- express-zod-api/tests/integration.spec.ts | 2 +- 7 files changed, 9 insertions(+), 11 deletions(-) diff --git a/express-zod-api/src/api-response.ts b/express-zod-api/src/api-response.ts index faad3bcb7..0d43926a8 100644 --- a/express-zod-api/src/api-response.ts +++ b/express-zod-api/src/api-response.ts @@ -11,7 +11,7 @@ export const responseVariants = Object.keys( ) as ResponseVariant[]; /** @public this is the user facing configuration */ -export interface ApiResponse { +export interface ApiResponse { schema: S; /** @default 200 for a positive and 400 for a negative response */ statusCode?: number | [number, ...number[]]; @@ -31,7 +31,7 @@ export interface ApiResponse { * @see normalize * */ export interface NormalizedResponse { - schema: z.ZodTypeAny; + schema: z.ZodType; statusCodes: [number, ...number[]]; mimeTypes: [string, ...string[]] | null; } diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 509c8be57..6f59f769e 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -45,7 +45,7 @@ interface IntegrationParams { * @desc The schema to use for responses without body such as 204 * @default z.undefined() * */ - noContent?: z.ZodTypeAny; + noContent?: z.ZodType; /** * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index 3cfe0c987..414608972 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -30,7 +30,7 @@ type Handler = (params: { logger: ActualLogger; }) => void | Promise; -export type Result = +export type Result = | S // plain schema, default status codes applied | ApiResponse // single response definition, status code(s) customizable | ApiResponse[]; // Feature #1431: different responses for different status codes (non-empty, prog. check!) diff --git a/express-zod-api/src/sse.ts b/express-zod-api/src/sse.ts index 6ad34fed0..5976e0f92 100644 --- a/express-zod-api/src/sse.ts +++ b/express-zod-api/src/sse.ts @@ -11,7 +11,7 @@ import { logServerError, } from "./result-helpers"; -type EventsMap = Record; +type EventsMap = Record; export interface Emitter extends FlatObject { /** @desc Returns true when the connection was closed or terminated */ @@ -20,7 +20,7 @@ export interface Emitter extends FlatObject { emit: (event: K, data: z.input) => void; } -export const makeEventSchema = (event: string, data: z.ZodTypeAny) => +export const makeEventSchema = (event: string, data: z.ZodType) => z.object({ data, event: z.literal(event), diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 8d8335cb2..79aaf59e1 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -256,7 +256,7 @@ const producers: HandlingRules< }; export const zodToTs = ( - schema: z.ZodTypeAny, + schema: z.ZodType, { brandHandling, ctx, diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index e3dba8809..011114f6c 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -88,9 +88,7 @@ describe("Index Entrypoint", () => { type: "openid"; url: string; }>().toEqualTypeOf(); - expectTypeOf({ schema: z.string() }).toExtend< - ApiResponse - >(); + expectTypeOf({ schema: z.string() }).toExtend>(); }); test("Extended Zod prototypes", () => { diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index c4a6088b4..1afd29689 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -9,7 +9,7 @@ import { } from "../src"; describe("Integration", () => { - const recursive1: z.ZodTypeAny = z.lazy(() => + const recursive1: z.ZodType = z.lazy(() => z.object({ name: z.string(), features: recursive1, From a527948b09f5bd2f079b90360968deea150813d3 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 25 Apr 2025 18:27:51 +0200 Subject: [PATCH 048/187] Using shorthand z.base64() for the corresponding ez.file(). --- express-zod-api/src/file-schema.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/express-zod-api/src/file-schema.ts b/express-zod-api/src/file-schema.ts index b5382f812..7b9cd5956 100644 --- a/express-zod-api/src/file-schema.ts +++ b/express-zod-api/src/file-schema.ts @@ -10,11 +10,7 @@ const variants = { buffer: () => bufferSchema.brand(ezFileBrand as symbol), string: () => z.string().brand(ezFileBrand as symbol), binary: () => bufferSchema.or(z.string()).brand(ezFileBrand as symbol), - base64: () => - z - .string() - .base64() - .brand(ezFileBrand as symbol), + base64: () => z.base64().brand(ezFileBrand as symbol), }; type Variants = typeof variants; From e15e2fe779f447a45a6af6c985a46340290e9d5c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 25 Apr 2025 19:43:10 +0200 Subject: [PATCH 049/187] Migrating traverse related methods to core types (#2579) Instead of #2574 --- express-zod-api/src/common-helpers.ts | 3 +-- express-zod-api/src/documentation-helpers.ts | 4 +++- express-zod-api/src/zts.ts | 18 ++++++++++-------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 0e3e9b760..9e4d226d5 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -89,8 +89,7 @@ export const getMessageFromError = (error: Error): string => { export const pullExampleProps = (subject: T) => Object.entries(subject.shape).reduce>[]>( (acc, [key, schema]) => { - const examples = - (schema as z.ZodType).meta()?.[metaSymbol]?.examples || []; + const { examples = [] } = globalRegistry.get(schema)?.[metaSymbol] || {}; return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ ...left, ...right, diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ebc480e98..33a959223 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -404,7 +404,9 @@ export const depictRequestParams = ({ name, in: location, deprecated: globalRegistry.get(paramSchema)?.deprecated, - required: !(paramSchema as z.ZodType).isOptional(), + required: !( + paramSchema instanceof z.ZodType && paramSchema.isOptional() + ), description: depicted.description || description, schema: result, examples: depictParamExamples(objectSchema, name), diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 79aaf59e1..7c2ec80e2 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -4,10 +4,12 @@ import type { $ZodDefault, $ZodDiscriminatedUnion, $ZodEnum, + $ZodInterface, $ZodIntersection, $ZodLazy, $ZodLiteral, $ZodNullable, + $ZodObject, $ZodOptional, $ZodPipe, $ZodReadonly, @@ -66,7 +68,7 @@ const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => { return values.length === 1 ? values[0] : f.createUnionTypeNode(values); }; -const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) => +const onInterface: Producer = (int: $ZodInterface, { next, makeAlias }) => makeAlias(int, () => { const members = Object.entries(int._zod.def.shape).map( ([key, value]) => { @@ -84,16 +86,16 @@ const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) => }); const onObject: Producer = ( - { _zod: { def } }: z.ZodObject, + { _zod: { def } }: $ZodObject, { isResponse, next }, ) => { const members = Object.entries(def.shape).map( ([key, value]) => { const isOptional = isResponse - ? value instanceof z.ZodOptional - : value instanceof z.ZodPromise - ? false - : (value as z.ZodType).isOptional(); + ? value._zod.def.type === "optional" + : value._zod.def.type !== "promise" && + value instanceof z.ZodType && + value.isOptional(); const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; return makeInterfaceProp(key, next(value), { @@ -211,9 +213,9 @@ const onFile: Producer = (schema: FileSchema) => { const stringType = ensureTypeNode(ts.SyntaxKind.StringKeyword); const bufferType = ensureTypeNode("Buffer"); const unionType = f.createUnionTypeNode([stringType, bufferType]); - return schema instanceof z.ZodString + return schema._zod.def.type === "string" ? stringType - : schema instanceof z.ZodUnion + : schema._zod.def.type === "union" ? unionType : bufferType; }; From c825246db0d6292206b2ded3dbf393caa7044f6c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 25 Apr 2025 23:46:20 +0200 Subject: [PATCH 050/187] Replacing `instanceof` in traverse (#2580) Instead of #2574 might be concluding one --- express-zod-api/src/common-helpers.ts | 27 +++++++-- express-zod-api/src/documentation-helpers.ts | 63 ++++++++++---------- express-zod-api/src/zts.ts | 48 +++++++-------- 3 files changed, 74 insertions(+), 64 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 9e4d226d5..1a2e2989d 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,4 +1,4 @@ -import type { $ZodType } from "@zod/core"; +import type { $ZodObject, $ZodTransform, $ZodType } from "@zod/core"; import { Request } from "express"; import * as R from "ramda"; import { globalRegistry, z } from "zod"; @@ -85,9 +85,15 @@ export const getMessageFromError = (error: Error): string => { return error.message; }; +/** Faster replacement to instanceof for code operating core types (traversing schemas) */ +export const isSchema = ( + subject: $ZodType, + type: T["_zod"]["def"]["type"], +): subject is T => subject._zod.def.type === type; + /** Takes the original unvalidated examples from the properties of ZodObject schema shape */ -export const pullExampleProps = (subject: T) => - Object.entries(subject.shape).reduce>[]>( +export const pullExampleProps = (subject: T) => + Object.entries(subject._zod.def.shape).reduce>[]>( (acc, [key, schema]) => { const { examples = [] } = globalRegistry.get(schema)?.[metaSymbol] || {}; return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ @@ -127,7 +133,7 @@ export const getExamples = < pullProps?: boolean; }): ReadonlyArray : z.input> => { let examples = globalRegistry.get(schema)?.[metaSymbol]?.examples || []; - if (!examples.length && pullProps && schema instanceof z.ZodObject) + if (!examples.length && pullProps && isSchema<$ZodObject>(schema, "object")) examples = pullExampleProps(schema); if (!validate && variant === "original") return examples; const result: Array | z.output> = []; @@ -159,11 +165,20 @@ export const makeCleanId = (...args: string[]) => { }; export const getTransformedType = R.tryCatch( - (schema: z.ZodTransform, sample: T) => - typeof schema.parse(sample), + (schema: $ZodTransform, sample: T) => + typeof z.parse(schema, sample), R.always(undefined), ); +/** @link https://github.com/colinhacks/zod/issues/4159 */ +export const doesAccept = R.tryCatch( + (schema: $ZodType, value: undefined | null) => { + z.parse(schema, value); + return true; + }, + R.always(false), +); + /** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */ export const isObject = (subject: unknown) => typeof subject === "object" && subject !== null; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 33a959223..3b72f3716 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,4 +1,10 @@ -import type { $ZodPipe, $ZodTuple, $ZodType, JSONSchema } from "@zod/core"; +import type { + $ZodPipe, + $ZodTransform, + $ZodTuple, + $ZodType, + JSONSchema, +} from "@zod/core"; import { ExamplesObject, isReferenceObject, @@ -20,10 +26,12 @@ import { globalRegistry, z } from "zod"; import { ResponseVariant } from "./api-response"; import { combinations, + doesAccept, FlatObject, getExamples, getRoutePathParams, getTransformedType, + isSchema, makeCleanId, routePathParamsRegex, Tag, @@ -263,28 +271,24 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { const opposite = (zodSchema as $ZodPipe)._zod.def[ ctx.isResponse ? "in" : "out" ]; - if (target instanceof z.ZodTransform) { - const opposingDepiction = depict(opposite, { ctx }); - if (isSchemaObject(opposingDepiction)) { - if (!ctx.isResponse) { - const { type: opposingType, ...rest } = opposingDepiction; + if (!isSchema<$ZodTransform>(target, "transform")) return; + const opposingDepiction = depict(opposite, { ctx }); + if (isSchemaObject(opposingDepiction)) { + if (!ctx.isResponse) { + const { type: opposingType, ...rest } = opposingDepiction; + Object.assign(jsonSchema, { + ...rest, + format: `${rest.format || opposingType} (preprocessed)`, + }); + } else { + const targetType = getTransformedType( + target, + makeSample(opposingDepiction), + ); + if (targetType && ["number", "string", "boolean"].includes(targetType)) { Object.assign(jsonSchema, { - ...rest, - format: `${rest.format || opposingType} (preprocessed)`, + type: targetType as "number" | "string" | "boolean", }); - } else { - const targetType = getTransformedType( - target, - makeSample(opposingDepiction), - ); - if ( - targetType && - ["number", "string", "boolean"].includes(targetType) - ) { - Object.assign(jsonSchema, { - type: targetType as "number" | "string" | "boolean", - }); - } } } } @@ -404,9 +408,7 @@ export const depictRequestParams = ({ name, in: location, deprecated: globalRegistry.get(paramSchema)?.deprecated, - required: !( - paramSchema instanceof z.ZodType && paramSchema.isOptional() - ), + required: !doesAccept(paramSchema, undefined), description: depicted.description || description, schema: result, examples: depictParamExamples(objectSchema, name), @@ -433,16 +435,11 @@ const overrides: Partial> = }; const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { - const shouldAvoidParsing = - zodSchema._zod.def.type === "lazy" || zodSchema._zod.def.type === "promise"; - const hasTypePropertyInDepiction = jsonSchema.type !== undefined; - const acceptsNull = + if ( !isResponse && - !shouldAvoidParsing && - hasTypePropertyInDepiction && - zodSchema instanceof z.ZodType && - zodSchema.isNullable(); - if (acceptsNull) + jsonSchema.type !== undefined && + doesAccept(zodSchema, null) + ) Object.assign(jsonSchema, { type: makeNullableType(jsonSchema) }); const examples = getExamples({ schema: zodSchema, diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 7c2ec80e2..0a1a1e40d 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -14,13 +14,15 @@ import type { $ZodPipe, $ZodReadonly, $ZodRecord, + $ZodString, + $ZodTransform, $ZodTuple, $ZodUnion, } from "@zod/core"; import * as R from "ramda"; import ts from "typescript"; import { globalRegistry, z } from "zod"; -import { getTransformedType } from "./common-helpers"; +import { doesAccept, getTransformedType, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { ezFileBrand, FileSchema } from "./file-schema"; @@ -92,10 +94,8 @@ const onObject: Producer = ( const members = Object.entries(def.shape).map( ([key, value]) => { const isOptional = isResponse - ? value._zod.def.type === "optional" - : value._zod.def.type !== "promise" && - value instanceof z.ZodType && - value.isOptional(); + ? isSchema<$ZodOptional>(value, "optional") + : doesAccept(value, undefined); const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; return makeInterfaceProp(key, next(value), { @@ -184,24 +184,22 @@ const onPipeline: Producer = ( ) => { const target = def[isResponse ? "out" : "in"]; const opposite = def[isResponse ? "in" : "out"]; - if (target instanceof z.ZodTransform) { - const opposingType = next(opposite); - const targetType = getTransformedType(target, makeSample(opposingType)); - const resolutions: Partial< - Record, ts.KeywordTypeSyntaxKind> - > = { - number: ts.SyntaxKind.NumberKeyword, - bigint: ts.SyntaxKind.BigIntKeyword, - boolean: ts.SyntaxKind.BooleanKeyword, - string: ts.SyntaxKind.StringKeyword, - undefined: ts.SyntaxKind.UndefinedKeyword, - object: ts.SyntaxKind.ObjectKeyword, - }; - return ensureTypeNode( - (targetType && resolutions[targetType]) || ts.SyntaxKind.AnyKeyword, - ); - } - return next(target); + if (!isSchema<$ZodTransform>(target, "transform")) return next(target); + const opposingType = next(opposite); + const targetType = getTransformedType(target, makeSample(opposingType)); + const resolutions: Partial< + Record, ts.KeywordTypeSyntaxKind> + > = { + number: ts.SyntaxKind.NumberKeyword, + bigint: ts.SyntaxKind.BigIntKeyword, + boolean: ts.SyntaxKind.BooleanKeyword, + string: ts.SyntaxKind.StringKeyword, + undefined: ts.SyntaxKind.UndefinedKeyword, + object: ts.SyntaxKind.ObjectKeyword, + }; + return ensureTypeNode( + (targetType && resolutions[targetType]) || ts.SyntaxKind.AnyKeyword, + ); }; const onNull: Producer = () => makeLiteralType(null); @@ -213,9 +211,9 @@ const onFile: Producer = (schema: FileSchema) => { const stringType = ensureTypeNode(ts.SyntaxKind.StringKeyword); const bufferType = ensureTypeNode("Buffer"); const unionType = f.createUnionTypeNode([stringType, bufferType]); - return schema._zod.def.type === "string" + return isSchema<$ZodString>(schema, "string") ? stringType - : schema._zod.def.type === "union" + : isSchema<$ZodUnion>(schema, "union") ? unionType : bufferType; }; From ab4ec0dd9da99b38ae2e7563e7d2562f733cc02b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 26 Apr 2025 08:17:22 +0200 Subject: [PATCH 051/187] Implementing `unref()` helper (#2581) https://github.com/colinhacks/zod/issues/4275 Extracting that into a helper. --- express-zod-api/src/documentation-helpers.ts | 26 ++++++++++++++----- .../tests/documentation-helpers.spec.ts | 8 +++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 3b72f3716..9cff6793b 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -167,7 +167,7 @@ const intersect = ( children: Array, ): JSONSchema.ObjectSchema => { const [left, right] = children - .map(({ _ref, ...rest }) => (_ref ? { ...rest, ..._ref } : rest)) + .map(unref) .filter( (schema): schema is JSONSchema.ObjectSchema => schema.type === "object", ) @@ -204,7 +204,8 @@ const isSupportedType = (subject: string): subject is SchemaObjectType => export const onDateIn: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); - delete jsonSchema._ref; // undo default + unref(jsonSchema); + delete jsonSchema.anyOf; // undo default Object.assign(jsonSchema, { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", @@ -295,13 +296,14 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { }; export const onRaw: Overrider = ({ jsonSchema }) => { - if (!jsonSchema._ref) return; - if (jsonSchema._ref.type !== "object") return; - const objSchema = jsonSchema._ref as JSONSchema.ObjectSchema; + unref(jsonSchema); + if (jsonSchema.type !== "object") return; + const objSchema = jsonSchema as JSONSchema.ObjectSchema; if (!objSchema.properties) return; if (!("raw" in objSchema.properties)) return; - delete jsonSchema._ref; // undo Object.assign(jsonSchema, objSchema.properties.raw); + delete jsonSchema.properties; // undo default + delete jsonSchema.required; }; const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => @@ -476,6 +478,18 @@ const fixReferences = ( return subject as SchemaObject; // @todo ideally, there should be a method to ensure that }; +/** @link https://github.com/colinhacks/zod/issues/4275 */ +const unref = ( + subject: JSONSchema.BaseSchema, +): Omit => { + while (subject._ref) { + const copy = { ...subject._ref }; + delete subject._ref; + Object.assign(subject, copy); + } + return subject; +}; + const depict = ( subject: $ZodType, { ctx, rules = overrides }: { ctx: OpenAPIContext; rules?: BrandHandling }, diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index ea7180597..1f42c0c63 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -135,10 +135,8 @@ describe("Documentation helpers", () => { describe("onRaw()", () => { test("should extract the raw property", () => { const jsonSchema: JSONSchema.BaseSchema = { - _ref: { - type: "object", - properties: { raw: { format: "binary", type: "string" } }, - }, + type: "object", + properties: { raw: { format: "binary", type: "string" } }, }; onRaw({ zodSchema: z.never(), jsonSchema }, requestCtx); expect(jsonSchema).toMatchSnapshot(); @@ -496,7 +494,7 @@ describe("Documentation helpers", () => { describe("onDateIn", () => { test("should set type:string, pattern and format", () => { - const jsonSchema: JSONSchema.BaseSchema = { _ref: { anyOf: [] } }; + const jsonSchema: JSONSchema.BaseSchema = { anyOf: [] }; onDateIn({ zodSchema: z.never(), jsonSchema }, requestCtx); expect(jsonSchema).toMatchSnapshot(); }); From 3829416fc988c0c238361f387ca8c7cdd1af314f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 26 Apr 2025 19:06:14 +0200 Subject: [PATCH 052/187] Schema compliance adjustment (#2582) Validating the the result is OpenAPI compliant. Addressing todo on ensuring the depiction complies to OpenAPI SchemaObejct --- example/example.documentation.yaml | 20 +++--- express-zod-api/src/documentation-helpers.ts | 63 +++++++++++++------ .../__snapshots__/documentation.spec.ts.snap | 50 +++++++-------- 3 files changed, 78 insertions(+), 55 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index c7eeb03d8..612d9e264 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -16,8 +16,8 @@ paths: required: true description: a numeric string containing the id of the user schema: - description: a numeric string containing the id of the user type: string + description: a numeric string containing the id of the user format: regex pattern: \d+ responses: @@ -85,8 +85,8 @@ paths: required: true description: numeric string schema: - description: numeric string type: string + description: numeric string format: regex pattern: \d+ responses: @@ -107,10 +107,10 @@ paths: required: true description: PATCH /v1/user/:id Parameter schema: - examples: - - "1234567890" type: string minLength: 1 + examples: + - "1234567890" examples: example1: value: "1234567890" @@ -119,9 +119,9 @@ paths: required: true description: PATCH /v1/user/:id Parameter schema: + type: string examples: - "12" - type: string examples: example1: value: "12" @@ -133,15 +133,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 @@ -179,9 +179,9 @@ paths: type: object properties: name: + type: string examples: - John Doe - type: string createdAt: description: YYYY-MM-DDTHH:mm:ss.sssZ type: string @@ -578,9 +578,9 @@ paths: required: false description: for testing error response schema: + type: string deprecated: true description: for testing error response - type: string responses: "200": description: GET /v1/events/stream Positive response diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 9cff6793b..150b55bd8 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -193,7 +193,7 @@ export const onIntersection: Overrider = ({ jsonSchema }) => { export const onNullable: Overrider = ({ jsonSchema }) => { if (!jsonSchema.anyOf) return; const original = jsonSchema.anyOf[0]; - Object.assign(original, { type: makeNullableType(original) }); + Object.assign(original, { type: makeNullableType(original.type) }); Object.assign(jsonSchema, original); delete jsonSchema.anyOf; }; @@ -201,10 +201,34 @@ export const onNullable: Overrider = ({ jsonSchema }) => { const isSupportedType = (subject: string): subject is SchemaObjectType => subject in samples; +const ensureCompliance = ({ + $ref, + type, + allOf, + oneOf, + anyOf, + not, + ...rest +}: JSONSchema.BaseSchema): SchemaObject | ReferenceObject => { + if ($ref) return { $ref }; + const valid: SchemaObject = { + type: Array.isArray(type) + ? type.filter(isSupportedType) + : type && isSupportedType(type) + ? type + : undefined, + ...rest, + }; + if (allOf) valid.allOf = allOf.map(ensureCompliance); + if (oneOf) valid.oneOf = oneOf.map(ensureCompliance); + if (anyOf) valid.anyOf = anyOf.map(ensureCompliance); + if (not) valid.not = ensureCompliance(not); + return valid; +}; + export const onDateIn: Overrider = ({ jsonSchema }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); - unref(jsonSchema); delete jsonSchema.anyOf; // undo default Object.assign(jsonSchema, { description: "YYYY-MM-DDTHH:mm:ss.sssZ", @@ -254,15 +278,18 @@ const makeSample = (depicted: SchemaObject) => { return samples?.[firstType]; }; -const makeNullableType = ({ - type, -}: JSONSchema.BaseSchema | SchemaObject): - | SchemaObjectType - | SchemaObjectType[] => { - if (type === "null") return type; - if (typeof type === "string") - return isSupportedType(type) ? [type, "null"] : "null"; - return type ? [...new Set(type).add("null")] : "null"; +/** @since v24.0.0 does not return null for undefined */ +const makeNullableType = ( + current: + | JSONSchema.BaseSchema["type"] + | Array>, +): typeof current => { + if (current === ("null" satisfies SchemaObjectType)) return current; + if (typeof current === "string") + return [current, "null" satisfies SchemaObjectType]; + return ( + current && [...new Set(current).add("null" satisfies SchemaObjectType)] + ); }; export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { @@ -296,7 +323,6 @@ export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { }; export const onRaw: Overrider = ({ jsonSchema }) => { - unref(jsonSchema); if (jsonSchema.type !== "object") return; const objSchema = jsonSchema as JSONSchema.ObjectSchema; if (!objSchema.properties) return; @@ -437,12 +463,8 @@ const overrides: Partial> = }; const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { - if ( - !isResponse && - jsonSchema.type !== undefined && - doesAccept(zodSchema, null) - ) - Object.assign(jsonSchema, { type: makeNullableType(jsonSchema) }); + if (!isResponse && doesAccept(zodSchema, null)) + Object.assign(jsonSchema, { type: makeNullableType(jsonSchema.type) }); const examples = getExamples({ schema: zodSchema, variant: isResponse ? "parsed" : "original", @@ -468,14 +490,14 @@ const fixReferences = ( const actualName = entry.$ref.split("/").pop()!; const depiction = defs[actualName]; if (depiction) - entry.$ref = ctx.makeRef(depiction, depiction as SchemaObject).$ref; // @todo see below + entry.$ref = ctx.makeRef(depiction, ensureCompliance(depiction)).$ref; continue; } stack.push(...R.values(entry)); } if (R.is(Array, entry)) stack.push(...R.values(entry)); } - return subject as SchemaObject; // @todo ideally, there should be a method to ensure that + return ensureCompliance(subject); }; /** @link https://github.com/colinhacks/zod/issues/4275 */ @@ -500,6 +522,7 @@ const depict = ( unrepresentable: "any", io: ctx.isResponse ? "output" : "input", override: (zodCtx) => { + unref(zodCtx.jsonSchema); const { brand } = globalRegistry.get(zodCtx.zodSchema)?.[metaSymbol] ?? {}; rules[ diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index b60546165..ad2c1d40d 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -610,9 +610,9 @@ paths: required: true description: GET /v1/getSomething Parameter schema: + type: array minItems: 1 maxItems: 3 - type: array items: type: integer exclusiveMaximum: 9007199254740991 @@ -708,6 +708,8 @@ paths: content: application/json: schema: + discriminator: + propertyName: type anyOf: - type: object properties: @@ -727,8 +729,6 @@ paths: required: - type - b - discriminator: - propertyName: type required: true responses: "200": @@ -930,8 +930,8 @@ paths: required: false description: GET /v1/getSomething Parameter schema: - default: test type: string + default: test - name: nullish in: query required: false @@ -945,11 +945,11 @@ paths: required: false description: GET /v1/getSomething Parameter schema: - default: 123 type: - integer - "null" exclusiveMaximum: 9007199254740991 + default: 123 responses: "200": description: GET /v1/getSomething Positive response @@ -2307,15 +2307,15 @@ paths: required: true description: GET /v1/:name Parameter schema: - summary: My custom schema type: string + summary: My custom schema - name: other in: query required: true description: GET /v1/:name Parameter schema: - summary: My custom schema type: boolean + summary: My custom schema - name: regular in: query required: true @@ -2336,8 +2336,8 @@ paths: type: object properties: number: - summary: My custom schema type: number + summary: My custom schema required: - number required: @@ -2405,8 +2405,8 @@ paths: required: true description: YYYY-MM-DDTHH:mm:ss.sssZ schema: - description: YYYY-MM-DDTHH:mm:ss.sssZ type: string + description: YYYY-MM-DDTHH:mm:ss.sssZ format: date-time pattern: ^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?)?Z?$ externalDocs: @@ -2818,8 +2818,8 @@ paths: required: true description: GET /v1/getSomething Parameter schema: - minItems: 1 type: array + minItems: 1 items: type: string responses: @@ -3076,8 +3076,8 @@ paths: required: true description: GET /v1/getSomething Parameter schema: - deprecated: true type: string + deprecated: true responses: "200": description: GET /v1/getSomething Positive response @@ -3172,11 +3172,6 @@ paths: status: const: success data: - examples: - - a: first - b: prefix_first - - a: second - b: prefix_second type: object properties: a: @@ -3186,6 +3181,11 @@ paths: required: - a - b + examples: + - a: first + b: prefix_first + - a: second + b: prefix_second required: - status - data @@ -3282,9 +3282,9 @@ paths: components: schemas: GetHrisEmployeesParameterCursor: + type: string description: An optional cursor string used for pagination. This can be retrieved from the \`next\` property of the previous page response. - type: string GetHrisEmployeesPositiveResponse: type: object properties: @@ -3361,14 +3361,14 @@ paths: status: const: success data: - examples: - - num: 123 type: object properties: num: type: number required: - num + examples: + - num: 123 required: - status - data @@ -3449,14 +3449,14 @@ paths: status: const: success data: - examples: - - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr + examples: + - numericStr: "123" required: - status - data @@ -3543,14 +3543,14 @@ paths: status: const: success data: - examples: - - numericStr: "123" type: object properties: numericStr: type: string required: - numericStr + examples: + - numericStr: "123" required: - status - data @@ -3616,9 +3616,9 @@ paths: required: true description: GET /v1/getSomething Parameter schema: + type: string examples: - "123" - type: string examples: example1: value: "123" @@ -3706,8 +3706,8 @@ paths: required: true description: here is the test schema: - description: here is the test type: string + description: here is the test responses: "200": description: GET /v1/getSomething Positive response From 154627f170f062a9d3b78d201a1a8fc602104515 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 27 Apr 2025 08:56:28 +0200 Subject: [PATCH 053/187] Restoring immutable depicters (#2583) Delegating prop replacement to the framework, so that public interface for custom brands handling could remain similar and more convenient. --- CHANGELOG.md | 2 +- README.md | 16 +- express-zod-api/src/documentation-helpers.ts | 157 +++++++++--------- express-zod-api/src/index.ts | 2 +- .../tests/documentation-helpers.spec.ts | 107 ++++++------ express-zod-api/tests/documentation.spec.ts | 12 +- express-zod-api/tests/index.spec.ts | 11 +- 7 files changed, 167 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c04cf92f..2a74aa20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - 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; - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - - The `Depicter` type changed to `Overrider` having different signature; + - The `Depicter` type signature changed; - The `optionalPropStyle` option removed from `Integration` class constructor: - Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface; - Changes to the plugin: diff --git a/README.md b/README.md index 22308a68c..6d5541949 100644 --- a/README.md +++ b/README.md @@ -1387,7 +1387,7 @@ const routing: Routing = { You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your schema to make it special and distinguishable for the framework in runtime. Using symbols is recommended for branding. After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. In case you -need to reuse a handling rule for multiple brands, use the exposed types `Overrider` and `Producer`. +need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`. ```ts import ts from "typescript"; @@ -1395,7 +1395,7 @@ import { z } from "zod"; import { Documentation, Integration, - Overrider, + Depicter, Producer, } from "express-zod-api"; @@ -1403,12 +1403,12 @@ const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for const myBrandedSchema = z.string().brand(myBrand); const ruleForDocs: Overrider = ( - { zodSchema, jsonSchema }, // adjust jsonSchema for overrides - { path, method, isResponse }, // handle a nested schema using next() -) => { - delete jsonSchema.format; - jsonSchema.summary = "Special type of data"; -}; + { zodSchema, jsonSchema }, // return changed jsonSchema + { path, method, isResponse }, +) => ({ + ...jsonSchema, + summary: "Special type of data", +}); const ruleForClient: Producer = ( schema: typeof myBrandedSchema, // you should assign type yourself diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 150b55bd8..3ec7019d1 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -65,10 +65,10 @@ export interface OpenAPIContext { method: Method; } -export type Overrider = ( +export type Depicter = ( zodCtx: { zodSchema: $ZodType; jsonSchema: JSONSchema.BaseSchema }, oasCtx: OpenAPIContext, -) => void; +) => JSONSchema.BaseSchema | SchemaObject; /** @desc Using defaultIsHeader when returns null or undefined */ export type IsHeader = ( @@ -77,7 +77,7 @@ export type IsHeader = ( path: string, ) => boolean | null | undefined; -export type BrandHandling = Record; +export type BrandHandling = Record; interface ReqResHandlingProps extends Omit { @@ -104,34 +104,36 @@ const samples = { export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const onDefault: Overrider = ({ zodSchema, jsonSchema }) => - (jsonSchema.default = +export const onDefault: Depicter = ({ zodSchema, jsonSchema }) => ({ + ...jsonSchema, + default: globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? - jsonSchema.default); + jsonSchema.default, +}); -export const onUpload: Overrider = ({ jsonSchema }, ctx) => { +export const onUpload: Depicter = ({}, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.upload() only for input.", ctx); - Object.assign(jsonSchema, { type: "string", format: "binary" }); + return { type: "string", format: "binary" }; }; -export const onFile: Overrider = ({ jsonSchema }) => { - delete jsonSchema.anyOf; // undo default - Object.assign(jsonSchema, { - type: "string", - format: - jsonSchema.type === "string" - ? jsonSchema.format === "base64" - ? "byte" - : "file" - : "binary", - }); -}; +export const onFile: Depicter = ({ jsonSchema }) => ({ + type: "string", + format: + jsonSchema.type === "string" + ? jsonSchema.format === "base64" + ? "byte" + : "file" + : "binary", +}); -export const onUnion: Overrider = ({ zodSchema, jsonSchema }) => { - if (!zodSchema._zod.disc) return; +export const onUnion: Depicter = ({ zodSchema, jsonSchema }) => { + if (!zodSchema._zod.disc) return jsonSchema; const propertyName = Array.from(zodSchema._zod.disc.keys()).pop(); - jsonSchema.discriminator ??= { propertyName }; + return { + ...jsonSchema, + discriminator: jsonSchema.discriminator ?? { propertyName }, + }; }; const propsMerger = (a: unknown, b: unknown) => { @@ -180,22 +182,20 @@ const intersect = ( return R.map((fn) => fn(left, right), suitable); }; -export const onIntersection: Overrider = ({ jsonSchema }) => { - if (!jsonSchema.allOf) return; - try { - const attempt = intersect(jsonSchema.allOf); - delete jsonSchema.allOf; // undo default - Object.assign(jsonSchema, attempt); - } catch {} +export const onIntersection: Depicter = ({ jsonSchema }) => { + if (jsonSchema.allOf) { + try { + return intersect(jsonSchema.allOf); + } catch {} + } + return jsonSchema; }; /** @since OAS 3.1 nullable replaced with type array having null */ -export const onNullable: Overrider = ({ jsonSchema }) => { - if (!jsonSchema.anyOf) return; +export const onNullable: Depicter = ({ jsonSchema }) => { + if (!jsonSchema.anyOf) return jsonSchema; const original = jsonSchema.anyOf[0]; - Object.assign(original, { type: makeNullableType(original.type) }); - Object.assign(jsonSchema, original); - delete jsonSchema.anyOf; + return Object.assign(original, { type: makeNullableType(original.type) }); }; const isSupportedType = (subject: string): subject is SchemaObjectType => @@ -226,11 +226,10 @@ const ensureCompliance = ({ return valid; }; -export const onDateIn: Overrider = ({ jsonSchema }, ctx) => { +export const onDateIn: Depicter = ({}, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); - delete jsonSchema.anyOf; // undo default - Object.assign(jsonSchema, { + return { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", @@ -238,37 +237,36 @@ export const onDateIn: Overrider = ({ jsonSchema }, ctx) => { externalDocs: { url: isoDateDocumentationUrl, }, - }); + }; }; -export const onDateOut: Overrider = ({ jsonSchema }, ctx) => { +export const onDateOut: Depicter = ({}, ctx) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); - Object.assign(jsonSchema, { + return { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", externalDocs: { url: isoDateDocumentationUrl, }, - }); + }; }; -export const onBigInt: Overrider = ({ jsonSchema }) => - Object.assign(jsonSchema, { - type: "string", - format: "bigint", - pattern: /^-?\d+$/.source, - }); +export const onBigInt: Depicter = () => ({ + type: "string", + format: "bigint", + pattern: /^-?\d+$/.source, +}); /** * @since OAS 3.1 using prefixItems for depicting tuples * @since 17.5.0 added rest handling, fixed tuple type */ -export const onTuple: Overrider = ({ zodSchema, jsonSchema }) => { - if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return; +export const onTuple: Depicter = ({ zodSchema, jsonSchema }) => { + if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return jsonSchema; // does not appear to support items:false, so not:{} is a recommended alias - jsonSchema.items = { not: {} }; + return { ...jsonSchema, items: { not: {} } }; }; const makeSample = (depicted: SchemaObject) => { @@ -292,44 +290,43 @@ const makeNullableType = ( ); }; -export const onPipeline: Overrider = ({ zodSchema, jsonSchema }, ctx) => { +export const onPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { const target = (zodSchema as $ZodPipe)._zod.def[ ctx.isResponse ? "out" : "in" ]; const opposite = (zodSchema as $ZodPipe)._zod.def[ ctx.isResponse ? "in" : "out" ]; - if (!isSchema<$ZodTransform>(target, "transform")) return; + if (!isSchema<$ZodTransform>(target, "transform")) return jsonSchema; const opposingDepiction = depict(opposite, { ctx }); if (isSchemaObject(opposingDepiction)) { if (!ctx.isResponse) { const { type: opposingType, ...rest } = opposingDepiction; - Object.assign(jsonSchema, { + return { ...rest, format: `${rest.format || opposingType} (preprocessed)`, - }); + }; } else { const targetType = getTransformedType( target, makeSample(opposingDepiction), ); if (targetType && ["number", "string", "boolean"].includes(targetType)) { - Object.assign(jsonSchema, { + return { type: targetType as "number" | "string" | "boolean", - }); + }; } } } + return jsonSchema; }; -export const onRaw: Overrider = ({ jsonSchema }) => { - if (jsonSchema.type !== "object") return; +export const onRaw: Depicter = ({ jsonSchema }) => { + if (jsonSchema.type !== "object") return jsonSchema; const objSchema = jsonSchema as JSONSchema.ObjectSchema; - if (!objSchema.properties) return; - if (!("raw" in objSchema.properties)) return; - Object.assign(jsonSchema, objSchema.properties.raw); - delete jsonSchema.properties; // undo default - delete jsonSchema.required; + if (!objSchema.properties) return jsonSchema; + if (!("raw" in objSchema.properties)) return jsonSchema; + return objSchema.properties.raw; }; const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => @@ -425,7 +422,7 @@ export const depictRequestParams = ({ : undefined; if (!location) return acc; const depicted = depict(paramSchema, { - rules: { ...brandHandling, ...overrides }, + rules: { ...brandHandling, ...depicters }, ctx: { isResponse: false, makeRef, path, method }, }); const result = @@ -446,7 +443,7 @@ export const depictRequestParams = ({ ); }; -const overrides: Partial> = +const depicters: Partial> = { nullable: onNullable, default: onDefault, @@ -462,15 +459,17 @@ const overrides: Partial> = [ezRawBrand]: onRaw, }; -const onEach: Overrider = ({ zodSchema, jsonSchema }, { isResponse }) => { +const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { + const result = { ...jsonSchema }; if (!isResponse && doesAccept(zodSchema, null)) - Object.assign(jsonSchema, { type: makeNullableType(jsonSchema.type) }); + Object.assign(result, { type: makeNullableType(jsonSchema.type) }); const examples = getExamples({ schema: zodSchema, variant: isResponse ? "parsed" : "original", validate: true, }); - if (examples.length) jsonSchema.examples = examples.slice(); + if (examples.length) result.examples = examples.slice(); + return result; }; /** @@ -514,7 +513,7 @@ const unref = ( const depict = ( subject: $ZodType, - { ctx, rules = overrides }: { ctx: OpenAPIContext; rules?: BrandHandling }, + { ctx, rules = depicters }: { ctx: OpenAPIContext; rules?: BrandHandling }, ) => { const { $defs = {}, properties = {} } = z.toJSONSchema( z.object({ subject }), // avoiding "document root" references @@ -525,10 +524,16 @@ const depict = ( unref(zodCtx.jsonSchema); const { brand } = globalRegistry.get(zodCtx.zodSchema)?.[metaSymbol] ?? {}; - rules[ - brand && brand in rules ? brand : zodCtx.zodSchema._zod.def.type - ]?.(zodCtx, ctx); - onEach(zodCtx, ctx); + const depicter = + rules[ + brand && brand in rules ? brand : zodCtx.zodSchema._zod.def.type + ]; + if (depicter) { + const overrides = { ...depicter(zodCtx, ctx) }; + 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; @@ -587,7 +592,7 @@ export const depictResponse = ({ if (!mimeTypes) return { description }; const depictedSchema = excludeExamplesFromDepiction( depict(schema, { - rules: { ...brandHandling, ...overrides }, + rules: { ...brandHandling, ...depicters }, ctx: { isResponse: true, makeRef, path, method }, }), ); @@ -709,7 +714,7 @@ export const depictBody = ({ }) => { const [withoutParams, hasRequired] = excludeParamsFromDepiction( depict(schema, { - rules: { ...brandHandling, ...overrides }, + rules: { ...brandHandling, ...depicters }, ctx: { isResponse: false, makeRef, path, method }, }), paramNames, diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index d76fc5036..76db03494 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -33,7 +33,7 @@ export { EventStreamFactory } from "./sse"; export { ez } from "./proprietary-schemas"; // Convenience types -export type { Overrider } from "./documentation-helpers"; +export type { Depicter } from "./documentation-helpers"; export type { Producer } from "./zts-helpers"; // Interfaces exposed for augmentation diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 1f42c0c63..2127726e8 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -127,8 +127,9 @@ describe("Documentation helpers", () => { default: "2025-05-21", format: "date-time", }; - onDefault({ zodSchema, jsonSchema }, responseCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onDefault({ zodSchema, jsonSchema }, responseCtx), + ).toMatchSnapshot(); }); }); @@ -138,21 +139,21 @@ describe("Documentation helpers", () => { type: "object", properties: { raw: { format: "binary", type: "string" } }, }; - onRaw({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onRaw({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); }); describe("onUpload()", () => { - const jsonSchema: JSONSchema.BaseSchema = {}; - const zodSchema = z.never(); test("should set format:binary and type:string", () => { - onUpload({ zodSchema, jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onUpload({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), + ).toMatchSnapshot(); }); test("should throw when using in response", () => { expect(() => - onUpload({ zodSchema, jsonSchema }, responseCtx), + onUpload({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); @@ -165,8 +166,9 @@ describe("Documentation helpers", () => { { anyOf: [], type: "string" }, {}, ])("should set type:string and format accordingly %#", (jsonSchema) => { - onFile({ zodSchema: z.never(), jsonSchema }, responseCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onFile({ zodSchema: z.never(), jsonSchema }, responseCtx), + ).toMatchSnapshot(); }); }); @@ -179,9 +181,9 @@ describe("Documentation helpers", () => { error: z.object({ message: z.string() }), }), ]); - const jsonSchema: JSONSchema.BaseSchema = {}; - onUnion({ zodSchema, jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onUnion({ zodSchema, jsonSchema: {} }, requestCtx), + ).toMatchSnapshot(); }); }); @@ -197,8 +199,9 @@ describe("Documentation helpers", () => { { type: "object", properties: { two: { type: "number" } } }, ], }; - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); test("should flatten objects with same prop of same type", () => { @@ -208,8 +211,9 @@ describe("Documentation helpers", () => { { type: "object", properties: { one: { type: "number" } } }, ], }; - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); test("should NOT flatten object schemas having conflicting props", () => { @@ -219,8 +223,9 @@ describe("Documentation helpers", () => { { type: "object", properties: { one: { type: "string" } } }, ], }; - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); test("should merge examples deeply", () => { @@ -238,8 +243,9 @@ describe("Documentation helpers", () => { }, ], }; - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); test("should maintain uniqueness in the array of required props", () => { @@ -249,8 +255,9 @@ describe("Documentation helpers", () => { { type: "object", properties: { test: { const: 5 } } }, ], }; - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); test.each([ @@ -272,8 +279,9 @@ describe("Documentation helpers", () => { allOf: [{ type: "number" }, { const: 5 }], // not objects }, ])("should fall back to allOf in other cases %#", (jsonSchema) => { - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toHaveProperty("allOf"); + expect( + onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toHaveProperty("allOf"); }); }); @@ -284,8 +292,9 @@ describe("Documentation helpers", () => { const jsonSchema: JSONSchema.BaseSchema = { anyOf: [{ type: "string" }, { type: "null" }], }; - onNullable({ zodSchema: z.never(), jsonSchema }, ctx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onNullable({ zodSchema: z.never(), jsonSchema }, ctx), + ).toMatchSnapshot(); }, ); @@ -293,16 +302,17 @@ describe("Documentation helpers", () => { const jsonSchema: JSONSchema.BaseSchema = { anyOf: [{ type: "null" }, { type: "null" }], }; - onNullable({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onNullable({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); }); }); describe("onBigInt()", () => { test("should set type:string and format:bigint", () => { - const jsonSchema: JSONSchema.BaseSchema = {}; - onBigInt({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onBigInt({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), + ).toMatchSnapshot(); }); }); @@ -311,9 +321,9 @@ describe("Documentation helpers", () => { z.tuple([z.boolean(), z.string(), z.literal("test")]), z.tuple([]), ])("should add items:not:{} when no rest %#", (zodSchema) => { - const jsonSchema: JSONSchema.BaseSchema = {}; - onTuple({ zodSchema, jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onTuple({ zodSchema, jsonSchema: {} }, requestCtx), + ).toMatchSnapshot(); }); }); @@ -330,18 +340,16 @@ describe("Documentation helpers", () => { expected: "string (preprocess)", }, ])("should depict as $expected", ({ zodSchema, ctx }) => { - const jsonSchema: JSONSchema.BaseSchema = {}; - onPipeline({ zodSchema, jsonSchema }, ctx); - expect(jsonSchema).toMatchSnapshot(); + expect(onPipeline({ zodSchema, jsonSchema: {} }, ctx)).toMatchSnapshot(); }); test.each([ z.number().transform((num) => () => num), z.number().transform(() => assert.fail("this should be handled")), ])("should handle edge cases %#", (zodSchema) => { - const jsonSchema: JSONSchema.BaseSchema = {}; - onPipeline({ zodSchema, jsonSchema }, responseCtx); - expect(jsonSchema).toEqual({}); + expect(onPipeline({ zodSchema, jsonSchema: {} }, responseCtx)).toEqual( + {}, + ); }); }); @@ -494,9 +502,12 @@ describe("Documentation helpers", () => { describe("onDateIn", () => { test("should set type:string, pattern and format", () => { - const jsonSchema: JSONSchema.BaseSchema = { anyOf: [] }; - onDateIn({ zodSchema: z.never(), jsonSchema }, requestCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onDateIn( + { zodSchema: z.never(), jsonSchema: { anyOf: [] } }, + requestCtx, + ), + ).toMatchSnapshot(); }); test("should throw when ZodDateIn in response", () => { expect(() => @@ -507,9 +518,9 @@ describe("Documentation helpers", () => { describe("onDateOut", () => { test("should set type:string, description and format", () => { - const jsonSchema: JSONSchema.BaseSchema = {}; - onDateOut({ zodSchema: z.never(), jsonSchema }, responseCtx); - expect(jsonSchema).toMatchSnapshot(); + expect( + onDateOut({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), + ).toMatchSnapshot(); }); test("should throw when ZodDateOut in request", () => { expect(() => diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 02176b2d2..37b23a5f1 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -9,7 +9,7 @@ import { defaultEndpointsFactory, ez, ResultHandler, - Overrider, + Depicter, } from "../src"; import { contentTypes } from "../src/content-type"; import { z } from "zod"; @@ -1185,7 +1185,10 @@ describe("Documentation", () => { describe("Feature #1470: Custom brands", () => { test("should be handled accordingly in request, response and params", () => { const deep = Symbol("DEEP"); - const rule: Overrider = ({ jsonSchema }) => (jsonSchema.type = "boolean"); + const rule: Depicter = ({ jsonSchema }) => ({ + ...jsonSchema, + type: "boolean", + }); const spec = new Documentation({ config: sampleConfig, routing: { @@ -1204,7 +1207,10 @@ describe("Documentation", () => { }, }, brandHandling: { - CUSTOM: ({ jsonSchema }) => (jsonSchema.summary = "My custom schema"), + CUSTOM: ({ jsonSchema }) => ({ + ...jsonSchema, + summary: "My custom schema", + }), [deep]: rule, }, version: "3.4.5", diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index 011114f6c..037a309c5 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -12,7 +12,7 @@ import { CommonConfig, CookieSecurity, HeaderSecurity, - Overrider, + Depicter, FlatObject, IOSchema, InputSecurity, @@ -40,8 +40,13 @@ describe("Index Entrypoint", () => { test("Convenience types should be exposed", () => { expectTypeOf( - ({}: { zodSchema: $ZodType; jsonSchema: JSONSchema.BaseSchema }) => {}, - ).toExtend(); + ({ + jsonSchema, + }: { + zodSchema: $ZodType; + jsonSchema: JSONSchema.BaseSchema; + }) => jsonSchema, + ).toExtend(); expectTypeOf(() => ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), ).toExtend(); From 4740d8cbc8453fb6e86139bf4c216a8bdfb2c749 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 27 Apr 2025 09:49:16 +0200 Subject: [PATCH 054/187] Coerce became safer (#2584) https://github.com/colinhacks/zod/issues/1911#issuecomment-2833268828 --- README.md | 13 +------------ express-zod-api/tests/env.spec.ts | 6 ++++++ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6d5541949..98473431b 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,7 @@ Start your API server with I/O schema validation and custom middlewares in minut 5. [Deprecated schemas and routes](#deprecated-schemas-and-routes) 6. [Customizable brands handling](#customizable-brands-handling) 8. [Caveats](#caveats) - 1. [Coercive schema of Zod](#coercive-schema-of-zod) - 2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output) + 1. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output) 9. [Your input to my output](#your-input-to-my-output) You can find the release notes and migration guides in [Changelog](CHANGELOG.md). @@ -1429,16 +1428,6 @@ new Integration({ There are some well-known issues and limitations, or third party bugs that cannot be fixed in the usual way, but you should be aware of them. -## Coercive schema of Zod - -Despite being supported by the framework, `z.coerce.*` schema -[does not work intuitively](https://github.com/RobinTail/express-zod-api/issues/759). -Please be aware that `z.coerce.number()` and `z.number({ coerce: true })` (being typed not well) still will NOT allow -you to assign anything but number. Moreover, coercive schemas are not fail-safe and their methods `.isOptional()` and -`.isNullable()` [are buggy](https://github.com/colinhacks/zod/issues/1911). If possible, try to avoid using this type -of schema. This issue [will NOT be fixed](https://github.com/colinhacks/zod/issues/1760#issuecomment-1407816838) in -Zod version 3.x. - ## Excessive properties in endpoint output The schema validator removes excessive properties by default. However, Typescript diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 51a9718f9..1aa46d4a0 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -96,6 +96,12 @@ describe("Environment checks", () => { expect(Object.keys(schema._zod.def.shape)).toEqual(["one", "two"]); expect(schema._zod.def.optional).toEqual(["two"]); }); + + test("coerce is safe for nullable and optional", () => { + const boolSchema = z.coerce.boolean(); + expect(boolSchema.isOptional()).toBeTruthy(); + expect(boolSchema.isNullable()).toBeTruthy(); + }); }); describe("Vitest error comparison", () => { From 4125ba51fabf0d91adc4e1d460af22a02cfa2a40 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 09:51:35 +0200 Subject: [PATCH 055/187] Readme: updating technologies article for Zod version. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 98473431b..e2bda5125 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ Much can be customized to fit your needs. - [Typescript](https://www.typescriptlang.org/) first. - Web server — [Express.js](https://expressjs.com/) v5. -- Schema validation — [Zod 3.x](https://github.com/colinhacks/zod) including [Zod Plugin](#zod-plugin). +- Schema validation — [Zod 4.x](https://github.com/colinhacks/zod) including [Zod Plugin](#zod-plugin): + - For using with Zod 3.x install the framework versions below 24.0.0. - Supports any logger having `info()`, `debug()`, `error()` and `warn()` methods; - Built-in console logger with colorful and pretty inspections by default. - Generators: From fabc796871b6ff3b8b31ea0f6325ad84019b8473 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 27 Apr 2025 10:49:32 +0200 Subject: [PATCH 056/187] Reset migration for v24 (#2585) --- compat-test/eslint.config.js | 2 +- compat-test/migration.spec.ts | 3 +- express-zod-api/src/migration.ts | 105 ++---------------- .../__snapshots__/migration.spec.ts.snap | 2 +- express-zod-api/tests/migration.spec.ts | 69 ++---------- 5 files changed, 24 insertions(+), 157 deletions(-) diff --git a/compat-test/eslint.config.js b/compat-test/eslint.config.js index 3b277e7a4..8f0e1fe6f 100644 --- a/compat-test/eslint.config.js +++ b/compat-test/eslint.config.js @@ -3,5 +3,5 @@ import migration from "express-zod-api/migration"; export default [ { languageOptions: { parser }, plugins: { migration } }, - { files: ["**/*.ts"], rules: { "migration/v23": "error" } }, + { files: ["**/*.ts"], rules: { "migration/v24": "error" } }, ]; diff --git a/compat-test/migration.spec.ts b/compat-test/migration.spec.ts index cddc4b04b..e3f365644 100644 --- a/compat-test/migration.spec.ts +++ b/compat-test/migration.spec.ts @@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises"; import { describe, test, expect } from "vitest"; describe("Migration", () => { - test("should fix the import", async () => { + // @todo update + test.skip("should fix the import", async () => { const fixed = await readFile("./sample.ts", "utf-8"); expect(fixed).toBe("const test: HeaderSecurity = {};\n"); }); diff --git a/express-zod-api/src/migration.ts b/express-zod-api/src/migration.ts index a6b90f346..bc80ec1a8 100644 --- a/express-zod-api/src/migration.ts +++ b/express-zod-api/src/migration.ts @@ -1,23 +1,16 @@ import { ESLintUtils, - AST_NODE_TYPES as NT, + // AST_NODE_TYPES as NT, type TSESLint, - type TSESTree, + // type TSESTree, } from "@typescript-eslint/utils"; // eslint-disable-line allowed/dependencies -- special case -interface Queries { - headerSecurity: TSESTree.Identifier; - createConfig: TSESTree.ObjectExpression; - testMiddleware: TSESTree.ObjectExpression; -} +/* +interface Queries {} type Listener = keyof Queries; -const queries: Record = { - headerSecurity: `${NT.Identifier}[name='CustomHeaderSecurity']`, - createConfig: `${NT.CallExpression}[callee.name='createConfig'] > ${NT.ObjectExpression}`, - testMiddleware: `${NT.CallExpression}[callee.name='testMiddleware'] > ${NT.ObjectExpression}`, -}; +const queries: Record = {}; const listen = < S extends { [K in Listener]: TSESLint.RuleFunction }, @@ -31,8 +24,9 @@ const listen = < }), {}, ); +*/ -const v23 = ESLintUtils.RuleCreator.withoutDocs({ +const v24 = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: "problem", fixable: "code", @@ -44,88 +38,7 @@ const v23 = ESLintUtils.RuleCreator.withoutDocs({ }, }, defaultOptions: [], - create: (ctx) => - listen({ - headerSecurity: (node) => - ctx.report({ - node, - messageId: "change", - data: { subject: "interface", from: node.name, to: "HeaderSecurity" }, - fix: (fixer) => fixer.replaceText(node, "HeaderSecurity"), - }), - createConfig: (node) => { - const wmProp = node.properties.find( - (prop) => - prop.type === NT.Property && - prop.key.type === NT.Identifier && - prop.key.name === "wrongMethodBehavior", - ); - if (wmProp) return; - ctx.report({ - node, - messageId: "add", - data: { - subject: "wrongMethodBehavior property", - to: "configuration", - }, - fix: (fixer) => - fixer.insertTextAfterRange( - [node.range[0], node.range[0] + 1], - "wrongMethodBehavior: 404,", - ), - }); - }, - testMiddleware: (node) => { - const ehProp = node.properties.find( - ( - prop, - ): prop is TSESTree.Property & { - value: - | TSESTree.ArrowFunctionExpression - | TSESTree.FunctionExpression; - } => - prop.type === NT.Property && - prop.key.type === NT.Identifier && - prop.key.name === "errorHandler" && - [NT.ArrowFunctionExpression, NT.FunctionExpression].includes( - prop.value.type, - ), - ); - if (!ehProp) return; - const hasComma = ctx.sourceCode.getTokenAfter(ehProp)?.value === ","; - const { body } = ehProp.value; - const configProp = node.properties.find( - ( - prop, - ): prop is TSESTree.Property & { value: TSESTree.ObjectExpression } => - prop.type === NT.Property && - prop.key.type === NT.Identifier && - prop.key.name === "configProps" && - prop.value.type === NT.ObjectExpression, - ); - const replacement = `errorHandler: new ResultHandler({ positive: [], negative: [], handler: ({ error, response }) => {${ctx.sourceCode.getText(body)}} }),`; - ctx.report({ - node: ehProp, - messageId: "move", - data: { subject: "errorHandler", to: "configProps" }, - fix: (fixer) => [ - fixer.removeRange([ - ehProp.range[0], - ehProp.range[1] + (hasComma ? 1 : 0), - ]), - configProp - ? fixer.insertTextAfterRange( - [configProp.value.range[0], configProp.value.range[0] + 1], - replacement, - ) - : fixer.insertTextAfterRange( - [node.range[0], node.range[0] + 1], - `configProps: {${replacement}},`, - ), - ], - }); - }, - }), + create: () => ({}), }); /** @@ -141,5 +54,5 @@ const v23 = ESLintUtils.RuleCreator.withoutDocs({ * ]; * */ export default { - rules: { v23 }, + rules: { v24 }, } satisfies TSESLint.Linter.Plugin; diff --git a/express-zod-api/tests/__snapshots__/migration.spec.ts.snap b/express-zod-api/tests/__snapshots__/migration.spec.ts.snap index 7686520a2..43f767625 100644 --- a/express-zod-api/tests/__snapshots__/migration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/migration.spec.ts.snap @@ -3,7 +3,7 @@ exports[`Migration > should consist of one rule being the major version of the package 1`] = ` { "rules": { - "v23": { + "v24": { "create": [Function], "defaultOptions": [], "meta": { diff --git a/express-zod-api/tests/migration.spec.ts b/express-zod-api/tests/migration.spec.ts index 1e1f23ef4..0e8aadc8e 100644 --- a/express-zod-api/tests/migration.spec.ts +++ b/express-zod-api/tests/migration.spec.ts @@ -1,76 +1,29 @@ import { RuleTester } from "@typescript-eslint/rule-tester"; import migration from "../src/migration"; -import parser from "@typescript-eslint/parser"; -import { version } from "../package.json"; +// import parser from "@typescript-eslint/parser"; +// import { version } from "../package.json"; RuleTester.afterAll = afterAll; RuleTester.describe = describe; RuleTester.it = it; +/* const tester = new RuleTester({ languageOptions: { parser }, }); +*/ describe("Migration", () => { test("should consist of one rule being the major version of the package", () => { - expect(migration.rules).toHaveProperty(`v${version.split(".")[0]}`); + // @todo restore + // expect(migration.rules).toHaveProperty(`v${version.split(".")[0]}`); expect(migration).toMatchSnapshot(); }); - tester.run("v23", migration.rules.v23, { - valid: [ - `import { HeaderSecurity } from "express-zod-api";`, - `createConfig({ wrongMethodBehavior: 405 });`, - `testMiddleware({ middleware })`, - ], - invalid: [ - { - code: `const security: CustomHeaderSecurity = {};`, - output: `const security: HeaderSecurity = {};`, - errors: [ - { - messageId: "change", - data: { - subject: "interface", - from: "CustomHeaderSecurity", - to: "HeaderSecurity", - }, - }, - ], - }, - { - code: `createConfig({});`, - output: `createConfig({wrongMethodBehavior: 404,});`, - errors: [ - { - messageId: "add", - data: { - subject: "wrongMethodBehavior property", - to: "configuration", - }, - }, - ], - }, - { - code: `testMiddleware({ errorHandler: (error, response) => response.end(error.message) })`, - output: `testMiddleware({configProps: {errorHandler: new ResultHandler({ positive: [], negative: [], handler: ({ error, response }) => {response.end(error.message)} }),}, })`, - errors: [ - { - messageId: "move", - data: { subject: "errorHandler", to: "configProps" }, - }, - ], - }, - { - code: `testMiddleware({ errorHandler(error, response) { response.end(error.message) }, configProps: { wrongMethodBehavior: 404 } })`, - output: `testMiddleware({ configProps: {errorHandler: new ResultHandler({ positive: [], negative: [], handler: ({ error, response }) => {{ response.end(error.message) }} }), wrongMethodBehavior: 404 } })`, - errors: [ - { - messageId: "move", - data: { subject: "errorHandler", to: "configProps" }, - }, - ], - }, - ], + /* + tester.run("v24", migration.rules.v24, { + valid: [], + invalid: [], }); + */ }); From ae37bcf5314174211d955c725df272fe69cc3d5e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 10:52:17 +0200 Subject: [PATCH 057/187] v24 is dedicated to Ashley Burton. --- express-zod-api/src/startup-logo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/startup-logo.ts b/express-zod-api/src/startup-logo.ts index 6005c90cf..7567d44b4 100644 --- a/express-zod-api/src/startup-logo.ts +++ b/express-zod-api/src/startup-logo.ts @@ -12,7 +12,7 @@ export const printStartupLogo = (stream: WriteStream) => { const thanks = italic( "Thank you for choosing Express Zod API for your project.".padStart(132), ); - const dedicationMessage = italic("for Sonia".padEnd(20)); + const dedicationMessage = italic("for Ashley".padEnd(20)); const pink = hex("#F5A9B8"); const blue = hex("#5BCEFA"); From ded0e0410d361fea019e8f21a0f544ee8760fcb1 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 11:03:15 +0200 Subject: [PATCH 058/187] Security: planning for june. --- SECURITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SECURITY.md b/SECURITY.md index 9892407af..2c2efbc96 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Code name | Release | Supported | | ------: | :------------ | :------ | :----------------: | +| 24.x.x | Ashley | 06.2025 | :white_check_mark: | | 23.x.x | Sonia | 04.2025 | :white_check_mark: | | 22.x.x | Tai | 01.2025 | :white_check_mark: | | 21.x.x | Kesaria | 11.2024 | :white_check_mark: | From 1f29f14d440df0fc2110e3015a61bec49e1abdfc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 11:03:59 +0200 Subject: [PATCH 059/187] v24.0.0-beta.0 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index a133fcfb9..011db1e6e 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "23.1.1", + "version": "24.0.0-beta.0", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 104c7c14c8483bc576927a42b5d5a620928977ed Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 12:01:12 +0200 Subject: [PATCH 060/187] Restoring original testing for changed/unchanged brand handling. --- .../tests/__snapshots__/documentation.spec.ts.snap | 3 --- express-zod-api/tests/documentation.spec.ts | 8 ++------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index ad2c1d40d..266f83dcf 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -2307,14 +2307,12 @@ paths: required: true description: GET /v1/:name Parameter schema: - type: string summary: My custom schema - name: other in: query required: true description: GET /v1/:name Parameter schema: - type: boolean summary: My custom schema - name: regular in: query @@ -2336,7 +2334,6 @@ paths: type: object properties: number: - type: number summary: My custom schema required: - number diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 37b23a5f1..adbcbfc58 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1185,10 +1185,7 @@ describe("Documentation", () => { describe("Feature #1470: Custom brands", () => { test("should be handled accordingly in request, response and params", () => { const deep = Symbol("DEEP"); - const rule: Depicter = ({ jsonSchema }) => ({ - ...jsonSchema, - type: "boolean", - }); + const rule: Depicter = ({ jsonSchema }) => jsonSchema; const spec = new Documentation({ config: sampleConfig, routing: { @@ -1207,8 +1204,7 @@ describe("Documentation", () => { }, }, brandHandling: { - CUSTOM: ({ jsonSchema }) => ({ - ...jsonSchema, + CUSTOM: () => ({ summary: "My custom schema", }), [deep]: rule, From 852db8582d933c53d5cfc8f5744e4d3da6d153bf Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 27 Apr 2025 12:04:18 +0200 Subject: [PATCH 061/187] Migration rules for v24 (#2586) --- compat-test/migration.spec.ts | 5 +- compat-test/sample.ts | 2 +- express-zod-api/src/migration.ts | 56 ++++++++++++++++--- .../__snapshots__/migration.spec.ts.snap | 1 + express-zod-api/tests/migration.spec.ts | 36 ++++++++---- 5 files changed, 77 insertions(+), 23 deletions(-) diff --git a/compat-test/migration.spec.ts b/compat-test/migration.spec.ts index e3f365644..28b9883d9 100644 --- a/compat-test/migration.spec.ts +++ b/compat-test/migration.spec.ts @@ -2,9 +2,8 @@ import { readFile } from "node:fs/promises"; import { describe, test, expect } from "vitest"; describe("Migration", () => { - // @todo update - test.skip("should fix the import", async () => { + test("should fix the import", async () => { const fixed = await readFile("./sample.ts", "utf-8"); - expect(fixed).toBe("const test: HeaderSecurity = {};\n"); + expect(fixed).toBe("new Documentation({ });\n"); }); }); diff --git a/compat-test/sample.ts b/compat-test/sample.ts index 69f9c38d6..60d3e3813 100644 --- a/compat-test/sample.ts +++ b/compat-test/sample.ts @@ -1 +1 @@ -const test: HeaderSecurity = {}; +new Documentation({ numericRange: {}, }); diff --git a/express-zod-api/src/migration.ts b/express-zod-api/src/migration.ts index bc80ec1a8..0a9cdfa12 100644 --- a/express-zod-api/src/migration.ts +++ b/express-zod-api/src/migration.ts @@ -1,16 +1,29 @@ import { ESLintUtils, - // AST_NODE_TYPES as NT, + AST_NODE_TYPES as NT, type TSESLint, - // type TSESTree, + type TSESTree, } from "@typescript-eslint/utils"; // eslint-disable-line allowed/dependencies -- special case -/* -interface Queries {} +type NamedProp = TSESTree.PropertyNonComputedName & { + key: TSESTree.Identifier; +}; + +interface Queries { + numericRange: NamedProp; + optionalPropStyle: NamedProp; +} type Listener = keyof Queries; -const queries: Record = {}; +const queries: Record = { + numericRange: + `${NT.NewExpression}[callee.name='Documentation'] > ` + + `${NT.ObjectExpression} > ${NT.Property}[key.name='numericRange']`, + optionalPropStyle: + `${NT.NewExpression}[callee.name='Integration'] > ` + + `${NT.ObjectExpression} > ${NT.Property}[key.name='optionalPropStyle']`, +}; const listen = < S extends { [K in Listener]: TSESLint.RuleFunction }, @@ -24,7 +37,15 @@ const listen = < }), {}, ); -*/ + +const rangeWithComma = ( + node: TSESTree.Node, + ctx: TSESLint.RuleContext, +) => + [ + node.range[0], + node.range[1] + (ctx.sourceCode.getTokenAfter(node)?.value === "," ? 1 : 0), + ] as const; const v24 = ESLintUtils.RuleCreator.withoutDocs({ meta: { @@ -33,12 +54,29 @@ const v24 = ESLintUtils.RuleCreator.withoutDocs({ schema: [], messages: { change: "change {{ subject }} from {{ from }} to {{ to }}", - add: `add {{ subject }} to {{ to }}`, + add: "add {{ subject }} to {{ to }}", move: "move {{ subject }} to {{ to }}", + remove: "remove {{ subject }}", }, }, defaultOptions: [], - create: () => ({}), + create: (ctx) => + listen({ + numericRange: (node) => + ctx.report({ + node, + messageId: "remove", + data: { subject: node.key.name }, + fix: (fixer) => fixer.removeRange(rangeWithComma(node, ctx)), + }), + optionalPropStyle: (node) => + ctx.report({ + node, + messageId: "remove", + data: { subject: node.key.name }, + fix: (fixer) => fixer.removeRange(rangeWithComma(node, ctx)), + }), + }), }); /** @@ -50,7 +88,7 @@ const v24 = ESLintUtils.RuleCreator.withoutDocs({ * import migration from "express-zod-api/migration"; * export default [ * { languageOptions: {parser}, plugins: {migration} }, - * { files: ["**\/*.ts"], rules: { "migration/v21": "error" } } + * { files: ["**\/*.ts"], rules: { "migration/v24": "error" } } * ]; * */ export default { diff --git a/express-zod-api/tests/__snapshots__/migration.spec.ts.snap b/express-zod-api/tests/__snapshots__/migration.spec.ts.snap index 43f767625..f4b344beb 100644 --- a/express-zod-api/tests/__snapshots__/migration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/migration.spec.ts.snap @@ -12,6 +12,7 @@ exports[`Migration > should consist of one rule being the major version of the p "add": "add {{ subject }} to {{ to }}", "change": "change {{ subject }} from {{ from }} to {{ to }}", "move": "move {{ subject }} to {{ to }}", + "remove": "remove {{ subject }}", }, "schema": [], "type": "problem", diff --git a/express-zod-api/tests/migration.spec.ts b/express-zod-api/tests/migration.spec.ts index 0e8aadc8e..7623065bf 100644 --- a/express-zod-api/tests/migration.spec.ts +++ b/express-zod-api/tests/migration.spec.ts @@ -1,29 +1,45 @@ import { RuleTester } from "@typescript-eslint/rule-tester"; import migration from "../src/migration"; -// import parser from "@typescript-eslint/parser"; -// import { version } from "../package.json"; +import parser from "@typescript-eslint/parser"; +import { version } from "../package.json"; RuleTester.afterAll = afterAll; RuleTester.describe = describe; RuleTester.it = it; -/* const tester = new RuleTester({ languageOptions: { parser }, }); -*/ describe("Migration", () => { test("should consist of one rule being the major version of the package", () => { - // @todo restore - // expect(migration.rules).toHaveProperty(`v${version.split(".")[0]}`); + expect(migration.rules).toHaveProperty(`v${version.split(".")[0]}`); expect(migration).toMatchSnapshot(); }); - /* tester.run("v24", migration.rules.v24, { - valid: [], - invalid: [], + valid: [`new Documentation({});`, `new Integration({})`], + invalid: [ + { + code: `new Documentation({ numericRange: {}, });`, + output: `new Documentation({ });`, + errors: [ + { + messageId: "remove", + data: { subject: "numericRange" }, + }, + ], + }, + { + code: `new Integration({ optionalPropStyle: {}, });`, + output: `new Integration({ });`, + errors: [ + { + messageId: "remove", + data: { subject: "optionalPropStyle" }, + }, + ], + }, + ], }); - */ }); From a4d71c8b02e91d5cea1ce70a47f06c48ce843aff Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 27 Apr 2025 12:04:45 +0200 Subject: [PATCH 062/187] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e2bda5125..b9ded055f 100644 --- a/README.md +++ b/README.md @@ -1403,7 +1403,7 @@ const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for const myBrandedSchema = z.string().brand(myBrand); const ruleForDocs: Overrider = ( - { zodSchema, jsonSchema }, // return changed jsonSchema + { zodSchema, jsonSchema }, // jsonSchema is the default depiction { path, method, isResponse }, ) => ({ ...jsonSchema, From 34b2c12aa53d011f3048806cce6525abe317dea8 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 13:08:12 +0200 Subject: [PATCH 063/187] Readme: fix type name. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b9ded055f..713aee064 100644 --- a/README.md +++ b/README.md @@ -1402,7 +1402,7 @@ import { const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose const myBrandedSchema = z.string().brand(myBrand); -const ruleForDocs: Overrider = ( +const ruleForDocs: Depicter = ( { zodSchema, jsonSchema }, // jsonSchema is the default depiction { path, method, isResponse }, ) => ({ From 7b4ab5c94236e0fac3e217af8739cc87d19af847 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 14:14:38 +0200 Subject: [PATCH 064/187] Migration for Depicter. --- express-zod-api/src/migration.ts | 47 +++++++++++++++++++++++++ express-zod-api/tests/migration.spec.ts | 28 ++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/migration.ts b/express-zod-api/src/migration.ts index 0a9cdfa12..358c11c5a 100644 --- a/express-zod-api/src/migration.ts +++ b/express-zod-api/src/migration.ts @@ -12,6 +12,8 @@ type NamedProp = TSESTree.PropertyNonComputedName & { interface Queries { numericRange: NamedProp; optionalPropStyle: NamedProp; + depicter: TSESTree.ArrowFunctionExpression; + nextCall: TSESTree.CallExpression; } type Listener = keyof Queries; @@ -23,6 +25,12 @@ const queries: Record = { optionalPropStyle: `${NT.NewExpression}[callee.name='Integration'] > ` + `${NT.ObjectExpression} > ${NT.Property}[key.name='optionalPropStyle']`, + depicter: + `${NT.VariableDeclarator}[id.typeAnnotation.typeAnnotation.typeName.name='Depicter'] > ` + + `${NT.ArrowFunctionExpression}`, + nextCall: + `${NT.VariableDeclarator}[id.typeAnnotation.typeAnnotation.typeName.name='Depicter'] > ` + + `${NT.ArrowFunctionExpression} ${NT.CallExpression}[callee.name='next']`, }; const listen = < @@ -76,6 +84,45 @@ const v24 = ESLintUtils.RuleCreator.withoutDocs({ data: { subject: node.key.name }, fix: (fixer) => fixer.removeRange(rangeWithComma(node, ctx)), }), + depicter: (node) => { + const [first, second] = node.params; + if (first?.type !== NT.Identifier) return; + const zodSchemaAlias = first.name; + if (second?.type !== NT.ObjectPattern) return; + const nextFn = second.properties.find( + (one) => + one.type === NT.Property && + one.key.type === NT.Identifier && + one.key.name === "next", + ); + ctx.report({ + node, + messageId: "change", + data: { + subject: "arguments", + from: `[${zodSchemaAlias}, { next, ...rest }]`, + to: `[{ zodSchema: ${zodSchemaAlias}, jsonSchema }, { ...rest }]`, + }, + fix: (fixer) => { + const fixes = [ + fixer.replaceText( + first, + `{ zodSchema: ${zodSchemaAlias}, jsonSchema }`, + ), + ]; + if (nextFn) + fixes.push(fixer.removeRange(rangeWithComma(nextFn, ctx))); + return fixes; + }, + }); + }, + nextCall: (node) => + ctx.report({ + node, + messageId: "change", + data: { subject: "statement", from: "next()", to: "jsonSchema" }, + fix: (fixer) => fixer.replaceText(node, "jsonSchema"), + }), }), }); diff --git a/express-zod-api/tests/migration.spec.ts b/express-zod-api/tests/migration.spec.ts index 7623065bf..89862d9ca 100644 --- a/express-zod-api/tests/migration.spec.ts +++ b/express-zod-api/tests/migration.spec.ts @@ -18,7 +18,11 @@ describe("Migration", () => { }); tester.run("v24", migration.rules.v24, { - valid: [`new Documentation({});`, `new Integration({})`], + valid: [ + `new Documentation({});`, + `new Integration({})`, + `const rule: Depicter = () => {}`, + ], invalid: [ { code: `new Documentation({ numericRange: {}, });`, @@ -40,6 +44,28 @@ describe("Migration", () => { }, ], }, + { + code: + `const rule: Depicter = (schema, { next, path, method, isResponse }) ` + + `=> ({ ...next(schema.unwrap()), summary: "test" })`, + output: + `const rule: Depicter = ({ zodSchema: schema, jsonSchema }, { path, method, isResponse }) ` + + `=> ({ ...jsonSchema, summary: "test" })`, + errors: [ + { + messageId: "change", + data: { + subject: "arguments", + from: "[schema, { next, ...rest }]", + to: "[{ zodSchema: schema, jsonSchema }, { ...rest }]", + }, + }, + { + messageId: "change", + data: { subject: "statement", from: "next()", to: "jsonSchema" }, + }, + ], + }, ], }); }); From ce66a319f5116c681e3de8ec426d4e2a44a44d8d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 14:17:32 +0200 Subject: [PATCH 065/187] Ref: DNRY: extracting propRemover. --- express-zod-api/src/migration.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/express-zod-api/src/migration.ts b/express-zod-api/src/migration.ts index 358c11c5a..c7e614b65 100644 --- a/express-zod-api/src/migration.ts +++ b/express-zod-api/src/migration.ts @@ -55,6 +55,15 @@ const rangeWithComma = ( node.range[1] + (ctx.sourceCode.getTokenAfter(node)?.value === "," ? 1 : 0), ] as const; +const propRemover = + (ctx: TSESLint.RuleContext) => (node: NamedProp) => + ctx.report({ + node, + messageId: "remove", + data: { subject: node.key.name }, + fix: (fixer) => fixer.removeRange(rangeWithComma(node, ctx)), + }); + const v24 = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: "problem", @@ -70,20 +79,8 @@ const v24 = ESLintUtils.RuleCreator.withoutDocs({ defaultOptions: [], create: (ctx) => listen({ - numericRange: (node) => - ctx.report({ - node, - messageId: "remove", - data: { subject: node.key.name }, - fix: (fixer) => fixer.removeRange(rangeWithComma(node, ctx)), - }), - optionalPropStyle: (node) => - ctx.report({ - node, - messageId: "remove", - data: { subject: node.key.name }, - fix: (fixer) => fixer.removeRange(rangeWithComma(node, ctx)), - }), + numericRange: propRemover(ctx), + optionalPropStyle: propRemover(ctx), depicter: (node) => { const [first, second] = node.params; if (first?.type !== NT.Identifier) return; From 9d4baaa43ff53ebc0049d7536ccbdc228f60b15b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 19:09:04 +0200 Subject: [PATCH 066/187] Applying two correction from code review to Documentation. --- express-zod-api/src/documentation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 59375aaab..bb203a0b7 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -64,7 +64,7 @@ interface DocumentationParams { * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. - * @example { MyBrand: ( schema: typeof myBrandSchema, { jsonSchema } ) => ({ type: "object" }) + * @example { MyBrand: ( { zodSchema, jsonSchema } ) => ({ type: "object" }) */ brandHandling?: BrandHandling; /** @@ -140,9 +140,9 @@ export class Documentation extends OpenApiBuilder { version, serverUrl, descriptions, + brandHandling, tags, isHeader, - brandHandling, hasSummaryFromDescription = true, composition = "inline", }: DocumentationParams) { From fa646564d8ee8d37537555afaa82c8f26013da98 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 19:12:53 +0200 Subject: [PATCH 067/187] Ref: using references size in makeRef. --- express-zod-api/src/documentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index bb203a0b7..8dd6cefeb 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -93,7 +93,7 @@ export class Documentation extends OpenApiBuilder { name = this.#references.get(key), ): ReferenceObject { if (!name) { - name = `Schema${Object.keys(this.rootDoc.components?.schemas || {}).length + 1}`; + name = `Schema${this.#references.size + 1}`; this.#references.set(key, name); } this.addSchema(name, subject); From 64c883d43d542f62c83e81f2ea9e48dacd74d495 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 27 Apr 2025 20:55:04 +0200 Subject: [PATCH 068/187] Fix EmptySchema type to ensure correct intersections for EndpointsFactory and handlers. --- express-zod-api/src/common-helpers.ts | 4 ++-- express-zod-api/tests/endpoints-factory.spec.ts | 7 ++++--- express-zod-api/tests/middleware.spec.ts | 7 +++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 1a2e2989d..d95cfdb81 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -9,8 +9,8 @@ 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 */ -export type EmptyObject = Record; -export type EmptySchema = z.ZodObject; +export type EmptyObject = z.output; +export type EmptySchema = z.ZodRecord; export type FlatObject = Record; /** @link https://stackoverflow.com/a/65492934 */ diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index dc053bf32..54a54f0b8 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -346,8 +346,9 @@ describe("EndpointsFactory", () => { output: z.object({}), handler: vi.fn(), }); - /** @see $InferObjectOutput - external logic */ - expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf(); + expectTypeOf( + endpoint.inputSchema._zod.output, + ).toEqualTypeOf(); expect(endpoint.isDeprecated).toBe(true); }); }); @@ -359,7 +360,7 @@ describe("EndpointsFactory", () => { handler: async () => {}, }); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.outputSchema).toExtend(); + expectTypeOf(endpoint.outputSchema.shape).toExtend(); }); }); }); diff --git a/express-zod-api/tests/middleware.spec.ts b/express-zod-api/tests/middleware.spec.ts index 329e1c306..efc2210df 100644 --- a/express-zod-api/tests/middleware.spec.ts +++ b/express-zod-api/tests/middleware.spec.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { InputValidationError, Middleware } from "../src"; +import { EmptyObject } from "../src/common-helpers"; import { AbstractMiddleware, ExpressMiddleware } from "../src/middleware"; import { makeLoggerMock, @@ -22,8 +23,7 @@ describe("Middleware", () => { test("should allow to omit input schema", () => { const mw = new Middleware({ handler: vi.fn() }); - /** @see $InferObjectOutput - external logic */ - expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); + expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); }); describe("#600: Top level refinements", () => { @@ -87,7 +87,6 @@ describe("ExpressMiddleware", () => { test("should inherit from Middleware", () => { const mw = new ExpressMiddleware(vi.fn()); expect(mw).toBeInstanceOf(Middleware); - /** @see $InferObjectOutput - external logic */ - expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); + expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); }); }); From 53fe3b58de78a236f06f1ea82ad35e6c09851826 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 04:48:31 +0200 Subject: [PATCH 069/187] Restoring .and() instead of z.intersection - turned out to be recovered. --- express-zod-api/src/io-schema.ts | 4 +-- express-zod-api/tests/deep-checks.spec.ts | 7 ++--- express-zod-api/tests/documentation.spec.ts | 11 ++------ .../tests/endpoints-factory.spec.ts | 5 +--- express-zod-api/tests/io-schema.spec.ts | 27 ++++++------------ express-zod-api/tests/zts.spec.ts | 28 ++++++++----------- 6 files changed, 26 insertions(+), 56 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index b0cca8fda..a9130e0c4 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -25,9 +25,7 @@ export const getFinalEndpointInputSchema = < ): z.ZodIntersection => { const allSchemas: IOSchema[] = R.pluck("schema", middlewares); allSchemas.push(input); - const finalSchema = allSchemas.reduce((acc, schema) => - z.intersection(acc, schema), - ); + const finalSchema = allSchemas.reduce((acc, schema) => acc.and(schema)); return allSchemas.reduce( (acc, schema) => mixExamples(schema, acc), finalSchema, diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index ccff858f1..68f27c228 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -18,10 +18,7 @@ describe("Checks", () => { test.each([ z.object({ test: ez.upload() }), ez.upload().or(z.boolean()), - z.intersection( - z.object({ test: z.boolean() }), - z.object({ test2: ez.upload() }), - ), + z.object({ test: z.boolean() }).and(z.object({ test2: ez.upload() })), z.optional(ez.upload()), ez.upload().nullable(), ez.upload().default({} as UploadedFile & $brand), @@ -36,7 +33,7 @@ describe("Checks", () => { z.object({}), z.any(), z.literal("test"), - z.intersection(z.boolean(), z.literal(true)), + z.boolean().and(z.literal(true)), z.number().or(z.string()), ])("should return false in other cases %#", (subject) => { expect(hasNestedSchema(subject, { condition })).toBeFalsy(); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index adbcbfc58..c7a096a37 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -112,14 +112,9 @@ describe("Documentation", () => { getSomething: defaultEndpointsFactory.build({ method: "post", input: z.object({ - intersection: z.intersection( - z.object({ - one: z.string(), - }), - z.object({ - two: z.string(), - }), - ), + intersection: z + .object({ one: z.string() }) + .and(z.object({ two: z.string() })), }), output: z.object({ and: z diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 54a54f0b8..466993538 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -286,10 +286,7 @@ describe("EndpointsFactory", () => { test("Should create an endpoint with intersection middleware", () => { const middleware = new Middleware({ - input: z.intersection( - z.object({ n1: z.number() }), - z.object({ n2: z.number() }), - ), + input: z.object({ n1: z.number() }).and(z.object({ n2: z.number() })), handler: vi.fn(), }); const factory = new EndpointsFactory(resultHandlerMock).addMiddleware( diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 9560fa9a6..cce312fcf 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -45,22 +45,15 @@ describe("I/O Schema and related helpers", () => { ).not.toExtend(); }); test("accepts intersection of objects", () => { + expectTypeOf(z.object({}).and(z.object({}))).toExtend(); + expectTypeOf(z.object({}).and(z.object({}))).toExtend(); expectTypeOf( - z.intersection(z.object({}), z.object({})), - ).toExtend(); - expectTypeOf( - z.intersection(z.object({}), z.object({})), - ).toExtend(); - expectTypeOf( - z.intersection( - z.intersection(z.object({}), z.object({})), - z.object({}), - ), + z.object({}).and(z.object({})).and(z.object({})), ).toExtend(); }); test("does not accepts intersection of object with array of objects", () => { expectTypeOf( - z.intersection(z.object({}), z.array(z.object({}))), + z.object({}).and(z.array(z.object({}))), ).not.toExtend(); }); test("accepts discriminated union of objects", () => { @@ -73,10 +66,10 @@ describe("I/O Schema and related helpers", () => { }); test("accepts a mix of types based on object", () => { expectTypeOf( - z.object({}).or(z.intersection(z.object({}), z.object({}))), + z.object({}).or(z.object({}).and(z.object({}))), ).toExtend(); expectTypeOf( - z.intersection(z.object({}), z.object({}).or(z.object({}))), + z.object({}).and(z.object({}).or(z.object({}))), ).toExtend(); }); describe("Feature #600: Top level refinements", () => { @@ -236,12 +229,8 @@ describe("I/O Schema and related helpers", () => { const right = z.object({ id: z.string().transform((str) => parseInt(str)), }); - const schema = z.intersection(left, right); - expect(() => - schema.parse({ - id: "123", - }), - ).toThrowErrorMatchingSnapshot(); + const schema = left.and(right); + expect(() => schema.parse({ id: "123" })).toThrowErrorMatchingSnapshot(); }); test("Should merge mixed object schemas", () => { diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 2c263900c..b0a1f529b 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -119,10 +119,9 @@ describe("zod-to-ts", () => { union: z.union([z.object({ number: z.number() }), z.literal("hi")]), enum: z.enum(["hi", "bye"]), intersectionWithTransform: z - .intersection( - z.intersection(z.number(), z.bigint()), - z.intersection(z.number(), z.string()), - ) + .number() + .and(z.bigint()) + .and(z.number().and(z.string())) .transform((arg) => console.log(arg)), date: z.date(), undefined: z.undefined(), @@ -154,16 +153,14 @@ describe("zod-to-ts", () => { ), map: z.map(z.string(), z.array(z.object({ string: z.string() }))), set: z.set(z.string()), - intersection: z.intersection(z.string(), z.number()).or(z.bigint()), + intersection: z.string().and(z.number()).or(z.bigint()), promise: z.promise(z.number()), optDefaultString: z.string().optional().default("hi"), - refinedStringWithSomeBullshit: z.intersection( - z - .string() - .refine((val) => val.length > 10) - .or(z.number()), - z.bigint().nullish().default(1000n), - ), + refinedStringWithSomeBullshit: z + .string() + .refine((val) => val.length > 10) + .or(z.number()) + .and(z.bigint().nullish().default(1000n)), nativeEnum: z.enum(Fruits), lazy: z.lazy(() => z.string()), discUnion: z.discriminatedUnion("kind", [ @@ -303,10 +300,7 @@ describe("zod-to-ts", () => { ], [z.object({}), z.object({})], ])("should deduplicate the prop with a same name", (a, b) => { - const schema = z.intersection( - z.object({ query: a }), - z.object({ query: b }), - ); + const schema = z.object({ query: a }).and(z.object({ query: b })); const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -331,7 +325,7 @@ describe("zod-to-ts", () => { ])( "should not flatten the result for objects with a conflicting prop %#", (a, b) => { - const node = zodToTs(z.intersection(a, b), { ctx }); + const node = zodToTs(a.and(b), { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }, ); From a9ca7d5b438639fcd7cfbcadb35f65a5eed74141 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 10:53:34 +0200 Subject: [PATCH 070/187] Ref: DNRY isObject in depictExamples and depictParamExamples. --- express-zod-api/src/documentation-helpers.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 3ec7019d1..cf28f06b2 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -27,7 +27,6 @@ import { ResponseVariant } from "./api-response"; import { combinations, doesAccept, - FlatObject, getExamples, getRoutePathParams, getTransformedType, @@ -343,12 +342,10 @@ export const depictExamples = ( schema: $ZodType, isResponse: boolean, omitProps: string[] = [], -): ExamplesObject | undefined => { - const isObject = (subj: unknown): subj is FlatObject => - R.type(subj) === "Object"; - return R.pipe( +): ExamplesObject | undefined => + R.pipe( getExamples, - R.map(R.when(isObject, R.omit(omitProps))), + R.map(R.when(R.is(Object), R.omit(omitProps))), enumerateExamples, )({ schema, @@ -356,21 +353,17 @@ export const depictExamples = ( validate: true, pullProps: true, }); -}; export const depictParamExamples = ( schema: z.ZodType, param: string, -): ExamplesObject | undefined => { - const isObject = (subj: unknown): subj is FlatObject => - R.type(subj) === "Object"; - return R.pipe( +): ExamplesObject | undefined => + R.pipe( getExamples, - R.filter(R.both(isObject, R.has(param))), + R.filter(R.both(R.is(Object), R.has(param))), R.pluck(param), enumerateExamples, )({ schema, variant: "original", validate: true, pullProps: true }); -}; export const defaultIsHeader = ( name: string, From 010bc8512ef89ed7af9d9e7a093dd0d678ed1905 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 10:57:47 +0200 Subject: [PATCH 071/187] Restoring original naming for depicters. --- express-zod-api/src/documentation-helpers.ts | 48 +- .../documentation-helpers.spec.ts.snap | 498 +++++++++--------- .../tests/documentation-helpers.spec.ts | 98 ++-- 3 files changed, 323 insertions(+), 321 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index cf28f06b2..38f7f3f46 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -103,20 +103,20 @@ const samples = { export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const onDefault: Depicter = ({ zodSchema, jsonSchema }) => ({ +export const depictDefault: Depicter = ({ zodSchema, jsonSchema }) => ({ ...jsonSchema, default: globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? jsonSchema.default, }); -export const onUpload: Depicter = ({}, ctx) => { +export const depictUpload: Depicter = ({}, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.upload() only for input.", ctx); return { type: "string", format: "binary" }; }; -export const onFile: Depicter = ({ jsonSchema }) => ({ +export const depictFile: Depicter = ({ jsonSchema }) => ({ type: "string", format: jsonSchema.type === "string" @@ -126,7 +126,7 @@ export const onFile: Depicter = ({ jsonSchema }) => ({ : "binary", }); -export const onUnion: Depicter = ({ zodSchema, jsonSchema }) => { +export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { if (!zodSchema._zod.disc) return jsonSchema; const propertyName = Array.from(zodSchema._zod.disc.keys()).pop(); return { @@ -181,7 +181,7 @@ const intersect = ( return R.map((fn) => fn(left, right), suitable); }; -export const onIntersection: Depicter = ({ jsonSchema }) => { +export const depictIntersection: Depicter = ({ jsonSchema }) => { if (jsonSchema.allOf) { try { return intersect(jsonSchema.allOf); @@ -191,7 +191,7 @@ export const onIntersection: Depicter = ({ jsonSchema }) => { }; /** @since OAS 3.1 nullable replaced with type array having null */ -export const onNullable: Depicter = ({ jsonSchema }) => { +export const depictNullable: Depicter = ({ jsonSchema }) => { if (!jsonSchema.anyOf) return jsonSchema; const original = jsonSchema.anyOf[0]; return Object.assign(original, { type: makeNullableType(original.type) }); @@ -225,7 +225,7 @@ const ensureCompliance = ({ return valid; }; -export const onDateIn: Depicter = ({}, ctx) => { +export const depictDateIn: Depicter = ({}, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); return { @@ -239,7 +239,7 @@ export const onDateIn: Depicter = ({}, ctx) => { }; }; -export const onDateOut: Depicter = ({}, ctx) => { +export const depictDateOut: Depicter = ({}, ctx) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); return { @@ -252,7 +252,7 @@ export const onDateOut: Depicter = ({}, ctx) => { }; }; -export const onBigInt: Depicter = () => ({ +export const depictBigInt: Depicter = () => ({ type: "string", format: "bigint", pattern: /^-?\d+$/.source, @@ -262,7 +262,7 @@ export const onBigInt: Depicter = () => ({ * @since OAS 3.1 using prefixItems for depicting tuples * @since 17.5.0 added rest handling, fixed tuple type */ -export const onTuple: Depicter = ({ zodSchema, jsonSchema }) => { +export const depictTuple: Depicter = ({ zodSchema, jsonSchema }) => { if ((zodSchema as $ZodTuple)._zod.def.rest !== null) return jsonSchema; // does not appear to support items:false, so not:{} is a recommended alias return { ...jsonSchema, items: { not: {} } }; @@ -289,7 +289,7 @@ const makeNullableType = ( ); }; -export const onPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { +export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { const target = (zodSchema as $ZodPipe)._zod.def[ ctx.isResponse ? "out" : "in" ]; @@ -320,7 +320,7 @@ export const onPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { return jsonSchema; }; -export const onRaw: Depicter = ({ jsonSchema }) => { +export const depictRaw: Depicter = ({ jsonSchema }) => { if (jsonSchema.type !== "object") return jsonSchema; const objSchema = jsonSchema as JSONSchema.ObjectSchema; if (!objSchema.properties) return jsonSchema; @@ -438,18 +438,18 @@ export const depictRequestParams = ({ const depicters: Partial> = { - nullable: onNullable, - default: onDefault, - union: onUnion, - bigint: onBigInt, - intersection: onIntersection, - tuple: onTuple, - pipe: onPipeline, - [ezDateInBrand]: onDateIn, - [ezDateOutBrand]: onDateOut, - [ezUploadBrand]: onUpload, - [ezFileBrand]: onFile, - [ezRawBrand]: onRaw, + nullable: depictNullable, + default: depictDefault, + union: depictUnion, + bigint: depictBigInt, + intersection: depictIntersection, + tuple: depictTuple, + pipe: depictPipeline, + [ezDateInBrand]: depictDateIn, + [ezDateOutBrand]: depictDateOut, + [ezUploadBrand]: depictUpload, + [ezFileBrand]: depictFile, + [ezRawBrand]: depictRaw, }; const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { 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 6d1045761..018c591b3 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -1,5 +1,57 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Documentation helpers > depictBigInt() > should set type:string and format:bigint 1`] = ` +{ + "format": "bigint", + "pattern": "^-?\\d+$", + "type": "string", +} +`; + +exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 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 throw when ZodDateIn in response 1`] = ` +DocumentationError({ + "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", + "message": "Please use ez.dateOut() for output.", +}) +`; + +exports[`Documentation helpers > depictDateOut > should set type:string, description and format 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 throw when ZodDateOut in request 1`] = ` +DocumentationError({ + "cause": "Input schema of an Endpoint assigned to GET method of /v1/user/:id path.", + "message": "Please use ez.dateIn() for input.", +}) +`; + +exports[`Documentation helpers > depictDefault() > Feature #1706: should override the default value by a label from metadata 1`] = ` +{ + "default": "Today", + "format": "date-time", +} +`; + exports[`Documentation helpers > depictExamples() > should 'pass' examples in case of 'request' 1`] = ` { "example1": { @@ -34,6 +86,146 @@ exports[`Documentation helpers > depictExamples() > should 'transform' examples } `; +exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 0 1`] = ` +{ + "format": "file", + "type": "string", +} +`; + +exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 1 1`] = ` +{ + "format": "binary", + "type": "string", +} +`; + +exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 2 1`] = ` +{ + "format": "byte", + "type": "string", +} +`; + +exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 3 1`] = ` +{ + "format": "file", + "type": "string", +} +`; + +exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 4 1`] = ` +{ + "format": "binary", + "type": "string", +} +`; + +exports[`Documentation helpers > depictIntersection() > should NOT flatten object schemas having conflicting props 1`] = ` +{ + "allOf": [ + { + "properties": { + "one": { + "type": "number", + }, + }, + "type": "object", + }, + { + "properties": { + "one": { + "type": "string", + }, + }, + "type": "object", + }, + ], +} +`; + +exports[`Documentation helpers > depictIntersection() > should flatten objects with same prop of same type 1`] = ` +{ + "properties": { + "one": { + "type": "number", + }, + }, + "type": "object", +} +`; + +exports[`Documentation helpers > depictIntersection() > should flatten two object schemas 1`] = ` +{ + "description": "some", + "properties": { + "one": { + "type": "number", + }, + "two": { + "type": "number", + }, + }, + "type": "object", +} +`; + +exports[`Documentation helpers > depictIntersection() > should maintain uniqueness in the array of required props 1`] = ` +{ + "properties": { + "test": { + "const": 5, + "type": "number", + }, + }, + "type": "object", +} +`; + +exports[`Documentation helpers > depictIntersection() > should merge examples deeply 1`] = ` +{ + "examples": [ + { + "a": 123, + "b": 456, + }, + ], + "properties": { + "a": { + "type": "number", + }, + "b": { + "type": "number", + }, + }, + "type": "object", +} +`; + +exports[`Documentation helpers > depictNullable() > should add null type to the first of anyOf 0 1`] = ` +{ + "type": [ + "string", + "null", + ], +} +`; + +exports[`Documentation helpers > depictNullable() > should add null type to the first of anyOf 1 1`] = ` +{ + "type": [ + "string", + "null", + ], +} +`; + +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1`] = ` +{ + "type": "null", +} +`; + exports[`Documentation helpers > depictParamExamples() > should pass examples for the given parameter 1`] = ` { "example1": { @@ -45,6 +237,25 @@ exports[`Documentation helpers > depictParamExamples() > should pass examples fo } `; +exports[`Documentation helpers > depictPipeline > should depict as 'number (out)' 1`] = ` +{ + "type": "number", +} +`; + +exports[`Documentation helpers > depictPipeline > should depict as 'string (preprocess)' 1`] = ` +{ + "format": "string (preprocessed)", +} +`; + +exports[`Documentation helpers > depictRaw() > should extract the raw property 1`] = ` +{ + "format": "binary", + "type": "string", +} +`; + exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: should depict header params when enabled 1`] = ` [ { @@ -383,6 +594,44 @@ exports[`Documentation helpers > depictTags() > should accept plain descriptions ] `; +exports[`Documentation helpers > depictTuple() > should add items:not:{} when no rest 0 1`] = ` +{ + "items": { + "not": {}, + }, +} +`; + +exports[`Documentation helpers > depictTuple() > should add items:not:{} when no rest 1 1`] = ` +{ + "items": { + "not": {}, + }, +} +`; + +exports[`Documentation helpers > depictUnion() > should set discriminator prop for such union 1`] = ` +{ + "discriminator": { + "propertyName": "status", + }, +} +`; + +exports[`Documentation helpers > depictUpload() > should set format:binary and type:string 1`] = ` +{ + "format": "binary", + "type": "string", +} +`; + +exports[`Documentation helpers > depictUpload() > should throw when using in response 1`] = ` +DocumentationError({ + "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", + "message": "Please use ez.upload() only for input.", +}) +`; + exports[`Documentation helpers > excludeExamplesFromDepiction() > should remove example property of supplied object 1`] = ` { "description": "test", @@ -479,252 +728,3 @@ exports[`Documentation helpers > excludeParamsFromDepiction() > should omit spec `; exports[`Documentation helpers > excludeParamsFromDepiction() > should omit specified params 3 2`] = `false`; - -exports[`Documentation helpers > onBigInt() > should set type:string and format:bigint 1`] = ` -{ - "format": "bigint", - "pattern": "^-?\\d+$", - "type": "string", -} -`; - -exports[`Documentation helpers > onDateIn > should set type:string, pattern and format 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 > onDateIn > should throw when ZodDateIn in response 1`] = ` -DocumentationError({ - "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Please use ez.dateOut() for output.", -}) -`; - -exports[`Documentation helpers > onDateOut > should set type:string, description and format 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 > onDateOut > should throw when ZodDateOut in request 1`] = ` -DocumentationError({ - "cause": "Input schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Please use ez.dateIn() for input.", -}) -`; - -exports[`Documentation helpers > onDefault() > Feature #1706: should override the default value by a label from metadata 1`] = ` -{ - "default": "Today", - "format": "date-time", -} -`; - -exports[`Documentation helpers > onFile() > should set type:string and format accordingly 0 1`] = ` -{ - "format": "file", - "type": "string", -} -`; - -exports[`Documentation helpers > onFile() > should set type:string and format accordingly 1 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > onFile() > should set type:string and format accordingly 2 1`] = ` -{ - "format": "byte", - "type": "string", -} -`; - -exports[`Documentation helpers > onFile() > should set type:string and format accordingly 3 1`] = ` -{ - "format": "file", - "type": "string", -} -`; - -exports[`Documentation helpers > onFile() > should set type:string and format accordingly 4 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > onIntersection() > should NOT flatten object schemas having conflicting props 1`] = ` -{ - "allOf": [ - { - "properties": { - "one": { - "type": "number", - }, - }, - "type": "object", - }, - { - "properties": { - "one": { - "type": "string", - }, - }, - "type": "object", - }, - ], -} -`; - -exports[`Documentation helpers > onIntersection() > should flatten objects with same prop of same type 1`] = ` -{ - "properties": { - "one": { - "type": "number", - }, - }, - "type": "object", -} -`; - -exports[`Documentation helpers > onIntersection() > should flatten two object schemas 1`] = ` -{ - "description": "some", - "properties": { - "one": { - "type": "number", - }, - "two": { - "type": "number", - }, - }, - "type": "object", -} -`; - -exports[`Documentation helpers > onIntersection() > should maintain uniqueness in the array of required props 1`] = ` -{ - "properties": { - "test": { - "const": 5, - "type": "number", - }, - }, - "type": "object", -} -`; - -exports[`Documentation helpers > onIntersection() > should merge examples deeply 1`] = ` -{ - "examples": [ - { - "a": 123, - "b": 456, - }, - ], - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "number", - }, - }, - "type": "object", -} -`; - -exports[`Documentation helpers > onNullable() > should add null type to the first of anyOf 0 1`] = ` -{ - "type": [ - "string", - "null", - ], -} -`; - -exports[`Documentation helpers > onNullable() > should add null type to the first of anyOf 1 1`] = ` -{ - "type": [ - "string", - "null", - ], -} -`; - -exports[`Documentation helpers > onNullable() > should not add null type when it's already there 1`] = ` -{ - "type": "null", -} -`; - -exports[`Documentation helpers > onPipeline > should depict as 'number (out)' 1`] = ` -{ - "type": "number", -} -`; - -exports[`Documentation helpers > onPipeline > should depict as 'string (preprocess)' 1`] = ` -{ - "format": "string (preprocessed)", -} -`; - -exports[`Documentation helpers > onRaw() > should extract the raw property 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > onTuple() > should add items:not:{} when no rest 0 1`] = ` -{ - "items": { - "not": {}, - }, -} -`; - -exports[`Documentation helpers > onTuple() > should add items:not:{} when no rest 1 1`] = ` -{ - "items": { - "not": {}, - }, -} -`; - -exports[`Documentation helpers > onUnion() > should set discriminator prop for such union 1`] = ` -{ - "discriminator": { - "propertyName": "status", - }, -} -`; - -exports[`Documentation helpers > onUpload() > should set format:binary and type:string 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > onUpload() > should throw when using in response 1`] = ` -DocumentationError({ - "cause": "Response schema of an Endpoint assigned to GET method of /v1/user/:id path.", - "message": "Please use ez.upload() only for input.", -}) -`; diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 2127726e8..2b58422c4 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -15,18 +15,18 @@ import { excludeParamsFromDepiction, defaultIsHeader, reformatParamsInPath, - onNullable, - onDefault, - onRaw, - onUpload, - onFile, - onUnion, - onIntersection, - onBigInt, - onTuple, - onPipeline, - onDateIn, - onDateOut, + depictNullable, + depictDefault, + depictRaw, + depictUpload, + depictFile, + depictUnion, + depictIntersection, + depictBigInt, + depictTuple, + depictPipeline, + depictDateIn, + depictDateOut, } from "../src/documentation-helpers"; describe("Documentation helpers", () => { @@ -117,7 +117,7 @@ describe("Documentation helpers", () => { }); }); - describe("onDefault()", () => { + describe("depictDefault()", () => { test("Feature #1706: should override the default value by a label from metadata", () => { const zodSchema = z.iso .datetime() @@ -128,37 +128,37 @@ describe("Documentation helpers", () => { format: "date-time", }; expect( - onDefault({ zodSchema, jsonSchema }, responseCtx), + depictDefault({ zodSchema, jsonSchema }, responseCtx), ).toMatchSnapshot(); }); }); - describe("onRaw()", () => { + describe("depictRaw()", () => { test("should extract the raw property", () => { const jsonSchema: JSONSchema.BaseSchema = { type: "object", properties: { raw: { format: "binary", type: "string" } }, }; expect( - onRaw({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictRaw({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); }); - describe("onUpload()", () => { + describe("depictUpload()", () => { test("should set format:binary and type:string", () => { expect( - onUpload({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), + depictUpload({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), ).toMatchSnapshot(); }); test("should throw when using in response", () => { expect(() => - onUpload({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), + depictUpload({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); - describe("onFile()", () => { + describe("depictFile()", () => { test.each([ { type: "string" }, { anyOf: [{}, { type: "string" }] }, @@ -167,12 +167,12 @@ describe("Documentation helpers", () => { {}, ])("should set type:string and format accordingly %#", (jsonSchema) => { expect( - onFile({ zodSchema: z.never(), jsonSchema }, responseCtx), + depictFile({ zodSchema: z.never(), jsonSchema }, responseCtx), ).toMatchSnapshot(); }); }); - describe("onUnion()", () => { + describe("depictUnion()", () => { test("should set discriminator prop for such union", () => { const zodSchema = z.discriminatedUnion([ z.object({ status: z.literal("success"), data: z.any() }), @@ -182,12 +182,12 @@ describe("Documentation helpers", () => { }), ]); expect( - onUnion({ zodSchema, jsonSchema: {} }, requestCtx), + depictUnion({ zodSchema, jsonSchema: {} }, requestCtx), ).toMatchSnapshot(); }); }); - describe("onIntersection()", () => { + describe("depictIntersection()", () => { test("should flatten two object schemas", () => { const jsonSchema: JSONSchema.BaseSchema = { allOf: [ @@ -200,7 +200,7 @@ describe("Documentation helpers", () => { ], }; expect( - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); @@ -212,7 +212,7 @@ describe("Documentation helpers", () => { ], }; expect( - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); @@ -224,7 +224,7 @@ describe("Documentation helpers", () => { ], }; expect( - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); @@ -244,7 +244,7 @@ describe("Documentation helpers", () => { ], }; expect( - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); @@ -256,7 +256,7 @@ describe("Documentation helpers", () => { ], }; expect( - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); @@ -280,12 +280,12 @@ describe("Documentation helpers", () => { }, ])("should fall back to allOf in other cases %#", (jsonSchema) => { expect( - onIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictIntersection({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toHaveProperty("allOf"); }); }); - describe("onNullable()", () => { + describe("depictNullable()", () => { test.each([requestCtx, responseCtx])( "should add null type to the first of anyOf %#", (ctx) => { @@ -293,7 +293,7 @@ describe("Documentation helpers", () => { anyOf: [{ type: "string" }, { type: "null" }], }; expect( - onNullable({ zodSchema: z.never(), jsonSchema }, ctx), + depictNullable({ zodSchema: z.never(), jsonSchema }, ctx), ).toMatchSnapshot(); }, ); @@ -303,31 +303,31 @@ describe("Documentation helpers", () => { anyOf: [{ type: "null" }, { type: "null" }], }; expect( - onNullable({ zodSchema: z.never(), jsonSchema }, requestCtx), + depictNullable({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); }); }); - describe("onBigInt()", () => { + describe("depictBigInt()", () => { test("should set type:string and format:bigint", () => { expect( - onBigInt({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), + depictBigInt({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), ).toMatchSnapshot(); }); }); - describe("onTuple()", () => { + describe("depictTuple()", () => { test.each([ z.tuple([z.boolean(), z.string(), z.literal("test")]), z.tuple([]), ])("should add items:not:{} when no rest %#", (zodSchema) => { expect( - onTuple({ zodSchema, jsonSchema: {} }, requestCtx), + depictTuple({ zodSchema, jsonSchema: {} }, requestCtx), ).toMatchSnapshot(); }); }); - describe("onPipeline", () => { + describe("depictPipeline", () => { test.each([ { zodSchema: z.string().transform((v) => parseInt(v, 10)), @@ -340,16 +340,18 @@ describe("Documentation helpers", () => { expected: "string (preprocess)", }, ])("should depict as $expected", ({ zodSchema, ctx }) => { - expect(onPipeline({ zodSchema, jsonSchema: {} }, ctx)).toMatchSnapshot(); + expect( + depictPipeline({ zodSchema, jsonSchema: {} }, ctx), + ).toMatchSnapshot(); }); test.each([ z.number().transform((num) => () => num), z.number().transform(() => assert.fail("this should be handled")), ])("should handle edge cases %#", (zodSchema) => { - expect(onPipeline({ zodSchema, jsonSchema: {} }, responseCtx)).toEqual( - {}, - ); + expect( + depictPipeline({ zodSchema, jsonSchema: {} }, responseCtx), + ).toEqual({}); }); }); @@ -500,10 +502,10 @@ describe("Documentation helpers", () => { }); }); - describe("onDateIn", () => { + describe("depictDateIn", () => { test("should set type:string, pattern and format", () => { expect( - onDateIn( + depictDateIn( { zodSchema: z.never(), jsonSchema: { anyOf: [] } }, requestCtx, ), @@ -511,20 +513,20 @@ describe("Documentation helpers", () => { }); test("should throw when ZodDateIn in response", () => { expect(() => - onDateIn({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), + depictDateIn({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), ).toThrowErrorMatchingSnapshot(); }); }); - describe("onDateOut", () => { + describe("depictDateOut", () => { test("should set type:string, description and format", () => { expect( - onDateOut({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), + depictDateOut({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), ).toMatchSnapshot(); }); test("should throw when ZodDateOut in request", () => { expect(() => - onDateOut({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), + depictDateOut({ zodSchema: z.never(), jsonSchema: {} }, requestCtx), ).toThrowErrorMatchingSnapshot(); }); }); From 022677769c9b537d2a188e7f452abea6ba13513a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 10:59:19 +0200 Subject: [PATCH 072/187] Revert "Ref: DNRY isObject in depictExamples and depictParamExamples." This reverts commit a9ca7d5b438639fcd7cfbcadb35f65a5eed74141. --- express-zod-api/src/documentation-helpers.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 38f7f3f46..dbca77f1d 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -27,6 +27,7 @@ import { ResponseVariant } from "./api-response"; import { combinations, doesAccept, + FlatObject, getExamples, getRoutePathParams, getTransformedType, @@ -342,10 +343,12 @@ export const depictExamples = ( schema: $ZodType, isResponse: boolean, omitProps: string[] = [], -): ExamplesObject | undefined => - R.pipe( +): ExamplesObject | undefined => { + const isObject = (subj: unknown): subj is FlatObject => + R.type(subj) === "Object"; + return R.pipe( getExamples, - R.map(R.when(R.is(Object), R.omit(omitProps))), + R.map(R.when(isObject, R.omit(omitProps))), enumerateExamples, )({ schema, @@ -353,17 +356,21 @@ export const depictExamples = ( validate: true, pullProps: true, }); +}; export const depictParamExamples = ( schema: z.ZodType, param: string, -): ExamplesObject | undefined => - R.pipe( +): ExamplesObject | undefined => { + const isObject = (subj: unknown): subj is FlatObject => + R.type(subj) === "Object"; + return R.pipe( getExamples, - R.filter(R.both(R.is(Object), R.has(param))), + R.filter(R.both(isObject, R.has(param))), R.pluck(param), enumerateExamples, )({ schema, variant: "original", validate: true, pullProps: true }); +}; export const defaultIsHeader = ( name: string, From ef468e31d18a6792dd61775b3bec6aeace3e230a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 11:23:46 +0200 Subject: [PATCH 073/187] Ref: DNRY isObject in depictExamples and depictParamExamples. --- express-zod-api/src/documentation-helpers.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index dbca77f1d..2bfe1dd4d 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -31,6 +31,7 @@ import { getExamples, getRoutePathParams, getTransformedType, + isObject, isSchema, makeCleanId, routePathParamsRegex, @@ -343,12 +344,15 @@ export const depictExamples = ( schema: $ZodType, isResponse: boolean, omitProps: string[] = [], -): ExamplesObject | undefined => { - const isObject = (subj: unknown): subj is FlatObject => - R.type(subj) === "Object"; - return R.pipe( +): ExamplesObject | undefined => + R.pipe( getExamples, - R.map(R.when(isObject, R.omit(omitProps))), + R.map( + R.when( + (one): one is FlatObject => isObject(one) && !Array.isArray(one), + R.omit(omitProps), + ), + ), enumerateExamples, )({ schema, @@ -356,14 +360,11 @@ export const depictExamples = ( validate: true, pullProps: true, }); -}; export const depictParamExamples = ( schema: z.ZodType, param: string, ): ExamplesObject | undefined => { - const isObject = (subj: unknown): subj is FlatObject => - R.type(subj) === "Object"; return R.pipe( getExamples, R.filter(R.both(isObject, R.has(param))), From b308d7c13ed9585aa2fb8c6da68cf46e7f36d5f1 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 11:28:32 +0200 Subject: [PATCH 074/187] Reusing isObject for intersect(). --- express-zod-api/src/documentation-helpers.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 2bfe1dd4d..bd4966e7e 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -149,10 +149,8 @@ const approaches = { required: ({ required: left = [] }, { required: right = [] }) => R.union(left, right), examples: ({ examples: left = [] }, { examples: right = [] }) => - combinations( - left.filter((entry) => typeof entry === "object"), - right.filter((entry) => typeof entry === "object"), - ([a, b]) => R.mergeDeepRight({ ...a }, { ...b }), + combinations(left.filter(isObject), right.filter(isObject), ([a, b]) => + R.mergeDeepRight({ ...a }, { ...b }), ), description: ({ description: left }, { description: right }) => left || right, } satisfies { From ef3ddf24eed18d914a3e526c6d18b598feb1ec19 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 12:14:20 +0200 Subject: [PATCH 075/187] Ref: depictIntersection using R.tryCatch. --- express-zod-api/src/documentation-helpers.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index bd4966e7e..5b8fd979e 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -181,14 +181,13 @@ const intersect = ( return R.map((fn) => fn(left, right), suitable); }; -export const depictIntersection: Depicter = ({ jsonSchema }) => { - if (jsonSchema.allOf) { - try { - return intersect(jsonSchema.allOf); - } catch {} - } - return jsonSchema; -}; +export const depictIntersection = R.tryCatch( + ({ jsonSchema }) => { + if (!jsonSchema.allOf) throw new Error("Missing allOf"); + return intersect(jsonSchema.allOf); + }, + (_err, { jsonSchema }) => jsonSchema, +); /** @since OAS 3.1 nullable replaced with type array having null */ export const depictNullable: Depicter = ({ jsonSchema }) => { From 7c3520cf89fed8b49b34ae667c48893ad76ae444 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 12:20:16 +0200 Subject: [PATCH 076/187] Ref: DNRY for ensureCompliance(). --- express-zod-api/src/documentation-helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 5b8fd979e..66f101e34 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -217,9 +217,9 @@ const ensureCompliance = ({ : undefined, ...rest, }; - if (allOf) valid.allOf = allOf.map(ensureCompliance); - if (oneOf) valid.oneOf = oneOf.map(ensureCompliance); - if (anyOf) valid.anyOf = anyOf.map(ensureCompliance); + // eslint-disable-next-line no-restricted-syntax -- need typed key here + for (const [prop, entry] of R.toPairs({ allOf, oneOf, anyOf })) + if (entry) valid[prop] = entry.map(ensureCompliance); if (not) valid.not = ensureCompliance(not); return valid; }; From 8379da4c6d22f0b4c282658a65a91cf84c9ae5ad Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 12:43:20 +0200 Subject: [PATCH 077/187] Ref: simpler type for makeRef in ZTS context. --- express-zod-api/src/integration.ts | 17 +++++------------ express-zod-api/src/zts-helpers.ts | 6 +----- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 6f59f769e..8910163a5 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -1,4 +1,3 @@ -import type { $ZodType } from "@zod/core"; import * as R from "ramda"; import ts from "typescript"; import { z } from "zod"; @@ -67,22 +66,16 @@ interface FormattedPrintingOptions { export class Integration extends IntegrationBase { readonly #program: ts.Node[] = [this.someOfType]; - readonly #aliases = new Map< - $ZodType | (() => $ZodType), - ts.TypeAliasDeclaration - >(); + readonly #aliases = new Map(); #usage: Array = []; - #makeAlias( - schema: $ZodType | (() => $ZodType), - produce: () => ts.TypeNode, - ): ts.TypeNode { - let name = this.#aliases.get(schema)?.name?.text; + #makeAlias(key: object, produce: () => ts.TypeNode): ts.TypeNode { + let name = this.#aliases.get(key)?.name?.text; if (!name) { name = `Type${this.#aliases.size + 1}`; const temp = makeLiteralType(null); - this.#aliases.set(schema, makeType(name, temp)); - this.#aliases.set(schema, makeType(name, produce())); + this.#aliases.set(key, makeType(name, temp)); + this.#aliases.set(key, makeType(name, produce())); } return ensureTypeNode(name); } diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index 53261c7d7..484c68fb0 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -1,14 +1,10 @@ -import type { $ZodType } from "@zod/core"; import type ts from "typescript"; import { FlatObject } from "./common-helpers"; import { SchemaHandler } from "./schema-walker"; export interface ZTSContext extends FlatObject { isResponse: boolean; - makeAlias: ( - schema: $ZodType | (() => $ZodType), - produce: () => ts.TypeNode, - ) => ts.TypeNode; + makeAlias: (key: object, produce: () => ts.TypeNode) => ts.TypeNode; } export type Producer = SchemaHandler; From 34313f04d53abe533615a5cf6635abaad3525df6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 13:11:23 +0200 Subject: [PATCH 078/187] Ref: brand extraction in hasNestedSchema. --- express-zod-api/src/deep-checks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index db767cf53..779938286 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -87,7 +87,7 @@ export const hasNestedSchema = ( ): boolean => { if (condition?.(subject)) return true; if (depth >= maxDepth) return false; - const brand = globalRegistry.get(subject)?.[metaSymbol]?.brand; + const { brand } = globalRegistry.get(subject)?.[metaSymbol] ?? {}; const handler = brand && brand in rules ? rules[brand as keyof typeof rules] From c8283da5f382ae722b82a010c42bd5a0499ceddc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 28 Apr 2025 14:25:11 +0200 Subject: [PATCH 079/187] FIX: handling interfaces incl. circular ones by deep checks. --- express-zod-api/src/deep-checks.ts | 32 ++++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index 779938286..e79964364 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -3,6 +3,7 @@ import type { $ZodCatch, $ZodDefault, $ZodDiscriminatedUnion, + $ZodInterface, $ZodIntersection, $ZodLazy, $ZodNullable, @@ -17,7 +18,6 @@ import type { } from "@zod/core"; import { fail } from "node:assert/strict"; // eslint-disable-line no-restricted-syntax -- acceptable import { globalRegistry } from "zod"; -import { EmptyObject } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { ezFileBrand } from "./file-schema"; @@ -34,8 +34,9 @@ import { } from "./schema-walker"; import { ezUploadBrand } from "./upload-schema"; +type CheckContext = { visited: WeakSet }; /** @desc Check is a schema handling rule returning boolean */ -type Check = SchemaHandler; +type Check = SchemaHandler; const onSomeUnion: Check = ( { _zod }: $ZodUnion | $ZodDiscriminatedUnion, @@ -52,9 +53,13 @@ const onWrapped: Check = ( { next }, ) => next(def.innerType); -const ioChecks: HandlingRules = { +const ioChecks: HandlingRules = { object: ({ _zod }: $ZodObject, { next }) => Object.values(_zod.def.shape).some(next), + interface: (int: $ZodInterface, { next, visited }) => + visited.has(int) + ? false + : visited.add(int) && Object.values(int._zod.def.shape).some(next), union: onSomeUnion, intersection: onIntersection, optional: onWrapped, @@ -64,11 +69,11 @@ const ioChecks: HandlingRules = { array: ({ _zod }: $ZodArray, { next }) => next(_zod.def.element), }; -interface NestedSchemaLookupProps { +interface NestedSchemaLookupProps extends Partial { condition?: (schema: $ZodType) => boolean; rules?: HandlingRules< boolean, - EmptyObject, + CheckContext, FirstPartyKind | ProprietaryBrand >; maxDepth?: number; @@ -83,6 +88,7 @@ export const hasNestedSchema = ( rules = ioChecks, depth = 1, maxDepth = Number.POSITIVE_INFINITY, + visited = new WeakSet(), }: NestedSchemaLookupProps, ): boolean => { if (condition?.(subject)) return true; @@ -94,14 +100,16 @@ export const hasNestedSchema = ( : rules[subject._zod.def.type]; if (handler) { return handler(subject, { + visited, next: (schema) => hasNestedSchema(schema, { condition, rules, maxDepth, + visited, depth: depth + 1, }), - } as EmptyObject & NextHandlerInc); + } as CheckContext & NextHandlerInc); } return false; }; @@ -131,19 +139,18 @@ export const hasForm = (subject: IOSchema) => }); /** @throws AssertionError with incompatible schema constructor */ -export const assertJsonCompatible = (subject: $ZodType, dir: "in" | "out") => { - const lazies = new WeakSet<() => $ZodType>(); - return hasNestedSchema(subject, { +export const assertJsonCompatible = (subject: $ZodType, dir: "in" | "out") => + hasNestedSchema(subject, { maxDepth: 300, rules: { ...ioChecks, readonly: onWrapped, catch: onWrapped, pipe: ({ _zod }: $ZodPipe, { next }) => next(_zod.def[dir]), - lazy: ({ _zod: { def } }: $ZodLazy, { next }) => - lazies.has(def.getter) + lazy: ({ _zod: { def } }: $ZodLazy, { next, visited }) => + visited.has(def.getter) ? false - : lazies.add(def.getter) && next(def.getter()), + : visited.add(def.getter) && next(def.getter()), tuple: ({ _zod: { def } }: $ZodTuple, { next }) => [...def.items].concat(def.rest ?? []).some(next), nan: () => fail("z.nan()"), @@ -162,4 +169,3 @@ export const assertJsonCompatible = (subject: $ZodType, dir: "in" | "out") => { [ezFileBrand]: () => false, }, }); -}; From 9051ed449ab163eacc71d0364c441edf27d2c8a2 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 28 Apr 2025 18:49:51 +0200 Subject: [PATCH 080/187] Deep checks using `toJSONSchema` (#2589) Addressing: - the need to handle pipes being aware of direction - the requirement to check JSON compatibility Revealed during review of #2546 --- express-zod-api/src/deep-checks.ts | 218 +++++++--------------- express-zod-api/src/diagnostics.ts | 26 ++- express-zod-api/src/endpoint.ts | 23 ++- express-zod-api/src/errors.ts | 9 + express-zod-api/tests/deep-checks.spec.ts | 23 ++- express-zod-api/tests/errors.spec.ts | 19 ++ express-zod-api/tests/routing.spec.ts | 4 +- 7 files changed, 135 insertions(+), 187 deletions(-) diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index e79964364..a1654109c 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -1,171 +1,81 @@ -import type { - $ZodArray, - $ZodCatch, - $ZodDefault, - $ZodDiscriminatedUnion, - $ZodInterface, - $ZodIntersection, - $ZodLazy, - $ZodNullable, - $ZodObject, - $ZodOptional, - $ZodPipe, - $ZodReadonly, - $ZodRecord, - $ZodTuple, - $ZodType, - $ZodUnion, -} from "@zod/core"; -import { fail } from "node:assert/strict"; // eslint-disable-line no-restricted-syntax -- acceptable -import { globalRegistry } from "zod"; +import type { $ZodType } from "@zod/core"; +import * as R from "ramda"; +import { globalRegistry, z } from "zod"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; -import { ezFileBrand } from "./file-schema"; +import { DeepCheckError } from "./errors"; import { ezFormBrand } from "./form-schema"; import { IOSchema } from "./io-schema"; import { metaSymbol } from "./metadata"; -import { ProprietaryBrand } from "./proprietary-schemas"; -import { ezRawBrand } from "./raw-schema"; -import { - FirstPartyKind, - HandlingRules, - NextHandlerInc, - SchemaHandler, -} from "./schema-walker"; +import { FirstPartyKind } from "./schema-walker"; import { ezUploadBrand } from "./upload-schema"; +import { ezRawBrand } from "./raw-schema"; -type CheckContext = { visited: WeakSet }; -/** @desc Check is a schema handling rule returning boolean */ -type Check = SchemaHandler; - -const onSomeUnion: Check = ( - { _zod }: $ZodUnion | $ZodDiscriminatedUnion, - { next }, -) => _zod.def.options.some(next); - -const onIntersection: Check = ({ _zod }: $ZodIntersection, { next }) => - [_zod.def.left, _zod.def.right].some(next); - -const onWrapped: Check = ( - { - _zod: { def }, - }: $ZodOptional | $ZodNullable | $ZodReadonly | $ZodDefault | $ZodCatch, - { next }, -) => next(def.innerType); - -const ioChecks: HandlingRules = { - object: ({ _zod }: $ZodObject, { next }) => - Object.values(_zod.def.shape).some(next), - interface: (int: $ZodInterface, { next, visited }) => - visited.has(int) - ? false - : visited.add(int) && Object.values(int._zod.def.shape).some(next), - union: onSomeUnion, - intersection: onIntersection, - optional: onWrapped, - nullable: onWrapped, - default: onWrapped, - record: ({ _zod }: $ZodRecord, { next }) => next(_zod.def.valueType), - array: ({ _zod }: $ZodArray, { next }) => next(_zod.def.element), -}; - -interface NestedSchemaLookupProps extends Partial { - condition?: (schema: $ZodType) => boolean; - rules?: HandlingRules< - boolean, - CheckContext, - FirstPartyKind | ProprietaryBrand - >; - maxDepth?: number; - depth?: number; +interface NestedSchemaLookupProps { + io: "input" | "output"; + condition: (zodSchema: $ZodType) => boolean; } -/** @desc The optimized version of the schema walker for boolean checks */ -export const hasNestedSchema = ( +export const findNestedSchema = ( subject: $ZodType, - { - condition, - rules = ioChecks, - depth = 1, - maxDepth = Number.POSITIVE_INFINITY, - visited = new WeakSet(), - }: NestedSchemaLookupProps, -): boolean => { - if (condition?.(subject)) return true; - if (depth >= maxDepth) return false; - const { brand } = globalRegistry.get(subject)?.[metaSymbol] ?? {}; - const handler = - brand && brand in rules - ? rules[brand as keyof typeof rules] - : rules[subject._zod.def.type]; - if (handler) { - return handler(subject, { - visited, - next: (schema) => - hasNestedSchema(schema, { - condition, - rules, - maxDepth, - visited, - depth: depth + 1, - }), - } as CheckContext & NextHandlerInc); - } - return false; -}; - -export const hasUpload = (subject: IOSchema) => - hasNestedSchema(subject, { - condition: (schema) => - globalRegistry.get(schema)?.[metaSymbol]?.brand === ezUploadBrand, - rules: { - ...ioChecks, - [ezFormBrand]: ioChecks.object, + { io, condition }: NestedSchemaLookupProps, +) => + R.tryCatch( + () => { + z.toJSONSchema(subject, { + io, + unrepresentable: "any", + override: ({ zodSchema }) => { + if (condition(zodSchema)) throw new DeepCheckError(zodSchema); // exits early + }, + }); + return undefined; }, - }); + (err: DeepCheckError) => err.cause, + )(); -export const hasRaw = (subject: IOSchema) => - hasNestedSchema(subject, { - condition: (schema) => - globalRegistry.get(schema)?.[metaSymbol]?.brand === ezRawBrand, - maxDepth: 3, +export const findRequestTypeDefiningSchema = (subject: IOSchema) => + findNestedSchema(subject, { + condition: (schema) => { + const { brand } = globalRegistry.get(schema)?.[metaSymbol] || {}; + return ( + typeof brand === "symbol" && + [ezUploadBrand, ezRawBrand, ezFormBrand].includes(brand) + ); + }, + io: "input", }); -export const hasForm = (subject: IOSchema) => - hasNestedSchema(subject, { - condition: (schema) => - globalRegistry.get(schema)?.[metaSymbol]?.brand === ezFormBrand, - maxDepth: 3, - }); +const unsupported: FirstPartyKind[] = [ + "nan", + "symbol", + "map", + "set", + "bigint", + "void", + "promise", + "never", +]; -/** @throws AssertionError with incompatible schema constructor */ -export const assertJsonCompatible = (subject: $ZodType, dir: "in" | "out") => - hasNestedSchema(subject, { - maxDepth: 300, - rules: { - ...ioChecks, - readonly: onWrapped, - catch: onWrapped, - pipe: ({ _zod }: $ZodPipe, { next }) => next(_zod.def[dir]), - lazy: ({ _zod: { def } }: $ZodLazy, { next, visited }) => - visited.has(def.getter) - ? false - : visited.add(def.getter) && next(def.getter()), - tuple: ({ _zod: { def } }: $ZodTuple, { next }) => - [...def.items].concat(def.rest ?? []).some(next), - nan: () => fail("z.nan()"), - symbol: () => fail("z.symbol()"), - map: () => fail("z.map()"), - set: () => fail("z.set()"), - bigint: () => fail("z.bigint()"), - void: () => fail("z.void()"), - promise: () => fail("z.promise()"), - never: () => fail("z.never()"), - date: () => dir === "in" && fail("z.date()"), - [ezDateOutBrand]: () => dir === "in" && fail("ez.dateOut()"), - [ezDateInBrand]: () => dir === "out" && fail("ez.dateIn()"), - [ezRawBrand]: () => dir === "out" && fail("ez.raw()"), - [ezUploadBrand]: () => dir === "out" && fail("ez.upload()"), - [ezFileBrand]: () => false, +export const findJsonIncompatible = ( + subject: $ZodType, + io: "input" | "output", +) => + findNestedSchema(subject, { + io, + condition: (zodSchema) => { + const { brand } = globalRegistry.get(zodSchema)?.[metaSymbol] || {}; + const { type } = zodSchema._zod.def; + if (unsupported.includes(type)) return true; + if (io === "input") { + if (type === "date") return true; + if (brand === ezDateOutBrand) return true; + } + if (io === "output") { + if (brand === ezDateInBrand) return true; + if (brand === ezRawBrand) return true; + if (brand === ezUploadBrand) return true; + } + return false; }, }); diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index b7092997b..86408dad6 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -1,17 +1,14 @@ -import * as R from "ramda"; import type { $ZodShape } from "@zod/core"; import { z } from "zod"; import { responseVariants } from "./api-response"; import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; -import { assertJsonCompatible } from "./deep-checks"; +import { findJsonIncompatible } from "./deep-checks"; import { AbstractEndpoint } from "./endpoint"; import { extractObjectSchema } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; export class Diagnostics { - /** @desc (catcher)(...args) => bool | ReturnValue */ - readonly #trier = R.tryCatch(assertJsonCompatible); #verifiedEndpoints = new WeakSet(); #verifiedPaths = new WeakMap< AbstractEndpoint, @@ -35,23 +32,24 @@ export class Diagnostics { } } if (endpoint.requestType === "json") { - this.#trier((reason) => + const reason = findJsonIncompatible(endpoint.inputSchema, "input"); + if (reason) { this.logger.warn( "The final input schema of the endpoint contains an unsupported JSON payload type.", Object.assign(ctx, { reason }), - ), - )(endpoint.inputSchema, "in"); + ); + } } for (const variant of responseVariants) { - const catcher = this.#trier((reason) => - this.logger.warn( - `The final ${variant} response schema of the endpoint contains an unsupported JSON payload type.`, - Object.assign(ctx, { reason }), - ), - ); for (const { mimeTypes, schema } of endpoint.getResponses(variant)) { if (!mimeTypes?.includes(contentTypes.json)) continue; - catcher(schema, "out"); + const reason = findJsonIncompatible(schema, "output"); + if (reason) { + this.logger.warn( + `The final ${variant} response schema of the endpoint contains an unsupported JSON payload type.`, + Object.assign(ctx, { reason }), + ); + } } } this.#verifiedEndpoints.add(endpoint); diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index 5527060c7..a62a9c650 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -1,8 +1,8 @@ import { Request, Response } from "express"; import * as R from "ramda"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { NormalizedResponse, ResponseVariant } from "./api-response"; -import { hasForm, hasRaw, hasUpload } from "./deep-checks"; +import { findRequestTypeDefiningSchema } from "./deep-checks"; import { FlatObject, getActualMethod, @@ -15,16 +15,20 @@ import { OutputValidationError, ResultHandlerError, } from "./errors"; +import { ezFormBrand } from "./form-schema"; import { IOSchema } from "./io-schema"; import { lastResortHandler } from "./last-resort"; import { ActualLogger } from "./logger-helpers"; import { LogicalContainer } from "./logical-container"; +import { metaSymbol } from "./metadata"; import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; +import { ezRawBrand } from "./raw-schema"; import { Routable } from "./routable"; import { AbstractResultHandler } from "./result-handler"; import { Security } from "./security"; +import { ezUploadBrand } from "./upload-schema"; export type Handler = (params: { /** @desc The inputs from the enabled input sources validated against the final input schema (incl. Middlewares) */ @@ -137,13 +141,14 @@ export class Endpoint< /** @internal */ public override get requestType() { - return hasUpload(this.#def.inputSchema) - ? "upload" - : hasRaw(this.#def.inputSchema) - ? "raw" - : hasForm(this.#def.inputSchema) - ? "form" - : "json"; + const found = findRequestTypeDefiningSchema(this.#def.inputSchema); + if (found) { + const { brand } = globalRegistry.get(found)?.[metaSymbol] || {}; + if (brand === ezUploadBrand) return "upload"; + if (brand === ezRawBrand) return "raw"; + if (brand === ezFormBrand) return "form"; + } + return "json"; } /** @internal */ diff --git a/express-zod-api/src/errors.ts b/express-zod-api/src/errors.ts index 39794d62e..d152c98a2 100644 --- a/express-zod-api/src/errors.ts +++ b/express-zod-api/src/errors.ts @@ -1,3 +1,4 @@ +import type { $ZodType } from "@zod/core"; import { z } from "zod"; import { getMessageFromError } from "./common-helpers"; import { OpenAPIContext } from "./documentation-helpers"; @@ -34,6 +35,14 @@ export class IOSchemaError extends Error { public override name = "IOSchemaError"; } +export class DeepCheckError extends IOSchemaError { + public override name = "DeepCheckError"; + + constructor(public override readonly cause: $ZodType) { + super("Found", { cause }); + } +} + /** @desc An error of validating the Endpoint handler's returns against the Endpoint output schema */ export class OutputValidationError extends IOSchemaError { public override name = "OutputValidationError"; diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index 68f27c228..b140d6bc9 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -2,17 +2,19 @@ import { UploadedFile } from "express-fileupload"; import { globalRegistry, z } from "zod"; import type { $brand, $ZodType } from "@zod/core"; import { ez } from "../src"; -import { hasNestedSchema } from "../src/deep-checks"; +import { findNestedSchema } from "../src/deep-checks"; import { metaSymbol } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; describe("Checks", () => { - describe("hasNestedSchema()", () => { + describe("findNestedSchema()", () => { const condition = (subject: $ZodType) => globalRegistry.get(subject)?.[metaSymbol]?.brand === ezUploadBrand; test("should return true for given argument satisfying condition", () => { - expect(hasNestedSchema(ez.upload(), { condition })).toBeTruthy(); + expect( + findNestedSchema(ez.upload(), { condition, io: "input" }), + ).toBeTruthy(); }); test.each([ @@ -26,7 +28,9 @@ describe("Checks", () => { ez.upload().refine(() => true), z.array(ez.upload()), ])("should return true for wrapped needle %#", (subject) => { - expect(hasNestedSchema(subject, { condition })).toBeTruthy(); + expect( + findNestedSchema(subject, { condition, io: "input" }), + ).toBeTruthy(); }); test.each([ @@ -36,10 +40,12 @@ describe("Checks", () => { z.boolean().and(z.literal(true)), z.number().or(z.string()), ])("should return false in other cases %#", (subject) => { - expect(hasNestedSchema(subject, { condition })).toBeFalsy(); + expect( + findNestedSchema(subject, { condition, io: "input" }), + ).toBeUndefined(); }); - test("should finish early", () => { + test("should finish early (from bottom to top)", () => { const subject = z.object({ one: z.object({ two: z.object({ @@ -47,9 +53,10 @@ describe("Checks", () => { }), }), }); - const check = vi.fn((schema) => schema instanceof z.ZodObject); - hasNestedSchema(subject, { + const check = vi.fn((schema) => schema instanceof z.ZodNumber); + findNestedSchema(subject, { condition: check, + io: "input", }); expect(check.mock.calls.length).toBe(1); }); diff --git a/express-zod-api/tests/errors.spec.ts b/express-zod-api/tests/errors.spec.ts index 4e6aa2334..c594ceddf 100644 --- a/express-zod-api/tests/errors.spec.ts +++ b/express-zod-api/tests/errors.spec.ts @@ -6,6 +6,7 @@ import { MissingPeerError, OutputValidationError, ResultHandlerError, + DeepCheckError, } from "../src/errors"; describe("Errors", () => { @@ -59,6 +60,24 @@ describe("Errors", () => { }); }); + describe("DeepCheckError", () => { + const schema = z.any(); + const error = new DeepCheckError(schema); + + test("should be an instance of IOSchemaError and Error", () => { + expect(error).toBeInstanceOf(IOSchemaError); + expect(error).toBeInstanceOf(Error); + }); + + test("should have the name matching its class", () => { + expect(error.name).toBe("DeepCheckError"); + }); + + test("should have the cause matching the schema", () => { + expect(error.cause).toBe(schema); + }); + }); + describe("OutputValidationError", () => { const zodError = new z.ZodError([]); const error = new OutputValidationError(zodError); diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index d4baabe94..d0cb1d8cc 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -423,11 +423,11 @@ describe("Routing", () => { expect(logger._getLogs().warn).toEqual([ [ "The final input schema of the endpoint contains an unsupported JSON payload type.", - { method: "get", path: "/path", reason: expect.any(Error) }, + { method: "get", path: "/path", reason: expect.any(z.ZodType) }, ], [ "The final positive response schema of the endpoint contains an unsupported JSON payload type.", - { method: "get", path: "/path", reason: expect.any(Error) }, + { method: "get", path: "/path", reason: expect.any(z.ZodType) }, ], ]); }); From d3158f5b5b193945a95489990d9c9c747144f7bb Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 29 Apr 2025 11:48:25 +0200 Subject: [PATCH 081/187] Fix nullable literals and enums (#2593) Addressing issue found during the review of #2546 --- example/example.documentation.yaml | 16 ++ express-zod-api/src/documentation-helpers.ts | 12 ++ .../documentation-helpers.spec.ts.snap | 27 +++ .../__snapshots__/documentation.spec.ts.snap | 156 ++++++++++++++++-- .../tests/documentation-helpers.spec.ts | 24 +++ express-zod-api/tests/documentation.spec.ts | 6 + 6 files changed, 231 insertions(+), 10 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 612d9e264..d43165b6b 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -29,6 +29,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -57,6 +58,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -174,6 +176,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -211,6 +214,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -255,6 +259,7 @@ paths: type: object properties: status: + type: string const: created data: type: object @@ -275,6 +280,7 @@ paths: type: object properties: status: + type: string const: created data: type: object @@ -295,6 +301,7 @@ paths: type: object properties: status: + type: string const: error reason: type: string @@ -309,6 +316,7 @@ paths: type: object properties: status: + type: string const: exists id: type: integer @@ -325,6 +333,7 @@ paths: type: object properties: status: + type: string const: error reason: type: string @@ -455,6 +464,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -490,6 +500,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -529,6 +540,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -549,6 +561,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -593,6 +606,7 @@ paths: type: integer exclusiveMaximum: 9007199254740991 event: + type: string const: time id: type: string @@ -644,6 +658,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -664,6 +679,7 @@ paths: type: object properties: status: + type: string const: error error: type: object diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 78b3b4b81..ccee20d85 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -199,6 +199,16 @@ export const depictNullable: Depicter = ({ jsonSchema }) => { const isSupportedType = (subject: string): subject is SchemaObjectType => subject in samples; +export const depictEnum: Depicter = ({ jsonSchema }) => ({ + type: typeof jsonSchema.enum?.[0], + ...jsonSchema, +}); + +export const depictLiteral: Depicter = ({ jsonSchema }) => ({ + type: typeof (jsonSchema.const || jsonSchema.enum?.[0]), + ...jsonSchema, +}); + const ensureCompliance = ({ $ref, type, @@ -450,6 +460,8 @@ const depicters: Partial> = intersection: depictIntersection, tuple: depictTuple, pipe: depictPipeline, + literal: depictLiteral, + enum: depictEnum, [ezDateInBrand]: depictDateIn, [ezDateOutBrand]: depictDateOut, [ezUploadBrand]: depictUpload, 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 018c591b3..91089c014 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -52,6 +52,16 @@ exports[`Documentation helpers > depictDefault() > Feature #1706: should overrid } `; +exports[`Documentation helpers > depictEnum() > should set type 1`] = ` +{ + "enum": [ + "test", + "jest", + ], + "type": "string", +} +`; + exports[`Documentation helpers > depictExamples() > should 'pass' examples in case of 'request' 1`] = ` { "example1": { @@ -202,6 +212,23 @@ exports[`Documentation helpers > depictIntersection() > should merge examples de } `; +exports[`Documentation helpers > depictLiteral() > should set type from either const or enum prop 0 1`] = ` +{ + "const": "test", + "type": "string", +} +`; + +exports[`Documentation helpers > depictLiteral() > should set type from either const or enum prop 1 1`] = ` +{ + "enum": [ + "test", + "jest", + ], + "type": "string", +} +`; + exports[`Documentation helpers > depictNullable() > should add null type to the first of anyOf 0 1`] = ` { "type": [ diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 266f83dcf..c3d08b78d 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -20,6 +20,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -36,6 +37,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -89,6 +91,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -105,6 +108,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -143,6 +147,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -159,6 +164,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -212,6 +218,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -228,6 +235,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -259,6 +267,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -275,6 +284,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -342,6 +352,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -361,6 +372,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -402,6 +414,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -418,6 +431,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -457,6 +471,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -473,6 +488,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -543,6 +559,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -562,6 +579,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -639,11 +657,13 @@ paths: type: object properties: status: + type: string const: success data: type: object properties: literal: + type: string const: something transformation: type: number @@ -661,6 +681,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -714,6 +735,7 @@ paths: - type: object properties: type: + type: string const: a a: type: string @@ -723,6 +745,7 @@ paths: - type: object properties: type: + type: string const: b b: type: string @@ -739,12 +762,14 @@ paths: type: object properties: status: + type: string const: success data: anyOf: - type: object properties: status: + type: string const: success data: {} required: @@ -753,6 +778,7 @@ paths: - type: object properties: status: + type: string const: error error: type: object @@ -777,6 +803,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -848,6 +875,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -876,6 +904,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -959,6 +988,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -967,8 +997,29 @@ paths: type: - string - "null" + literal: + type: + - string + - "null" + const: test + multiliteral: + type: + - string + - "null" + enum: + - one + - two + enum: + type: + - string + - "null" + enum: + - test required: - nullable + - literal + - multiliteral + - enum required: - status - data @@ -980,6 +1031,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1063,6 +1115,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1085,6 +1138,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1162,6 +1216,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1188,6 +1243,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1245,6 +1301,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1264,6 +1321,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1339,6 +1397,7 @@ paths: type: object properties: status: + type: string const: OK result: type: object @@ -1356,6 +1415,7 @@ paths: type: object properties: status: + type: string const: NOT OK required: - status @@ -1436,6 +1496,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1457,6 +1518,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1584,6 +1646,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1604,6 +1667,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1654,6 +1718,7 @@ paths: type: object properties: regularEnum: + type: string enum: - ABC - DEF @@ -1669,11 +1734,13 @@ paths: type: object properties: status: + type: string const: success data: type: object properties: nativeEnum: + type: number enum: - 1 - 2 @@ -1690,6 +1757,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1749,6 +1817,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1780,6 +1849,7 @@ paths: literal: type: object propertyNames: + type: string const: only additionalProperties: type: boolean @@ -1787,13 +1857,16 @@ paths: type: object propertyNames: anyOf: - - const: option1 - - const: option2 + - type: string + const: option1 + - type: string + const: option2 additionalProperties: type: boolean enum: type: object propertyNames: + type: string enum: - option1 - option2 @@ -1817,6 +1890,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1884,6 +1958,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -1903,6 +1978,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -1990,6 +2066,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2012,6 +2089,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2069,6 +2147,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2087,6 +2166,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2152,6 +2232,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2171,6 +2252,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2234,6 +2316,7 @@ paths: type: object properties: status: + type: string const: ok data: type: object @@ -2253,6 +2336,7 @@ paths: type: object properties: status: + type: string const: kinda data: type: object @@ -2269,12 +2353,14 @@ paths: content: application/json: schema: + type: string const: error "500": description: POST /v1/mtpl Negative response 500 content: application/json: schema: + type: string const: failure components: schemas: {} @@ -2329,6 +2415,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2348,6 +2435,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2417,6 +2505,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2436,6 +2525,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2494,6 +2584,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2513,6 +2604,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2577,6 +2669,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2593,6 +2686,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2665,6 +2759,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2681,6 +2776,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2751,6 +2847,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2767,6 +2864,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2828,6 +2926,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2850,6 +2949,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2893,6 +2993,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -2915,6 +3016,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -2991,6 +3093,7 @@ paths: type: object properties: status: + type: string const: success data: anyOf: @@ -3023,6 +3126,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3084,6 +3188,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3100,6 +3205,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3167,6 +3273,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3207,6 +3314,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3286,6 +3394,7 @@ components: type: object properties: status: + type: string const: success data: type: object @@ -3298,6 +3407,7 @@ components: type: object properties: status: + type: string const: error error: type: object @@ -3356,6 +3466,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3383,6 +3494,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3444,6 +3556,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3471,6 +3584,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3538,6 +3652,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3565,6 +3680,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3628,6 +3744,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3655,6 +3772,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3714,6 +3832,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3735,6 +3854,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3784,8 +3904,10 @@ paths: description: parameter of post /v1/:name schema: anyOf: - - const: John - - const: Jane + - type: string + const: John + - type: string + const: Jane requestBody: description: the body of request content: @@ -3807,6 +3929,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -3823,6 +3946,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -3902,12 +4026,15 @@ components: schemas: ParameterOfPostV1NameName: anyOf: - - const: John - - const: Jane + - type: string + const: John + - type: string + const: Jane SuperPositiveResponseOfV1Name: type: object properties: status: + type: string const: success data: type: object @@ -3920,6 +4047,7 @@ components: type: object properties: status: + type: string const: error error: type: object @@ -3968,8 +4096,10 @@ paths: description: GET /v1/:name Parameter schema: anyOf: - - const: John - - const: Jane + - type: string + const: John + - type: string + const: Jane - name: other in: query required: true @@ -3985,6 +4115,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -4001,6 +4132,7 @@ paths: type: object properties: status: + type: string const: error error: type: object @@ -4050,8 +4182,10 @@ paths: description: POST /v1/:name Parameter schema: anyOf: - - const: John - - const: Jane + - type: string + const: John + - type: string + const: Jane requestBody: description: POST /v1/:name Request body content: @@ -4073,6 +4207,7 @@ paths: type: object properties: status: + type: string const: success data: type: object @@ -4089,6 +4224,7 @@ paths: type: object properties: status: + type: string const: error error: type: object diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 4be1a7cf3..6f05998b9 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -29,6 +29,8 @@ import { depictDateIn, depictDateOut, depictBody, + depictEnum, + depictLiteral, } from "../src/documentation-helpers"; describe("Documentation helpers", () => { @@ -310,6 +312,28 @@ describe("Documentation helpers", () => { }); }); + describe("depictEnum()", () => { + test("should set type", () => { + expect( + depictEnum( + { zodSchema: z.never(), jsonSchema: { enum: ["test", "jest"] } }, + requestCtx, + ), + ).toMatchSnapshot(); + }); + }); + + describe("depictLiteral()", () => { + test.each([{ const: "test" }, { enum: ["test", "jest"] }])( + "should set type from either const or enum prop %#", + (jsonSchema) => { + expect( + depictLiteral({ zodSchema: z.never(), jsonSchema }, requestCtx), + ).toMatchSnapshot(); + }, + ); + }); + describe("depictBigInt()", () => { test("should set type:string and format:bigint", () => { expect( diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index c7a096a37..cd5d33658 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -90,9 +90,15 @@ describe("Documentation", () => { }), output: z.object({ nullable: z.string().nullable(), + literal: z.literal("test").nullable(), + multiliteral: z.literal(["one", "two"]).nullable(), + enum: z.enum(["test"]).nullable(), }), handler: async () => ({ nullable: null, + literal: "test" as const, + multiliteral: "one" as const, + enum: "test" as const, }), }), }, From 02167a989b585c5a7d482fe81511d9bce8084687 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 13:05:51 +0200 Subject: [PATCH 082/187] Fix: exclude ZodSuccess, ZodError and ZodFunction from the plugin. --- express-zod-api/src/zod-plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 5b78cf2d1..e4209bda9 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -118,6 +118,7 @@ if (!(metaSymbol in globalThis)) { (globalThis as Record)[metaSymbol] = true; for (const entry of Object.keys(z)) { if (!entry.startsWith("Zod")) continue; + if (/(Success|Error|Function)$/.test(entry)) continue; const Cls = z[entry as keyof typeof z]; if (typeof Cls !== "function") continue; let originalCheck: z.ZodType["check"]; From 6cb290740ed888d4b5402c0c4622d73e2b3ea0c2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 13:09:03 +0200 Subject: [PATCH 083/187] Plugin: rm redundant type coercion for remap. --- express-zod-api/src/zod-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index e4209bda9..134b6c310 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -175,7 +175,7 @@ if (!(metaSymbol in globalThis)) { "remap" satisfies keyof z.ZodObject, { get() { - return objectMapper.bind(this) as unknown as z.ZodObject["remap"]; + return objectMapper.bind(this); }, }, ); From 1c23ad10c0f5a1c0bf62a6e904956c63e30888b4 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 29 Apr 2025 17:18:17 +0200 Subject: [PATCH 084/187] Discriminating `ResultHandler::handler()` params (#2594) Addressing https://github.com/RobinTail/express-zod-api/pull/2546#discussion_r2062671551 --- example/factories.ts | 7 ++- express-zod-api/src/endpoint.ts | 56 ++++++++++---------- express-zod-api/src/result-handler.ts | 30 +++++------ express-zod-api/src/result-helpers.ts | 10 ++++ express-zod-api/tests/result-handler.spec.ts | 6 +-- express-zod-api/tests/sse.spec.ts | 3 +- 6 files changed, 62 insertions(+), 50 deletions(-) diff --git a/example/factories.ts b/example/factories.ts index 4282e568e..658ae8aa6 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -22,7 +22,7 @@ export const fileSendingEndpointsFactory = new EndpointsFactory( negative: { schema: z.string(), mimeType: "text/plain" }, handler: ({ response, error, output }) => { if (error) return void response.status(400).send(error.message); - if (output && "data" in output && typeof output.data === "string") + if ("data" in output && typeof output.data === "string") response.type("svg").send(output.data); else response.status(400).send("Data is missing"); }, @@ -37,7 +37,6 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory( handler: ({ response, error, output }) => { if (error) return void response.status(400).send(error.message); if ( - output && "filename" in output && typeof output.filename === "string" && output.filename.includes(".") @@ -76,8 +75,8 @@ export const statusDependingFactory = new EndpointsFactory( }, ], handler: ({ error, response, output }) => { - if (error || !output) { - const httpError = ensureHttpError(error || new Error("Missing output")); + if (error) { + const httpError = ensureHttpError(error); const doesExist = httpError.statusCode === 409 && "id" in httpError && diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index a62a9c650..b30db131f 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -25,6 +25,7 @@ import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; import { ezRawBrand } from "./raw-schema"; +import { DiscriminatedResult } from "./result-helpers"; import { Routable } from "./routable"; import { AbstractResultHandler } from "./result-handler"; import { Security } from "./security"; @@ -238,24 +239,24 @@ export class Endpoint< return this.#def.handler({ ...rest, input: finalInput }); } - async #handleResult({ - error, - ...rest - }: { - error: Error | null; - request: Request; - response: Response; - logger: ActualLogger; - input: FlatObject; - output: FlatObject | null; - options: Partial; - }) { + async #handleResult( + params: DiscriminatedResult & { + request: Request; + response: Response; + logger: ActualLogger; + input: FlatObject; + options: Partial; + }, + ) { try { - await this.#def.resultHandler.execute({ ...rest, error }); + await this.#def.resultHandler.execute(params); } catch (e) { lastResortHandler({ - ...rest, - error: new ResultHandlerError(ensureError(e), error || undefined), + ...params, + error: new ResultHandlerError( + ensureError(e), + params.error || undefined, + ), }); } } @@ -273,8 +274,7 @@ export class Endpoint< }) { const method = getActualMethod(request); const options: Partial = {}; - let output: FlatObject | null = null; - let error: Error | null = null; + let result: DiscriminatedResult = { output: {}, error: null }; const input = getInput(request, config.inputSources); try { await this.#runMiddlewares({ @@ -287,22 +287,24 @@ export class Endpoint< }); if (response.writableEnded) return; if (method === "options") return void response.status(200).end(); - output = await this.#parseOutput( - await this.#parseAndRunHandler({ - input, - logger, - options: options as OPT, // ensured the complete OPT by writableEnded condition and try-catch - }), - ); + result = { + output: await this.#parseOutput( + await this.#parseAndRunHandler({ + input, + logger, + options: options as OPT, // ensured the complete OPT by writableEnded condition and try-catch + }), + ), + error: null, + }; } catch (e) { - error = ensureError(e); + result = { output: null, error: ensureError(e) }; } await this.#handleResult({ + ...result, input, - output, request, response, - error, logger, options, }); diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index 414608972..fcf5ccfae 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -10,6 +10,7 @@ import { contentTypes } from "./content-type"; import { IOSchema } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; import { + DiscriminatedResult, ensureHttpError, getPublicErrorMessage, logServerError, @@ -17,18 +18,17 @@ import { ResultSchema, } from "./result-helpers"; -type Handler = (params: { - /** null in case of failure to parse or to find the matching endpoint (error: not found) */ - input: FlatObject | null; - /** null in case of errors or failures */ - output: FlatObject | null; - /** can be empty: check presence of the required property using "in" operator */ - options: FlatObject; - error: Error | null; - request: Request; - response: Response; - logger: ActualLogger; -}) => void | Promise; +type Handler = ( + params: DiscriminatedResult & { + /** null in case of failure to parse or to find the matching endpoint (error: not found) */ + input: FlatObject | null; + /** can be empty: check presence of the required property using "in" operator */ + options: FlatObject; + request: Request; + response: Response; + logger: ActualLogger; + }, +) => void | Promise; export type Result = | S // plain schema, default status codes applied @@ -117,8 +117,8 @@ export const defaultResultHandler = new ResultHandler({ error: { message: "Sample error message" }, }), handler: ({ error, input, output, request, response, logger }) => { - if (error || !output) { - const httpError = ensureHttpError(error || new Error("Missing output")); + if (error) { + const httpError = ensureHttpError(error); logServerError(httpError, logger, request, input); return void response .status(httpError.statusCode) @@ -167,7 +167,7 @@ export const arrayResultHandler = new ResultHandler({ .type("text/plain") .send(getPublicErrorMessage(httpError)); } - if (output && "items" in output && Array.isArray(output.items)) { + if ("items" in output && Array.isArray(output.items)) { return void response .status(defaultStatusCodes.positive) .json(output.items); diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index 5916b9848..ff686a41b 100644 --- a/express-zod-api/src/result-helpers.ts +++ b/express-zod-api/src/result-helpers.ts @@ -14,6 +14,16 @@ import type { LazyResult, Result } from "./result-handler"; export type ResultSchema = R extends Result ? S : never; +export type DiscriminatedResult = + | { + output: FlatObject; + error: null; + } + | { + output: null; + error: Error; + }; + /** @throws ResultHandlerError when Result is an empty array */ export const normalize = ( subject: Result | LazyResult, diff --git a/express-zod-api/tests/result-handler.spec.ts b/express-zod-api/tests/result-handler.spec.ts index 79a1c74da..7fe11286a 100644 --- a/express-zod-api/tests/result-handler.spec.ts +++ b/express-zod-api/tests/result-handler.spec.ts @@ -88,7 +88,7 @@ describe("ResultHandler", () => { subject.execute({ error, input: { something: 453 }, - output: { anything: 118 }, + output: null, request: requestMock, response: responseMock, logger: loggerMock, @@ -123,7 +123,7 @@ describe("ResultHandler", () => { ]), ), input: { something: 453 }, - output: { anything: 118 }, + output: null, options: {}, request: requestMock, response: responseMock, @@ -148,7 +148,7 @@ describe("ResultHandler", () => { subject.execute({ error: createHttpError(404, "Something not found"), input: { something: 453 }, - output: { anything: 118 }, + output: null, options: {}, request: requestMock, response: responseMock, diff --git a/express-zod-api/tests/sse.spec.ts b/express-zod-api/tests/sse.spec.ts index 257df0841..ef90eb00c 100644 --- a/express-zod-api/tests/sse.spec.ts +++ b/express-zod-api/tests/sse.spec.ts @@ -118,13 +118,13 @@ describe("SSE", () => { const positiveResponse = makeResponseMock(); const commons = { input: {}, - output: {}, options: {}, request: makeRequestMock(), logger: makeLoggerMock(), }; resultHandler.execute({ ...commons, + output: {}, response: positiveResponse, error: null, }); @@ -134,6 +134,7 @@ describe("SSE", () => { const negativeResponse = makeResponseMock(); resultHandler.execute({ ...commons, + output: null, response: negativeResponse, error: new Error("failure"), }); From 0f27d2ba49e53b5bc47c6e307f6db6fc0963767f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 17:47:07 +0200 Subject: [PATCH 085/187] Smoothing type coercion throu IOSchema instead of unknown in Middleware and EndpointsFactory. --- express-zod-api/src/endpoints-factory.ts | 2 +- express-zod-api/src/middleware.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index 2de4458d8..a88169324 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -118,7 +118,7 @@ export class EndpointsFactory< } public build({ - input = z.object({}) as unknown as BIN, + input = z.object({}) as IOSchema as BIN, // @todo revisit output: outputSchema, operationId, scope, diff --git a/express-zod-api/src/middleware.ts b/express-zod-api/src/middleware.ts index 866b2a4f0..d8afb140b 100644 --- a/express-zod-api/src/middleware.ts +++ b/express-zod-api/src/middleware.ts @@ -50,7 +50,7 @@ export class Middleware< readonly #handler: Handler, OPT, OUT>; constructor({ - input = z.object({}) as unknown as IN, + input = z.object({}) as IOSchema as IN, // @todo revisit security, handler, }: { From b9ff6e022a87221b66b8a605dcc6be6788dcca58 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 18:12:07 +0200 Subject: [PATCH 086/187] Add env test for the found external issue. --- express-zod-api/tests/env.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 1aa46d4a0..7ddab9b49 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -1,5 +1,6 @@ import createHttpError from "http-errors"; import * as R from "ramda"; +import { expectTypeOf } from "vitest"; import { z } from "zod"; describe("Environment checks", () => { @@ -85,6 +86,12 @@ describe("Environment checks", () => { .meta({ title: "last" }); expect(schema.meta()).toMatchSnapshot(); }); + + test("output of empty object schema is too abstract object", () => { + const schema = z.strictObject({}); + expectTypeOf(schema._zod.output).toEqualTypeOf(); + expectTypeOf(schema._zod.output).not.toExtend>(); + }); }); describe("Zod new features", () => { From 86c56652ca75f8e15d4402c97754875064b7f299 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 19:38:15 +0200 Subject: [PATCH 087/187] Diagnostics to handle error thrown from extractObjectSchema. --- express-zod-api/src/diagnostics.ts | 8 +++++++- express-zod-api/tests/routing.spec.ts | 22 +++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 86408dad6..245989149 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -1,4 +1,5 @@ import type { $ZodShape } from "@zod/core"; +import * as R from "ramda"; import { z } from "zod"; import { responseVariants } from "./api-response"; import { FlatObject, getRoutePathParams } from "./common-helpers"; @@ -64,7 +65,12 @@ export class Diagnostics { if (ref?.paths.includes(path)) return; const params = getRoutePathParams(path); if (params.length === 0) return; // next statement can be expensive - const { shape } = ref || extractObjectSchema(endpoint.inputSchema); + const { shape } = + ref || + R.tryCatch(extractObjectSchema, (err) => { + this.logger.warn("Diagnostics::checkPathParams()", err); + return z.object({}); + })(endpoint.inputSchema); for (const param of params) { if (param in shape) continue; this.logger.warn( diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index d0cb1d8cc..88ee2defb 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -1,3 +1,4 @@ +import { IOSchemaError } from "../src/errors"; import { appMock, expressMock, @@ -461,9 +462,12 @@ describe("Routing", () => { ]); }); - test("should warn about unused path params", () => { + test.each([ + z.object({ id: z.string() }), + z.record(z.literal("id"), z.string()), // @todo should support records as an IOSchema compliant one + ])("should warn about unused path params %#", (input) => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ - input: z.object({ id: z.string() }), + input, output: z.object({}), handler: vi.fn(), }); @@ -475,11 +479,15 @@ describe("Routing", () => { config: configMock as CommonConfig, routing: { v1: { ":idx": endpoint } }, }); - expect(logger._getLogs().warn).toEqual([ - [ - "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", - { method: "get", param: "idx", path: "/v1/:idx" }, - ], + if (input instanceof z.ZodRecord) { + expect(logger._getLogs().warn).toContainEqual([ + "Diagnostics::checkPathParams()", + expect.any(IOSchemaError), + ]); + } + expect(logger._getLogs().warn).toContainEqual([ + "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", + { method: "get", param: "idx", path: "/v1/:idx" }, ]); }); }); From 92a4211a3d9bd7f205ea8303eb11c2ad095f78e4 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 21:19:06 +0200 Subject: [PATCH 088/187] Upgrading zod, core 0.10, base64 no extends string, int().max fn fixed runtime. --- express-zod-api/src/date-in-schema.ts | 2 +- .../tests/__snapshots__/env.spec.ts.snap | 62 ++++++++++++++++++- express-zod-api/tests/env.spec.ts | 13 +--- express-zod-api/tests/file-schema.spec.ts | 2 +- package.json | 2 +- yarn.lock | 18 +++--- 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index 19ec6c94a..2222c80c1 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -7,7 +7,7 @@ export const dateIn = () => { z.iso.date(), z.iso.datetime(), z.iso.datetime({ local: true }), - ]) as z.ZodUnion<[z.ZodString, z.ZodString, z.ZodString]>; // this fixes DTS build for ez export + ]) as unknown as z.ZodUnion<[z.ZodString, z.ZodString, z.ZodString]>; // this fixes DTS build for ez export return schema .transform((str) => new Date(str)) diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index aecb4dcc3..0e3f7d995 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -32,7 +32,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmai "$ZodString", "$ZodType", "ZodStringFormat", - "ZodString", + "_ZodString", "ZodType", }, } @@ -101,6 +101,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "$ZodCheckNumberFormat", "$ZodCheck", "$ZodType", + "ZodNumber", "ZodType", }, } @@ -136,6 +137,64 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "$ZodCheckNumberFormat", "$ZodCheck", "$ZodType", + "ZodNumber", + "ZodType", + }, +} +`; + +exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumberFormat' definition 3`] = ` +{ + "check": [Function], + "computed": { + "format": "safeint", + "inclusive": true, + "maximum": 1000, + "minimum": -9007199254740991, + "pattern": /\\^\\\\d\\+\\$/, + }, + "constr": [Function], + "def": { + "abort": false, + "check": "number_format", + "checks": [ + $ZodCheckLessThan { + "_zod": { + "check": [Function], + "constr": [Function], + "def": { + "check": "less_than", + "inclusive": true, + "value": 1000, + }, + "deferred": [], + "onattach": [ + [Function], + ], + "traits": Set { + "$ZodCheckLessThan", + "$ZodCheck", + }, + }, + }, + ], + "format": "safeint", + "type": "number", + }, + "deferred": [], + "onattach": [ + [Function], + ], + "parse": [Function], + "pattern": /\\^\\\\d\\+\\$/, + "run": [Function], + "traits": Set { + "ZodNumberFormat", + "$ZodNumber", + "$ZodCheckNumberFormat", + "$ZodCheck", + "$ZodType", + "ZodNumber", "ZodType", }, } @@ -166,6 +225,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri "ZodString", "$ZodString", "$ZodType", + "_ZodString", "ZodType", }, } diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 7ddab9b49..4dabea660 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -17,10 +17,6 @@ describe("Environment checks", () => { ); }); - /** - * @todo try z.int().max(1000) when it's fixed in Zod 4 - * @link https://github.com/colinhacks/zod/issues/4162 - */ describe("Zod checks/refinements", () => { test.each([ z.string().email(), @@ -28,18 +24,11 @@ describe("Environment checks", () => { z.number().int(), z.int(), z.int32(), + z.int().max(1000), ])("Snapshot control $constructor.name definition", (schema) => { const snapshot = R.omit(["id", "version"], schema._zod); expect(snapshot).toMatchSnapshot(); }); - - /** - * @link https://github.com/colinhacks/zod/issues/4162 - * @link https://github.com/colinhacks/zod/issues/4141 - * */ - test("This should fail when they fix it", () => { - expect(z.int()).not.toHaveProperty("max"); - }); }); describe("Zod imperfections", () => { diff --git a/express-zod-api/tests/file-schema.spec.ts b/express-zod-api/tests/file-schema.spec.ts index 597d0a870..1c5e95c6b 100644 --- a/express-zod-api/tests/file-schema.spec.ts +++ b/express-zod-api/tests/file-schema.spec.ts @@ -32,7 +32,7 @@ describe("ez.file()", () => { test("should create a base64 file", () => { const schema = ez.file("base64"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBase64); expectTypeOf(schema._zod.output).toBeString(); }); }); diff --git a/package.json b/package.json index f59192415..8b25b5c1d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", "vitest": "^3.1.2", - "zod": "^4.0.0-beta.20250424T163858" + "zod": "^4.0.0-beta.20250430T185432" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index dfd0e627a..42be428fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,10 +825,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.9.0.tgz#53dfa0a61916cf7e53980ecbd9681e57362edcd1" - integrity sha512-bVfPiV2kDUkAJ4ArvV4MHcPZA8y3xOX6/SjzSy2kX2ACopbaaAP4wk6hd/byRmfi9MLNai+4SFJMmcATdOyclg== +"@zod/core@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.10.0.tgz#316a3d304190ec1aacd11627e2b30c7488b24c34" + integrity sha512-iMITRygme3v9jPsITJjvRMw60+MQq7MWnNpJleRkfjeSCjBm3c1/tiw3NUS4re/M2CBXVP5kAjI7sQrf22twXA== accepts@^1.3.7: version "1.3.8" @@ -3079,9 +3079,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250424T163858: - version "4.0.0-beta.20250424T163858" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250424T163858.tgz#182eed707f41dc1aa3524ad79b171ce780cd926a" - integrity sha512-fKhW+lEJnfUGo0fvQjmam39zUytARR2UdCEh7/OXJSBbKScIhD343K74nW+UUHu/r6dkzN6Uc/GqwogFjzpCXg== +zod@^4.0.0-beta.20250430T185432: + version "4.0.0-beta.20250430T185432" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250430T185432.tgz#814e0e1db7b5c835dfe54b9d181f70ee73fd3573" + integrity sha512-vslrh3wNLK14cpJwzcuU/uphPR0K/nf4G+/rDpp7TTl4d9h5Rgk1PNcr+2zftZJ4o+sKRUvlFV+FmAVfhu7ZqA== dependencies: - "@zod/core" "0.9.0" + "@zod/core" "0.10.0" From f78a1c3718c16006304eac04aa03367f8b9939ff Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 22:12:53 +0200 Subject: [PATCH 089/187] z.number().int() -> z.int(). --- example/endpoints/accept-raw.ts | 2 +- example/endpoints/create-user.ts | 2 +- example/endpoints/retrieve-user.ts | 2 +- example/endpoints/submit-feedback.ts | 2 +- example/endpoints/upload-avatar.ts | 2 +- example/factories.ts | 2 +- express-zod-api/src/sse.ts | 2 +- express-zod-api/tests/documentation.spec.ts | 49 ++++++--------------- 8 files changed, 21 insertions(+), 42 deletions(-) diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index 6e485ae1e..3b84eba20 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -7,7 +7,7 @@ export const rawAcceptingEndpoint = defaultEndpointsFactory.build({ input: ez.raw({ /* the place for additional inputs, like route params, if needed */ }), - output: z.object({ length: z.number().int().nonnegative() }), + output: z.object({ length: z.int().nonnegative() }), handler: async ({ input: { raw } }) => ({ length: raw.length, // input.raw is populated automatically by the corresponding parser }), diff --git a/example/endpoints/create-user.ts b/example/endpoints/create-user.ts index dc01abe33..c48b09a08 100644 --- a/example/endpoints/create-user.ts +++ b/example/endpoints/create-user.ts @@ -11,7 +11,7 @@ export const createUserEndpoint = statusDependingFactory.build({ name: z.string().nonempty(), }), output: z.object({ - id: z.number().int().positive(), + id: z.int().positive(), }), handler: async ({ input: { name } }) => { assert(name !== "Gimme Jimmy", createHttpError(500, "That went wrong")); diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 523288195..5d32c03ca 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -27,7 +27,7 @@ export const retrieveUserEndpoint = defaultEndpointsFactory .describe("a numeric string containing the id of the user"), }), output: z.object({ - id: z.number().int().nonnegative(), + id: z.int().nonnegative(), name: z.string(), features: feature.array(), }), diff --git a/example/endpoints/submit-feedback.ts b/example/endpoints/submit-feedback.ts index 9aca83648..cc70895aa 100644 --- a/example/endpoints/submit-feedback.ts +++ b/example/endpoints/submit-feedback.ts @@ -10,7 +10,7 @@ export const submitFeedbackEndpoint = defaultEndpointsFactory.build({ message: z.string().min(1), }), output: z.object({ - crc: z.number().int().positive(), + crc: z.int().positive(), }), handler: async ({ input: { name, email, message } }) => ({ crc: [name, email, message].reduce((acc, { length }) => acc + length, 0), diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index bede9ae3c..671c9da78 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -11,7 +11,7 @@ export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ }), output: z.object({ name: z.string(), - size: z.number().int().nonnegative(), + size: z.int().nonnegative(), mime: z.string(), hash: z.string(), otherInputs: z.record(z.string(), z.any()), diff --git a/example/factories.ts b/example/factories.ts index 658ae8aa6..3eed6809c 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -107,5 +107,5 @@ export const noContentFactory = new EndpointsFactory( /** @desc This factory is for producing event streams of server-sent events (SSE) */ export const eventsFactory = new EventStreamFactory({ - time: z.number().int().positive(), + time: z.int().positive(), }); diff --git a/express-zod-api/src/sse.ts b/express-zod-api/src/sse.ts index 5976e0f92..45a1da93f 100644 --- a/express-zod-api/src/sse.ts +++ b/express-zod-api/src/sse.ts @@ -25,7 +25,7 @@ export const makeEventSchema = (event: string, data: z.ZodType) => data, event: z.literal(event), id: z.string().optional(), - retry: z.number().int().positive().optional(), + retry: z.int().positive().optional(), }); export const formatEvent = ( diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index cd5d33658..ec4186209 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -54,7 +54,7 @@ describe("Documentation", () => { v1: { getSomething: defaultEndpointsFactory.build({ input: z.object({ - array: z.array(z.number().int().positive()).min(1).max(3), + array: z.array(z.int().positive()).min(1).max(3), unlimited: z.array(z.boolean()), transformer: z.string().transform((str) => str.length), }), @@ -86,7 +86,7 @@ describe("Documentation", () => { optional: z.string().optional(), optDefault: z.string().optional().default("test"), nullish: z.boolean().nullish(), - nuDefault: z.number().int().positive().nullish().default(123), + nuDefault: z.int().positive().nullish().default(123), }), output: z.object({ nullable: z.string().nullable(), @@ -124,14 +124,8 @@ describe("Documentation", () => { }), output: z.object({ and: z - .object({ - five: z.number().int().gte(0), - }) - .and( - z.object({ - six: z.string(), - }), - ), + .object({ five: z.int().gte(0) }) + .and(z.object({ six: z.string() })), }), handler: async () => ({ and: { @@ -158,19 +152,11 @@ describe("Documentation", () => { method: "post", input: z.object({ union: z.union([ - z.object({ - one: z.string(), - two: z.number().int().positive(), - }), - z.object({ - two: z.number().int().negative(), - three: z.string(), - }), + z.object({ one: z.string(), two: z.int().positive() }), + z.object({ two: z.int().negative(), three: z.string() }), ]), }), - output: z.object({ - or: z.string().or(z.number().int().positive()), - }), + output: z.object({ or: z.string().or(z.int().positive()) }), handler: async () => ({ or: 554, }), @@ -223,10 +209,7 @@ describe("Documentation", () => { v1: { getSomething: defaultEndpointsFactory.build({ method: "post", - input: z.object({ - one: z.string(), - two: z.number().int().positive(), - }), + input: z.object({ one: z.string(), two: z.int().positive() }), output: z.object({ transform: z.string().transform((str) => str.length), }), @@ -338,10 +321,10 @@ describe("Documentation", () => { doubleNegative: z.number().negative(), doubleLimited: z.number().min(-0.5).max(0.5), int: z.int(), - intPositive: z.number().int().positive(), - intNegative: z.number().int().negative(), - intLimited: z.number().int().min(-100).max(100), - zero: z.number().int().nonnegative().nonpositive().optional(), + intPositive: z.int().positive(), + intNegative: z.int().negative(), + intLimited: z.int().min(-100).max(100), + zero: z.int().nonnegative().nonpositive().optional(), }), output: z.object({ bigint: z.bigint(), @@ -406,11 +389,7 @@ describe("Documentation", () => { input: z.object({ ofOne: z.tuple([z.boolean()]), ofStrings: z.tuple([z.string(), z.string().nullable()]), - complex: z.tuple([ - z.boolean(), - z.string(), - z.number().int().positive(), - ]), + complex: z.tuple([z.boolean(), z.string(), z.int().positive()]), }), output: z.object({ empty: z.tuple([]), @@ -456,7 +435,7 @@ describe("Documentation", () => { const string = z.preprocess((arg) => String(arg), z.string()); const number = z.preprocess( (arg) => parseInt(String(arg), 16), - z.number().int().nonnegative(), + z.int().nonnegative(), ); const boolean = z.preprocess((arg) => !!arg, z.boolean()); const spec = new Documentation({ From fb39add7ca836f3ffabf92eccfe3b5b2b609bbe1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 1 May 2025 15:55:32 +0200 Subject: [PATCH 090/187] Replace `extractObjectSchema` with JSON Schema flattening (#2595) In most cases we are only interested in property names of the flattened IOSchema. I came to realization that it might be possible to use `toJSONSchema` for that. --- example/example.documentation.yaml | 3 + express-zod-api/src/diagnostics.ts | 24 +-- express-zod-api/src/documentation-helpers.ts | 173 ++++++++++-------- express-zod-api/src/documentation.ts | 5 +- express-zod-api/src/io-schema.ts | 30 --- express-zod-api/src/json-schema-helpers.ts | 68 +++++++ .../documentation-helpers.spec.ts.snap | 91 +++++---- .../__snapshots__/documentation.spec.ts.snap | 11 +- .../__snapshots__/io-schema.spec.ts.snap | 115 ------------ .../json-schema-helpers.spec.ts.snap | 102 +++++++++++ .../tests/documentation-helpers.spec.ts | 166 ++++++++--------- express-zod-api/tests/io-schema.spec.ts | 71 +------ .../tests/json-schema-helpers.spec.ts | 75 ++++++++ express-zod-api/tests/routing.spec.ts | 9 +- 14 files changed, 494 insertions(+), 449 deletions(-) create mode 100644 express-zod-api/src/json-schema-helpers.ts create mode 100644 express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap create mode 100644 express-zod-api/tests/json-schema-helpers.spec.ts diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index d43165b6b..1f025f2e7 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -196,6 +196,9 @@ paths: required: - name - createdAt + examples: + - name: John Doe + createdAt: 2021-12-31T00:00:00.000Z required: - status - data diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 245989149..024f4ffcf 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -1,19 +1,17 @@ -import type { $ZodShape } from "@zod/core"; -import * as R from "ramda"; import { z } from "zod"; import { responseVariants } from "./api-response"; import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; import { findJsonIncompatible } from "./deep-checks"; import { AbstractEndpoint } from "./endpoint"; -import { extractObjectSchema } from "./io-schema"; +import { flattenIO } from "./json-schema-helpers"; import { ActualLogger } from "./logger-helpers"; export class Diagnostics { #verifiedEndpoints = new WeakSet(); #verifiedPaths = new WeakMap< AbstractEndpoint, - { shape: $ZodShape; paths: string[] } + { flat: ReturnType; paths: string[] } >(); constructor(protected logger: ActualLogger) {} @@ -65,20 +63,22 @@ export class Diagnostics { if (ref?.paths.includes(path)) return; const params = getRoutePathParams(path); if (params.length === 0) return; // next statement can be expensive - const { shape } = - ref || - R.tryCatch(extractObjectSchema, (err) => { - this.logger.warn("Diagnostics::checkPathParams()", err); - return z.object({}); - })(endpoint.inputSchema); + const flat = + ref?.flat || + flattenIO( + z.toJSONSchema(endpoint.inputSchema, { + unrepresentable: "any", + io: "input", + }), + ); for (const param of params) { - if (param in shape) continue; + if (param in flat.properties) continue; this.logger.warn( "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", Object.assign(ctx, { path, param }), ); } if (ref) ref.paths.push(path); - else this.#verifiedPaths.set(endpoint, { shape, paths: [path] }); + else this.#verifiedPaths.set(endpoint, { flat, paths: [path] }); } } diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ccee20d85..57bc2f080 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,4 +1,5 @@ import type { + $ZodObject, $ZodPipe, $ZodTransform, $ZodTuple, @@ -44,7 +45,8 @@ import { ezDateOutBrand } from "./date-out-schema"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; -import { extractObjectSchema, IOSchema } from "./io-schema"; +import { IOSchema } from "./io-schema"; +import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; @@ -80,13 +82,7 @@ export type IsHeader = ( export type BrandHandling = Record; -interface ReqResHandlingProps - extends Omit { - schema: S; - composition: "inline" | "components"; - description?: string; - brandHandling?: BrandHandling; -} +type ReqResCommons = Omit; const shortDescriptionLimit = 50; const isoDateDocumentationUrl = @@ -164,6 +160,7 @@ const canMerge = R.pipe( R.isEmpty, ); +/** @todo DNRY with flattenIO() */ const intersect = ( children: Array, ): JSONSchema.ObjectSchema => { @@ -209,6 +206,21 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({ ...jsonSchema, }); +export const depictObject: Depicter = ( + { zodSchema, jsonSchema }, + { isResponse }, +) => { + if (isResponse) return jsonSchema; + if (!isSchema<$ZodObject>(zodSchema, "object")) return jsonSchema; + const { required = [] } = jsonSchema as JSONSchema.ObjectSchema; + const result: string[] = []; + for (const key of required) { + const valueSchema = zodSchema._zod.def.shape[key]; + if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key); + } + return { ...jsonSchema, required: result }; +}; + const ensureCompliance = ({ $ref, type, @@ -306,7 +318,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { ctx.isResponse ? "in" : "out" ]; if (!isSchema<$ZodTransform>(target, "transform")) return jsonSchema; - const opposingDepiction = depict(opposite, { ctx }); + const opposingDepiction = ensureCompliance(depict(opposite, { ctx })); if (isSchemaObject(opposingDepiction)) { if (!ctx.isResponse) { const { type: opposingType, ...rest } = opposingDepiction; @@ -347,39 +359,6 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => ) : undefined; -export const depictExamples = ( - schema: $ZodType, - isResponse: boolean, - omitProps: string[] = [], -): ExamplesObject | undefined => - R.pipe( - getExamples, - R.map( - R.when( - (one): one is FlatObject => isObject(one) && !Array.isArray(one), - R.omit(omitProps), - ), - ), - enumerateExamples, - )({ - schema, - variant: isResponse ? "parsed" : "original", - validate: true, - pullProps: true, - }); - -export const depictParamExamples = ( - schema: z.ZodType, - param: string, -): ExamplesObject | undefined => { - return R.pipe( - getExamples, - R.filter(R.both(isObject, R.has(param))), - R.pluck(param), - enumerateExamples, - )({ schema, variant: "original", validate: true, pullProps: true }); -}; - export const defaultIsHeader = ( name: string, familiar?: string[], @@ -391,20 +370,22 @@ export const defaultIsHeader = ( export const depictRequestParams = ({ path, method, - schema, + request, inputSources, makeRef, composition, - brandHandling, isHeader, security, description = `${method.toUpperCase()} ${path} Parameter`, -}: ReqResHandlingProps & { +}: ReqResCommons & { + composition: "inline" | "components"; + description?: string; + request: JSONSchema.BaseSchema; inputSources: InputSource[]; isHeader?: IsHeader; security?: Alternatives; }) => { - const objectSchema = extractObjectSchema(schema); + const flat = flattenIO(request); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); @@ -419,8 +400,8 @@ export const depictRequestParams = ({ areHeadersEnabled && (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)); - return Object.entries(objectSchema.shape).reduce( - (acc, [name, paramSchema]) => { + return Object.entries(flat.properties).reduce( + (acc, [name, jsonSchema]) => { const location = isPathParam(name) ? "path" : isHeaderParam(name) @@ -429,22 +410,26 @@ export const depictRequestParams = ({ ? "query" : undefined; if (!location) return acc; - const depicted = depict(paramSchema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, - }); + const depicted = ensureCompliance(jsonSchema); const result = composition === "components" - ? makeRef(paramSchema, depicted, makeCleanId(description, name)) + ? makeRef(jsonSchema, depicted, makeCleanId(description, name)) : depicted; return acc.concat({ name, in: location, - deprecated: globalRegistry.get(paramSchema)?.deprecated, - required: !doesAccept(paramSchema, undefined), + deprecated: jsonSchema.deprecated, + required: flat.required.includes(name), description: depicted.description || description, schema: result, - examples: depictParamExamples(objectSchema, name), + examples: enumerateExamples( + isSchemaObject(depicted) && depicted.examples?.length + ? depicted.examples // own examples or from the flat: + : R.pluck( + name, + flat.examples.filter(R.both(isObject, R.has(name))), + ), + ), }); }, [], @@ -462,6 +447,7 @@ const depicters: Partial> = pipe: depictPipeline, literal: depictLiteral, enum: depictEnum, + object: depictObject, [ezDateInBrand]: depictDateIn, [ezDateOutBrand]: depictDateOut, [ezUploadBrand]: depictUpload, @@ -477,6 +463,7 @@ const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { schema: zodSchema, variant: isResponse ? "parsed" : "original", validate: true, + pullProps: true, }); if (examples.length) result.examples = examples.slice(); return result; @@ -506,7 +493,7 @@ const fixReferences = ( } if (R.is(Array, entry)) stack.push(...R.values(entry)); } - return ensureCompliance(subject); + return subject; }; /** @link https://github.com/colinhacks/zod/issues/4275 */ @@ -574,11 +561,6 @@ export const excludeParamsFromDepiction = ( return [result, hasRequired || Boolean(result.required?.length)]; }; -export const excludeExamplesFromDepiction = ( - depicted: SchemaObject | ReferenceObject, -): SchemaObject | ReferenceObject => - isReferenceObject(depicted) ? depicted : R.omit(["examples"], depicted); - export const depictResponse = ({ method, path, @@ -593,25 +575,34 @@ export const depictResponse = ({ description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), -}: ReqResHandlingProps<$ZodType> & { +}: ReqResCommons & { + schema: $ZodType; + composition: "inline" | "components"; + description?: string; + brandHandling?: BrandHandling; mimeTypes: ReadonlyArray | null; variant: ResponseVariant; statusCode: number; hasMultipleStatusCodes: boolean; }): ResponseObject => { if (!mimeTypes) return { description }; - const depictedSchema = excludeExamplesFromDepiction( + const response = ensureCompliance( depict(schema, { rules: { ...brandHandling, ...depicters }, ctx: { isResponse: true, makeRef, path, method }, }), ); + const examples = []; + if (isSchemaObject(response) && response.examples) { + examples.push(...response.examples); + delete response.examples; // moving them up + } const media: MediaTypeObject = { schema: composition === "components" - ? makeRef(schema, depictedSchema, makeCleanId(description)) - : depictedSchema, - examples: depictExamples(schema, true), + ? makeRef(schema, response, makeCleanId(description)) + : response, + examples: enumerateExamples(examples), }; return { description, content: R.fromPairs(R.xprod(mimeTypes, [media])) }; }; @@ -708,34 +699,62 @@ export const depictSecurityRefs = ( }, {}), ); +export const depictRequest = ({ + schema, + brandHandling, + makeRef, + path, + method, +}: ReqResCommons & { + schema: IOSchema; + brandHandling?: BrandHandling; +}) => + depict(schema, { + rules: { ...brandHandling, ...depicters }, + ctx: { isResponse: false, makeRef, path, method }, + }); + export const depictBody = ({ method, path, schema, + request, mimeType, makeRef, composition, - brandHandling, paramNames, description = `${method.toUpperCase()} ${path} Request body`, -}: ReqResHandlingProps & { +}: ReqResCommons & { + schema: IOSchema; + composition: "inline" | "components"; + description?: string; + request: JSONSchema.BaseSchema; mimeType: string; paramNames: string[]; }) => { const [withoutParams, hasRequired] = excludeParamsFromDepiction( - depict(schema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, - }), + ensureCompliance(request), paramNames, ); - const bodyDepiction = excludeExamplesFromDepiction(withoutParams); + const examples = []; + if (isSchemaObject(withoutParams) && withoutParams.examples) { + examples.push(...withoutParams.examples); + delete withoutParams.examples; // pull up + } const media: MediaTypeObject = { schema: composition === "components" - ? makeRef(schema, bodyDepiction, makeCleanId(description)) - : bodyDepiction, - examples: depictExamples(extractObjectSchema(schema), false, paramNames), + ? makeRef(schema, withoutParams, makeCleanId(description)) + : withoutParams, + examples: enumerateExamples( + examples.length + ? examples + : flattenIO(request) + .examples.filter( + (one): one is FlatObject => isObject(one) && !Array.isArray(one), + ) + .map(R.omit(paramNames)), + ), }; const body: RequestBodyObject = { description, diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 8dd6cefeb..dadac4ac9 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -27,6 +27,7 @@ import { IsHeader, nonEmpty, BrandHandling, + depictRequest, } from "./documentation-helpers"; import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; @@ -173,13 +174,14 @@ export class Documentation extends OpenApiBuilder { endpoint.getOperationId(method), ); + const request = depictRequest({ ...commons, schema: inputSchema }); const security = processContainers(endpoint.security); const depictedParams = depictRequestParams({ ...commons, inputSources, isHeader, security, - schema: inputSchema, + request, description: descriptions?.requestParameter?.call(null, { method, path, @@ -214,6 +216,7 @@ export class Documentation extends OpenApiBuilder { const requestBody = inputSources.includes("body") ? depictBody({ ...commons, + request, paramNames: R.pluck("name", depictedParams), schema: inputSchema, mimeType: contentTypes[endpoint.requestType], diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index a9130e0c4..ea8160b51 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,6 +1,5 @@ import * as R from "ramda"; import { z } from "zod"; -import { IOSchemaError } from "./errors"; import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; @@ -31,32 +30,3 @@ export const getFinalEndpointInputSchema = < finalSchema, ) as z.ZodIntersection; }; - -export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { - if (subject instanceof z.ZodObject) return subject; - if (subject instanceof z.ZodInterface) { - const { optional } = subject._zod.def; - const mask = R.zipObj(optional, Array(optional.length).fill(true)); - const partial = subject.pick(mask); - const required = subject.omit(mask); - return z - .object(required._zod.def.shape) - .extend(z.object(partial._zod.def.shape).partial()); - } - if ( - subject instanceof z.ZodUnion || - subject instanceof z.ZodDiscriminatedUnion - ) { - return subject._zod.def.options - .map((option) => extractObjectSchema(option as IOSchema)) - .reduce((acc, option) => acc.extend(option.partial()), z.object({})); - } - if (subject instanceof z.ZodPipe) - return extractObjectSchema(subject.in as IOSchema); - if (subject instanceof z.ZodIntersection) { - return extractObjectSchema(subject._zod.def.left as IOSchema).extend( - extractObjectSchema(subject._zod.def.right as IOSchema), - ); - } - throw new IOSchemaError("Can not flatten IOSchema", { cause: subject }); -}; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts new file mode 100644 index 000000000..8132315ae --- /dev/null +++ b/express-zod-api/src/json-schema-helpers.ts @@ -0,0 +1,68 @@ +import type { JSONSchema } from "@zod/core"; +import { combinations, isObject } from "./common-helpers"; + +const isJsonObjectSchema = ( + subject: JSONSchema.BaseSchema, +): subject is JSONSchema.ObjectSchema => subject.type === "object"; + +/** @todo DNRY with intersect() */ +export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { + const stack = [{ entry: jsonSchema, isOptional: false }]; + const flat: Required< + Pick< + JSONSchema.ObjectSchema, + "type" | "properties" | "required" | "examples" + > + > = { + type: "object", + properties: {}, + required: [], + examples: [], + }; + while (stack.length) { + const { entry, isOptional } = stack.shift()!; + if (entry.allOf) + stack.push(...entry.allOf.map((one) => ({ entry: one, isOptional }))); + if (entry.anyOf) { + stack.push( + ...entry.anyOf.map((one) => ({ entry: one, isOptional: true })), + ); + } + if (entry.oneOf) { + stack.push( + ...entry.oneOf.map((one) => ({ entry: one, isOptional: true })), + ); + } + if (!isJsonObjectSchema(entry)) continue; + if (entry.properties) { + Object.assign(flat.properties, entry.properties); + if (!isOptional && entry.required) flat.required.push(...entry.required); + } + if (entry.examples) { + if (isOptional) { + flat.examples.push(...entry.examples); + } else { + flat.examples = combinations( + flat.examples.filter(isObject), + entry.examples.filter(isObject), + ([a, b]) => ({ ...a, ...b }), + ); + } + } + if (entry.propertyNames) { + const keys: string[] = []; + if (typeof entry.propertyNames.const === "string") + keys.push(entry.propertyNames.const); + if (entry.propertyNames.enum) { + keys.push( + ...entry.propertyNames.enum.filter((one) => typeof one === "string"), + ); + } + const value = { ...Object(entry.additionalProperties) }; // it can be bool + for (const key of keys) flat.properties[key] = value; + if (!isOptional) flat.required.push(...keys); + } + } + if (flat.required.length > 1) flat.required = [...new Set(flat.required)]; // drop duplicates + return flat; +}; 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 91089c014..ef43084e2 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -62,40 +62,6 @@ exports[`Documentation helpers > depictEnum() > should set type 1`] = ` } `; -exports[`Documentation helpers > depictExamples() > should 'pass' examples in case of 'request' 1`] = ` -{ - "example1": { - "value": { - "one": "test", - "two": 123, - }, - }, - "example2": { - "value": { - "one": "test2", - "two": 456, - }, - }, -} -`; - -exports[`Documentation helpers > depictExamples() > should 'transform' examples in case of 'response' 1`] = ` -{ - "example1": { - "value": { - "one": 4, - "two": "123", - }, - }, - "example2": { - "value": { - "one": 5, - "two": "456", - }, - }, -} -`; - exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 0 1`] = ` { "format": "file", @@ -253,14 +219,36 @@ exports[`Documentation helpers > depictNullable() > should not add null type whe } `; -exports[`Documentation helpers > depictParamExamples() > should pass examples for the given parameter 1`] = ` +exports[`Documentation helpers > depictObject() > should remove optional props from required for request 0 1`] = ` { - "example1": { - "value": 123, + "properties": { + "a": { + "type": "number", + }, + "b": { + "type": "string", + }, }, - "example2": { - "value": 456, + "required": [ + "a", + "b", + ], + "type": "object", +} +`; + +exports[`Documentation helpers > depictObject() > should remove optional props from required for request 1 1`] = ` +{ + "properties": { + "a": { + "type": "number", + }, + "b": { + "type": "string", + }, }, + "required": [], + "type": "object", } `; @@ -283,6 +271,24 @@ exports[`Documentation helpers > depictRaw() > should extract the raw property 1 } `; +exports[`Documentation helpers > depictRequest > should simply delegate it all to Zod 4 1`] = ` +{ + "properties": { + "id": { + "type": "string", + }, + "test": { + "type": "boolean", + }, + }, + "required": [ + "id", + "test", + ], + "type": "object", +} +`; + exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: should depict header params when enabled 1`] = ` [ { @@ -659,13 +665,6 @@ DocumentationError({ }) `; -exports[`Documentation helpers > excludeExamplesFromDepiction() > should remove example property of supplied object 1`] = ` -{ - "description": "test", - "type": "string", -} -`; - exports[`Documentation helpers > excludeParamsFromDepiction() > should handle the ReferenceObject 1`] = ` { "$ref": "test", diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index c3d08b78d..3862c60e3 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1291,7 +1291,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Schema2" + $ref: "#/components/schemas/Schema1" responses: "200": description: POST /v1/getSomething Positive response @@ -1307,7 +1307,7 @@ paths: type: object properties: zodExample: - $ref: "#/components/schemas/Schema1" + $ref: "#/components/schemas/Schema2" required: - zodExample required: @@ -3456,6 +3456,11 @@ paths: required: - key - str + examples: + example1: + value: + key: 1234-56789-01 + str: test required: true responses: "200": @@ -3755,6 +3760,8 @@ paths: - "123" required: - numericStr + examples: + - numericStr: "123" required: - status - data diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 31da5d7f7..16bdb933c 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -1,120 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #600: Top level refinements > should handle refined object schema 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869: Top level transformations > should handle transformations to another object 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should handle interfaces with optional props 1`] = ` -{ - "properties": { - "one": { - "type": "boolean", - }, - "two": { - "type": "boolean", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = ` -IOSchemaError({ - "cause": { - "type": "string", - }, - "message": "Can not flatten IOSchema", -}) -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > should pass the object schema through 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the intersection of object schemas 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - "two": { - "type": "number", - }, - }, - "required": [ - "one", - "two", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the union of object schemas 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - "two": { - "type": "number", - }, - }, - "required": [], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > should support ez.raw() 1`] = ` -{ - "properties": { - "raw": { - "x-brand": "Symbol(File)", - }, - }, - "required": [ - "raw", - ], - "type": "object", -} -`; - exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should handle no middlewares 1`] = ` { "properties": { diff --git a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap new file mode 100644 index 000000000..bdb347c0d --- /dev/null +++ b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap @@ -0,0 +1,102 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`JSON Schema helpers > flattenIO() > should handle records 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": "jest", + }, + { + "one": "some", + "two": "another", + }, + { + "four": 456, + "three": 123, + }, + ], + "properties": { + "four": { + "type": "number", + }, + "one": { + "type": "string", + }, + "three": { + "type": "number", + }, + "two": { + "type": "string", + }, + }, + "required": [], + "type": "object", +} +`; + +exports[`JSON Schema helpers > flattenIO() > should pass the object schema through 1`] = ` +{ + "examples": [ + { + "one": "test", + }, + ], + "properties": { + "one": { + "type": "string", + }, + }, + "required": [ + "one", + ], + "type": "object", +} +`; + +exports[`JSON Schema helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": "jest", + }, + ], + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, + }, + "required": [ + "one", + "two", + ], + "type": "object", +} +`; + +exports[`JSON Schema helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` +{ + "examples": [ + { + "one": "test", + }, + { + "two": "jest", + }, + ], + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, + }, + "required": [], + "type": "object", +} +`; diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 6f05998b9..022446b7d 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -5,14 +5,11 @@ import { z } from "zod"; import { ez } from "../src"; import { OpenAPIContext, - depictExamples, - depictParamExamples, depictRequestParams, depictSecurity, depictSecurityRefs, depictTags, ensureShortDescription, - excludeExamplesFromDepiction, excludeParamsFromDepiction, defaultIsHeader, reformatParamsInPath, @@ -31,6 +28,8 @@ import { depictBody, depictEnum, depictLiteral, + depictRequest, + depictObject, } from "../src/documentation-helpers"; describe("Documentation helpers", () => { @@ -334,6 +333,34 @@ describe("Documentation helpers", () => { ); }); + describe("depictObject()", () => { + test.each([ + { + zodSchema: z.object({ a: z.number(), b: z.string() }), + jsonSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "string" } }, + required: ["a", "b"], + }, + }, + { + zodSchema: z.object({ a: z.number().optional(), b: z.coerce.string() }), + jsonSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "string" } }, + required: ["b"], + }, + }, + ])( + "should remove optional props from required for request %#", + ({ zodSchema, jsonSchema }) => { + expect( + depictObject({ zodSchema, jsonSchema }, requestCtx), + ).toMatchSnapshot(); + }, + ); + }); + describe("depictBigInt()", () => { test("should set type:string and format:bigint", () => { expect( @@ -381,62 +408,6 @@ describe("Documentation helpers", () => { }); }); - describe("depictExamples()", () => { - test.each<{ isResponse: boolean } & Record<"case" | "action", string>>([ - { isResponse: false, case: "request", action: "pass" }, - { isResponse: true, case: "response", action: "transform" }, - ])("should $action examples in case of $case", ({ isResponse }) => { - expect( - depictExamples( - z - .object({ - one: z.string().transform((v) => v.length), - two: z.number().transform((v) => `${v}`), - three: z.boolean(), - }) - .example({ - one: "test", - two: 123, - three: true, - }) - .example({ - one: "test2", - two: 456, - three: false, - }), - isResponse, - ["three"], - ), - ).toMatchSnapshot(); - }); - }); - - describe("depictParamExamples()", () => { - test("should pass examples for the given parameter", () => { - expect( - depictParamExamples( - z - .object({ - one: z.string().transform((v) => v.length), - two: z.number().transform((v) => `${v}`), - three: z.boolean(), - }) - .example({ - one: "test", - two: 123, - three: true, - }) - .example({ - one: "test2", - two: 456, - three: false, - }), - "two", - ), - ).toMatchSnapshot(); - }); - }); - describe("defaultIsHeader()", () => { test.each([ { name: "x-request-id", expected: true }, @@ -455,14 +426,32 @@ describe("Documentation helpers", () => { ); }); - describe("depictRequestParams()", () => { - test("should depict query and path params", () => { + describe("depictRequest", () => { + test("should simply delegate it all to Zod 4", () => { expect( - depictRequestParams({ + depictRequest({ schema: z.object({ id: z.string(), test: z.boolean(), }), + ...requestCtx, + }), + ).toMatchSnapshot(); + }); + }); + + describe("depictRequestParams()", () => { + test("should depict query and path params", () => { + expect( + depictRequestParams({ + request: { + properties: { + id: { type: "string" }, + test: { type: "boolean" }, + }, + required: ["id", "test"], + type: "object", + }, inputSources: ["query", "params"], composition: "inline", ...requestCtx, @@ -473,10 +462,14 @@ describe("Documentation helpers", () => { test("should depict only path params if query is disabled", () => { expect( depictRequestParams({ - schema: z.object({ - id: z.string(), - test: z.boolean(), - }), + request: { + properties: { + id: { type: "string" }, + test: { type: "boolean" }, + }, + required: ["id", "test"], + type: "object", + }, inputSources: ["body", "params"], composition: "inline", ...requestCtx, @@ -487,10 +480,14 @@ describe("Documentation helpers", () => { test("should depict none if both query and params are disabled", () => { expect( depictRequestParams({ - schema: z.object({ - id: z.string(), - test: z.boolean(), - }), + request: { + properties: { + id: { type: "string" }, + test: { type: "boolean" }, + }, + required: ["id", "test"], + type: "object", + }, inputSources: ["body"], composition: "inline", ...requestCtx, @@ -501,12 +498,16 @@ describe("Documentation helpers", () => { test("Features 1180 and 2344: should depict header params when enabled", () => { expect( depictRequestParams({ - schema: z.object({ - "x-request-id": z.string(), - id: z.string(), - test: z.boolean(), - secure: z.string(), - }), + request: { + properties: { + "x-request-id": { type: "string" }, + id: { type: "string" }, + test: { type: "boolean" }, + secure: { type: "string" }, + }, + required: ["x-request-id", "id", "test", "secure"], + type: "object", + }, inputSources: ["query", "headers", "params"], composition: "inline", security: [[{ type: "header", name: "secure" }]], @@ -516,23 +517,12 @@ describe("Documentation helpers", () => { }); }); - describe("excludeExamplesFromDepiction()", () => { - test("should remove example property of supplied object", () => { - expect( - excludeExamplesFromDepiction({ - type: "string", - description: "test", - examples: ["test"], - }), - ).toMatchSnapshot(); - }); - }); - describe("depictBody", () => { test("should mark ez.raw() body as required", () => { const body = depictBody({ ...requestCtx, schema: ez.raw(), + request: { type: "string", format: "binary" }, composition: "inline", mimeType: "application/octet-stream", // raw content type paramNames: [], diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index cce312fcf..3a4bd7a5d 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,10 +1,7 @@ import { expectTypeOf } from "vitest"; import { z } from "zod"; import { IOSchema, Middleware, ez } from "../src"; -import { - extractObjectSchema, - getFinalEndpointInputSchema, -} from "../src/io-schema"; +import { getFinalEndpointInputSchema } from "../src/io-schema"; import { metaSymbol } from "../src/metadata"; import { AbstractMiddleware } from "../src/middleware"; @@ -288,70 +285,4 @@ describe("I/O Schema and related helpers", () => { ]); }); }); - - describe("extractObjectSchema()", () => { - test("should pass the object schema through", () => { - const subject = extractObjectSchema(z.object({ one: z.string() })); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - - test("should return object schema for the union of object schemas", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).or(z.object({ two: z.number() })), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - - test("should return object schema for the intersection of object schemas", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).and(z.object({ two: z.number() })), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - - test("should support ez.raw()", () => { - const subject = extractObjectSchema(ez.raw()); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - - describe("Feature #600: Top level refinements", () => { - test("should handle refined object schema", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).refine(() => true), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - }); - - describe("Feature #1869: Top level transformations", () => { - test("should handle transformations to another object", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).transform(({ one }) => ({ two: one })), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - }); - - describe("Zod 4", () => { - test("should handle interfaces with optional props", () => { - expect( - extractObjectSchema( - z.interface({ one: z.boolean(), "two?": z.boolean() }), - ), - ).toMatchSnapshot(); - }); - - test("should throw for incompatible ones", () => { - expect(() => - extractObjectSchema(z.string() as unknown as IOSchema), - ).toThrowErrorMatchingSnapshot(); - }); - }); - }); }); diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts new file mode 100644 index 000000000..defeeed76 --- /dev/null +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { flattenIO } from "../src/json-schema-helpers"; + +describe("JSON Schema helpers", () => { + describe("flattenIO()", () => { + test("should pass the object schema through", () => { + const subject = flattenIO({ + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + examples: [{ one: "test" }], + }); + expect(subject).toMatchSnapshot(); + }); + + test("should return object schema for the union of object schemas", () => { + const subject = flattenIO({ + oneOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + examples: [{ one: "test" }], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + examples: [{ two: "jest" }], + }, + ], + }); + expect(subject).toMatchSnapshot(); + }); + + test("should return object schema for the intersection of object schemas", () => { + const subject = flattenIO({ + allOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + examples: [{ one: "test" }], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + examples: [{ two: "jest" }], + }, + ], + }); + expect(subject).toMatchSnapshot(); + }); + + test("should handle records", () => { + const subject = z.toJSONSchema( + z + .record(z.literal(["one", "two"]), z.string()) + .meta({ + examples: [ + { one: "test", two: "jest" }, + { one: "some", two: "another" }, + ], + }) + .or( + z + .record(z.enum(["three", "four"]), z.number()) + .meta({ examples: [{ three: 123, four: 456 }] }), + ), + ); + expect(flattenIO(subject)).toMatchSnapshot(); + }); + }); +}); diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 88ee2defb..5cb0e5d51 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -1,4 +1,3 @@ -import { IOSchemaError } from "../src/errors"; import { appMock, expressMock, @@ -464,7 +463,7 @@ describe("Routing", () => { test.each([ z.object({ id: z.string() }), - z.record(z.literal("id"), z.string()), // @todo should support records as an IOSchema compliant one + z.record(z.literal("id"), z.string()), ])("should warn about unused path params %#", (input) => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ input, @@ -479,12 +478,6 @@ describe("Routing", () => { config: configMock as CommonConfig, routing: { v1: { ":idx": endpoint } }, }); - if (input instanceof z.ZodRecord) { - expect(logger._getLogs().warn).toContainEqual([ - "Diagnostics::checkPathParams()", - expect.any(IOSchemaError), - ]); - } expect(logger._getLogs().warn).toContainEqual([ "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", { method: "get", param: "idx", path: "/v1/:idx" }, From ab19eb623edd76603002372091a2d68b15c0350a Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 1 May 2025 20:45:34 +0200 Subject: [PATCH 091/187] Ref: Unify flatten and intersect (#2598) Caused by #2595 `flattenIO` and `intersect` are doing very similar things. first one is coercive, last one is suggestive. Seeking for a way to unify them into one --- express-zod-api/src/documentation-helpers.ts | 59 ++-------------- express-zod-api/src/json-schema-helpers.ts | 68 +++++++++++++------ .../documentation-helpers.spec.ts.snap | 3 + .../__snapshots__/documentation.spec.ts.snap | 2 - .../json-schema-helpers.spec.ts.snap | 2 - .../tests/documentation-helpers.spec.ts | 12 +++- 6 files changed, 67 insertions(+), 79 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 57bc2f080..21b1e6e25 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -26,7 +26,6 @@ import * as R from "ramda"; import { globalRegistry, z } from "zod"; import { ResponseVariant } from "./api-response"; import { - combinations, doesAccept, FlatObject, getExamples, @@ -133,55 +132,11 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { }; }; -const propsMerger = (a: unknown, b: unknown) => { - if (Array.isArray(a) && Array.isArray(b)) return R.concat(a, b); - if (a === b) return b; - throw new Error("Can not flatten properties"); -}; -const approaches = { - type: R.always("object"), - properties: ({ properties: left = {} }, { properties: right = {} }) => - R.mergeDeepWith(propsMerger, left, right), - required: ({ required: left = [] }, { required: right = [] }) => - R.union(left, right), - examples: ({ examples: left = [] }, { examples: right = [] }) => - combinations(left.filter(isObject), right.filter(isObject), ([a, b]) => - R.mergeDeepRight({ ...a }, { ...b }), - ), - description: ({ description: left }, { description: right }) => left || right, -} satisfies { - [K in keyof JSONSchema.ObjectSchema]: ( - ...subj: JSONSchema.ObjectSchema[] - ) => JSONSchema.ObjectSchema[K]; -}; -const canMerge = R.pipe( - Object.keys, - R.without(Object.keys(approaches)), - R.isEmpty, -); - -/** @todo DNRY with flattenIO() */ -const intersect = ( - children: Array, -): JSONSchema.ObjectSchema => { - const [left, right] = children - .map(unref) - .filter( - (schema): schema is JSONSchema.ObjectSchema => schema.type === "object", - ) - .filter(canMerge); - if (!left || !right) throw new Error("Can not flatten objects"); - const suitable: typeof approaches = R.pickBy( - (_, prop) => (left[prop] || right[prop]) !== undefined, - approaches, - ); - return R.map((fn) => fn(left, right), suitable); -}; - export const depictIntersection = R.tryCatch( ({ jsonSchema }) => { - if (!jsonSchema.allOf) throw new Error("Missing allOf"); - return intersect(jsonSchema.allOf); + if (!jsonSchema.allOf) throw "no allOf"; + for (const entry of jsonSchema.allOf) unref(entry); + return flattenIO(jsonSchema, "throw"); }, (_err, { jsonSchema }) => jsonSchema, ); @@ -419,7 +374,7 @@ export const depictRequestParams = ({ name, in: location, deprecated: jsonSchema.deprecated, - required: flat.required.includes(name), + required: flat.required?.includes(name) || false, description: depicted.description || description, schema: result, examples: enumerateExamples( @@ -427,7 +382,7 @@ export const depictRequestParams = ({ ? depicted.examples // own examples or from the flat: : R.pluck( name, - flat.examples.filter(R.both(isObject, R.has(name))), + flat.examples?.filter(R.both(isObject, R.has(name))) || [], ), ), }); @@ -750,10 +705,10 @@ export const depictBody = ({ examples.length ? examples : flattenIO(request) - .examples.filter( + .examples?.filter( (one): one is FlatObject => isObject(one) && !Array.isArray(one), ) - .map(R.omit(paramNames)), + .map(R.omit(paramNames)) || [], ), }; const body: RequestBodyObject = { diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 8132315ae..a556de195 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -1,28 +1,51 @@ import type { JSONSchema } from "@zod/core"; +import * as R from "ramda"; import { combinations, isObject } from "./common-helpers"; const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; -/** @todo DNRY with intersect() */ -export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { +const propsMerger = R.mergeDeepWith((a: unknown, b: unknown) => { + if (Array.isArray(a) && Array.isArray(b)) return R.concat(a, b); + if (a === b) return b; + throw new Error("Can not flatten properties"); +}); + +const canMerge = R.pipe( + Object.keys, + R.without([ + "type", + "properties", + "required", + "examples", + "description", + ] satisfies Array), + R.isEmpty, +); + +export const flattenIO = ( + jsonSchema: JSONSchema.BaseSchema, + mode: "coerce" | "throw" = "coerce", +) => { const stack = [{ entry: jsonSchema, isOptional: false }]; - const flat: Required< - Pick< - JSONSchema.ObjectSchema, - "type" | "properties" | "required" | "examples" - > - > = { + const flat: JSONSchema.ObjectSchema & + Required> = { type: "object", properties: {}, - required: [], - examples: [], }; while (stack.length) { const { entry, isOptional } = stack.shift()!; - if (entry.allOf) - stack.push(...entry.allOf.map((one) => ({ entry: one, isOptional }))); + if (entry.description) flat.description ??= entry.description; + if (entry.allOf) { + stack.push( + ...entry.allOf.map((one) => { + if (mode === "throw" && !(one.type == "object" && canMerge(one))) + throw new Error("Can not merge"); + return { entry: one, isOptional }; + }), + ); + } if (entry.anyOf) { stack.push( ...entry.anyOf.map((one) => ({ entry: one, isOptional: true })), @@ -35,17 +58,21 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { } if (!isJsonObjectSchema(entry)) continue; if (entry.properties) { - Object.assign(flat.properties, entry.properties); - if (!isOptional && entry.required) flat.required.push(...entry.required); + flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( + flat.properties, + entry.properties, + ); + if (!isOptional && entry.required?.length) + flat.required = R.union(flat.required || [], entry.required); } - if (entry.examples) { + if (entry.examples?.length) { if (isOptional) { - flat.examples.push(...entry.examples); + flat.examples = R.concat(flat.examples || [], entry.examples); } else { flat.examples = combinations( - flat.examples.filter(isObject), + flat.examples?.filter(isObject) || [], entry.examples.filter(isObject), - ([a, b]) => ({ ...a, ...b }), + ([a, b]) => R.mergeDeepRight(a, b), ); } } @@ -59,10 +86,9 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { ); } const value = { ...Object(entry.additionalProperties) }; // it can be bool - for (const key of keys) flat.properties[key] = value; - if (!isOptional) flat.required.push(...keys); + for (const key of keys) flat.properties[key] ??= value; + if (!isOptional) flat.required = R.union(flat.required || [], keys); } } - if (flat.required.length > 1) flat.required = [...new Set(flat.required)]; // drop duplicates return flat; }; 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 ef43084e2..d4657b06d 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -154,6 +154,9 @@ exports[`Documentation helpers > depictIntersection() > should maintain uniquene "type": "number", }, }, + "required": [ + "test", + ], "type": "object", } `; diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 3862c60e3..9c9406eb8 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -400,7 +400,6 @@ paths: schema: type: object properties: {} - required: [] security: - HTTP_1: [] OAUTH2_1: @@ -459,7 +458,6 @@ paths: schema: type: object properties: {} - required: [] security: - HTTP_2: [] responses: diff --git a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap index bdb347c0d..00528c2fc 100644 --- a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap @@ -30,7 +30,6 @@ exports[`JSON Schema helpers > flattenIO() > should handle records 1`] = ` "type": "string", }, }, - "required": [], "type": "object", } `; @@ -96,7 +95,6 @@ exports[`JSON Schema helpers > flattenIO() > should return object schema for the "type": "number", }, }, - "required": [], "type": "object", } `; diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 022446b7d..ab2699fec 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -254,8 +254,16 @@ describe("Documentation helpers", () => { test("should maintain uniqueness in the array of required props", () => { const jsonSchema: JSONSchema.BaseSchema = { allOf: [ - { type: "object", properties: { test: { type: "number" } } }, - { type: "object", properties: { test: { const: 5 } } }, + { + type: "object", + properties: { test: { type: "number" } }, + required: ["test"], + }, + { + type: "object", + properties: { test: { const: 5 } }, + required: ["test"], + }, ], }; expect( From e7edc01773e2c3a4a785838d02f8f71c336af06d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 2 May 2025 08:01:44 +0200 Subject: [PATCH 092/187] Ref: switching stack of flattenIO to tuples. --- express-zod-api/src/json-schema-helpers.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index a556de195..2b814bfa5 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -24,38 +24,32 @@ const canMerge = R.pipe( R.isEmpty, ); +const nestOptional = R.pair(true); + export const flattenIO = ( jsonSchema: JSONSchema.BaseSchema, mode: "coerce" | "throw" = "coerce", ) => { - const stack = [{ entry: jsonSchema, isOptional: false }]; + const stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] const flat: JSONSchema.ObjectSchema & Required> = { type: "object", properties: {}, }; while (stack.length) { - const { entry, isOptional } = stack.shift()!; + const [isOptional, entry] = stack.shift()!; if (entry.description) flat.description ??= entry.description; if (entry.allOf) { stack.push( ...entry.allOf.map((one) => { if (mode === "throw" && !(one.type == "object" && canMerge(one))) throw new Error("Can not merge"); - return { entry: one, isOptional }; + return R.pair(isOptional, one); }), ); } - if (entry.anyOf) { - stack.push( - ...entry.anyOf.map((one) => ({ entry: one, isOptional: true })), - ); - } - if (entry.oneOf) { - stack.push( - ...entry.oneOf.map((one) => ({ entry: one, isOptional: true })), - ); - } + 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)( From 5a4d1c6581ff9d07fdc9cc2892eeb0d50cb83a9f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 2 May 2025 15:39:19 +0200 Subject: [PATCH 093/187] Replacing `R.union()` (#2599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` ✓ bench/experiment.bench.ts > Experiment for key lookup 4612ms name hz min max mean p75 p99 p995 p999 rme samples · set 4,577,119.53 0.0002 0.7375 0.0002 0.0002 0.0003 0.0003 0.0006 ±0.87% 2288560 fastest · R.union 2,958,612.29 0.0003 0.2822 0.0003 0.0003 0.0004 0.0006 0.0007 ±0.83% 1479307 BENCH Summary set - bench/experiment.bench.ts > Experiment for key lookup 1.55x faster than R.union ``` --- eslint.config.js | 4 +++ express-zod-api/bench/experiment.bench.ts | 29 +++++----------------- express-zod-api/src/json-schema-helpers.ts | 7 +++--- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 6a76ba61a..4f2374809 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -49,6 +49,10 @@ const performanceConcerns = [ selector: "CallExpression[callee.property.name='flatMap']", // #2209 message: "flatMap() is about 1.3x slower than R.chain()", }, + { + selector: "MemberExpression[object.name='R'] > Identifier[name='union']", // #2599 + message: "R.union() is 1.5x slower than [...Set().add()]", + }, ]; const tsFactoryConcerns = [ diff --git a/express-zod-api/bench/experiment.bench.ts b/express-zod-api/bench/experiment.bench.ts index f1b2a28b2..e79364dc3 100644 --- a/express-zod-api/bench/experiment.bench.ts +++ b/express-zod-api/bench/experiment.bench.ts @@ -1,31 +1,14 @@ import * as R from "ramda"; import { bench } from "vitest"; -describe("Experiment for key lookup", () => { - const subject = { - a: 1, - b: 2, - c: 3, - }; +describe("Experiment for unique elements", () => { + const current = ["one", "two"]; - bench("in", () => { - return void ("a" in subject && "b" in subject && "c" in subject); + bench("set", () => { + return void [...new Set(current).add("null")]; }); - bench("R.has", () => { - return void ( - R.has("a", subject) && - R.has("b", subject) && - R.has("c", subject) - ); - }); - - bench("Object.keys + includes", () => { - const keys = Object.keys(subject); - return void ( - keys.includes("a") && - keys.includes("b") && - keys.includes("c") - ); + bench("R.union", () => { + return void R.union(current, ["null"]); }); }); diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 2b814bfa5..0c41df4a0 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -36,6 +36,7 @@ export const flattenIO = ( type: "object", properties: {}, }; + const flatRequired: string[] = []; while (stack.length) { const [isOptional, entry] = stack.shift()!; if (entry.description) flat.description ??= entry.description; @@ -56,8 +57,7 @@ export const flattenIO = ( flat.properties, entry.properties, ); - if (!isOptional && entry.required?.length) - flat.required = R.union(flat.required || [], entry.required); + if (!isOptional && entry.required) flatRequired.push(...entry.required); } if (entry.examples?.length) { if (isOptional) { @@ -81,8 +81,9 @@ export const flattenIO = ( } const value = { ...Object(entry.additionalProperties) }; // it can be bool for (const key of keys) flat.properties[key] ??= value; - if (!isOptional) flat.required = R.union(flat.required || [], keys); + if (!isOptional) flatRequired.push(...keys); } } + if (flatRequired.length) flat.required = [...new Set(flatRequired)]; return flat; }; From 6f805121816d6c8435c4b14abc5c29ae81e79a07 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 3 May 2025 08:20:03 +0200 Subject: [PATCH 094/187] Updating zod, core 0.10.1, no significant fixes. --- express-zod-api/package.json | 2 +- .../__snapshots__/documentation.spec.ts.snap | 2 +- package.json | 2 +- yarn.lock | 18 +++++++++--------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 1f968727e..5ae700a0b 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "^4.0.0-beta.20250414T061543" + "zod": "^4.0.0-beta.20250503T014749" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 9c9406eb8..98d3ce5d6 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1597,7 +1597,7 @@ paths: ulid: type: string format: ulid - pattern: ^[0-9A-HJKMNP-TV-Z]{26}$ + pattern: ^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$ ip: type: string format: ipv4 diff --git a/package.json b/package.json index 8b25b5c1d..1fb9f7a9d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", "vitest": "^3.1.2", - "zod": "^4.0.0-beta.20250430T185432" + "zod": "^4.0.0-beta.20250503T014749" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index f324549cb..58d9f251a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,10 +825,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.10.0": - version "0.10.0" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.10.0.tgz#316a3d304190ec1aacd11627e2b30c7488b24c34" - integrity sha512-iMITRygme3v9jPsITJjvRMw60+MQq7MWnNpJleRkfjeSCjBm3c1/tiw3NUS4re/M2CBXVP5kAjI7sQrf22twXA== +"@zod/core@0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.10.1.tgz#305c4ce1e0a551c3272f628b5f1bd68f45d6c829" + integrity sha512-EmgYiJLMfZ3Dop9Wp7SadkEGYxbjGvrB/qRCT6PhGft9Eh1TbtNQYO9wEBgw4RE9JsmkolZ5Ah+tHu0EwoIy5g== accepts@^1.3.7: version "1.3.8" @@ -3079,9 +3079,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250430T185432: - version "4.0.0-beta.20250430T185432" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250430T185432.tgz#814e0e1db7b5c835dfe54b9d181f70ee73fd3573" - integrity sha512-vslrh3wNLK14cpJwzcuU/uphPR0K/nf4G+/rDpp7TTl4d9h5Rgk1PNcr+2zftZJ4o+sKRUvlFV+FmAVfhu7ZqA== +zod@^4.0.0-beta.20250503T014749: + version "4.0.0-beta.20250503T014749" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250503T014749.tgz#1941c77ed8bdc29e4244449ebcd330d509f23640" + integrity sha512-ND9JjNpf2IaTZlHr4xgvWbOmzOwjDzrlCqBlhpnYSpXcx6DFzmLJrWhCZc4xgNGieD7MCx/ZoWIHDGZzmg/gnA== dependencies: - "@zod/core" "0.10.0" + "@zod/core" "0.10.1" From 07c76a9c4a9ca8c2324c1dc63f7f8e9fd79baf1c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 3 May 2025 08:50:47 +0200 Subject: [PATCH 095/187] Ref: moving unref() into JSON Schema helpers. --- express-zod-api/src/documentation-helpers.ts | 17 ++--------------- express-zod-api/src/json-schema-helpers.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 21b1e6e25..60f0c8230 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -39,13 +39,13 @@ import { ucFirst, } from "./common-helpers"; import { InputSource } from "./config-type"; +import { contentTypes } from "./content-type"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; -import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; -import { flattenIO } from "./json-schema-helpers"; +import { flattenIO, unref } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; @@ -135,7 +135,6 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { export const depictIntersection = R.tryCatch( ({ jsonSchema }) => { if (!jsonSchema.allOf) throw "no allOf"; - for (const entry of jsonSchema.allOf) unref(entry); return flattenIO(jsonSchema, "throw"); }, (_err, { jsonSchema }) => jsonSchema, @@ -451,18 +450,6 @@ const fixReferences = ( return subject; }; -/** @link https://github.com/colinhacks/zod/issues/4275 */ -const unref = ( - subject: JSONSchema.BaseSchema, -): Omit => { - while (subject._ref) { - const copy = { ...subject._ref }; - delete subject._ref; - Object.assign(subject, copy); - } - return subject; -}; - const depict = ( subject: $ZodType, { ctx, rules = depicters }: { ctx: OpenAPIContext; rules?: BrandHandling }, diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 0c41df4a0..3aeaf3219 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -2,6 +2,18 @@ import type { JSONSchema } from "@zod/core"; import * as R from "ramda"; import { combinations, isObject } from "./common-helpers"; +/** @link https://github.com/colinhacks/zod/issues/4275 */ +export const unref = ( + subject: JSONSchema.BaseSchema, +): Omit => { + while (subject._ref) { + const copy = { ...subject._ref }; + delete subject._ref; + Object.assign(subject, copy); + } + return subject; +}; + const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; @@ -24,7 +36,7 @@ const canMerge = R.pipe( R.isEmpty, ); -const nestOptional = R.pair(true); +const nestOptional = R.pipe(unref, R.pair(true)); export const flattenIO = ( jsonSchema: JSONSchema.BaseSchema, @@ -42,7 +54,7 @@ export const flattenIO = ( if (entry.description) flat.description ??= entry.description; if (entry.allOf) { stack.push( - ...entry.allOf.map((one) => { + ...entry.allOf.map(unref).map((one) => { if (mode === "throw" && !(one.type == "object" && canMerge(one))) throw new Error("Can not merge"); return R.pair(isOptional, one); From 1ad76b7e3f50e07338de9473991a7f1210f02ab6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 3 May 2025 10:05:04 +0200 Subject: [PATCH 096/187] Another bug test for env.spec.ts. --- express-zod-api/tests/env.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 4dabea660..a54eaf47f 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -81,6 +81,13 @@ describe("Environment checks", () => { expectTypeOf(schema._zod.output).toEqualTypeOf(); expectTypeOf(schema._zod.output).not.toExtend>(); }); + + /** @link https://github.com/colinhacks/zod/issues/4152 */ + test("qin presence", () => { + const s = z.number().optional(); + expect(s._zod.qout).toBe("true"); + expect(s._zod.qin).toBeUndefined(); + }); }); describe("Zod new features", () => { From 642744ed3229d5609949b21c8660a3f631077562 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 3 May 2025 10:06:23 +0200 Subject: [PATCH 097/187] minor: link to the zod issue. --- express-zod-api/tests/env.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index a54eaf47f..cf797e862 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -52,7 +52,7 @@ describe("Environment checks", () => { ); }); - /** now input examples are broken */ + /** @link https://github.com/colinhacks/zod/issues/4274 */ test.each(["input", "output"] as const)( "%s examples of transformations", (io) => { From a60c12378ee1661e49408c3206a0035add74dd82 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 3 May 2025 20:23:28 +0200 Subject: [PATCH 098/187] Ref: no Omit<> in OpenAPIContext. --- express-zod-api/src/documentation-helpers.ts | 9 +++++---- express-zod-api/tests/documentation-helpers.spec.ts | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 60f0c8230..d849f610c 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -56,8 +56,7 @@ import { Security } from "./security"; import { ezUploadBrand } from "./upload-schema"; import wellKnownHeaders from "./well-known-headers.json"; -export interface OpenAPIContext { - isResponse: boolean; +interface ReqResCommons { makeRef: ( key: object, subject: SchemaObject | ReferenceObject, @@ -67,6 +66,10 @@ export interface OpenAPIContext { method: Method; } +export interface OpenAPIContext extends ReqResCommons { + isResponse: boolean; +} + export type Depicter = ( zodCtx: { zodSchema: $ZodType; jsonSchema: JSONSchema.BaseSchema }, oasCtx: OpenAPIContext, @@ -81,8 +84,6 @@ export type IsHeader = ( export type BrandHandling = Record; -type ReqResCommons = Omit; - const shortDescriptionLimit = 50; const isoDateDocumentationUrl = "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 ab2699fec..b09e553bc 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -34,18 +34,18 @@ import { describe("Documentation helpers", () => { const makeRefMock = vi.fn(); - const requestCtx = { + const requestCtx: OpenAPIContext = { path: "/v1/user/:id", method: "get", isResponse: false, makeRef: makeRefMock, - } satisfies OpenAPIContext; - const responseCtx = { + }; + const responseCtx: OpenAPIContext = { path: "/v1/user/:id", method: "get", isResponse: true, makeRef: makeRefMock, - } satisfies OpenAPIContext; + }; beforeEach(() => { makeRefMock.mockClear(); From aab3814aa59aa3b14aa2ca14033b2a59832be397 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 5 May 2025 12:55:53 +0200 Subject: [PATCH 099/187] v24: upgrading core to 0.11 (#2600) ![bug on buggy](https://github.com/user-attachments/assets/e4eefacc-2bb6-4db7-9e6a-2641c582f13a) Bugs found: - https://github.com/colinhacks/zod/issues/4317 - https://github.com/colinhacks/zod/issues/4318 - https://github.com/colinhacks/zod/issues/4320 - https://github.com/colinhacks/zod/issues/4322 --- CHANGELOG.md | 3 +- example/endpoints/retrieve-user.ts | 8 +-- example/example.client.ts | 2 +- example/example.documentation.yaml | 8 +++ express-zod-api/package.json | 2 +- express-zod-api/src/common-helpers.ts | 7 ++- express-zod-api/src/deep-checks.ts | 26 ++++++++- express-zod-api/src/documentation-helpers.ts | 13 +++-- express-zod-api/src/metadata.ts | 2 +- express-zod-api/src/zod-plugin.ts | 10 ++-- express-zod-api/src/zts.ts | 41 +++++--------- .../__snapshots__/documentation.spec.ts.snap | 18 ++++++- .../tests/__snapshots__/env.spec.ts.snap | 36 +++++++++++++ .../tests/__snapshots__/sse.spec.ts.snap | 4 ++ express-zod-api/tests/deep-checks.spec.ts | 18 ++++++- express-zod-api/tests/documentation.spec.ts | 4 +- express-zod-api/tests/endpoint.spec.ts | 6 ++- express-zod-api/tests/env.spec.ts | 54 +++++++++++++------ express-zod-api/tests/integration.spec.ts | 2 +- express-zod-api/tests/io-schema.spec.ts | 9 ---- express-zod-api/tests/middleware.spec.ts | 2 +- express-zod-api/tests/zts.spec.ts | 2 +- package.json | 2 +- yarn.lock | 18 +++---- 24 files changed, 207 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c483f5d..995f972b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,7 @@ - The `numericRange` option removed from `Documentation` class constructor argument; - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type signature changed; -- The `optionalPropStyle` option removed from `Integration` class constructor: - - Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface; +- The `optionalPropStyle` option removed from `Integration` class constructor. - Changes to the plugin: - Brand is the only kind of metadata that withstands refinements and checks. diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 5d32c03ca..838ee69b1 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -4,11 +4,11 @@ import { z } from "zod"; import { defaultEndpointsFactory } from "express-zod-api"; import { methodProviderMiddleware } from "../middlewares"; -// Demonstrating circular schemas using z.interface() -const feature = z.interface({ +// Demonstrating circular schemas using z.object() +const feature = z.object({ title: z.string(), - get "features?"() { - return z.array(feature); + get features() { + return z.array(feature).optional(); }, }); diff --git a/example/example.client.ts b/example/example.client.ts index 027b5945e..61a7d056b 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -1,6 +1,6 @@ type Type1 = { title: string; - features?: Type1[]; + features?: Type1[] | undefined; }; type SomeOf = T[keyof T]; diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 1f025f2e7..a0ec40b6f 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -36,6 +36,7 @@ paths: properties: id: type: integer + minimum: 0 maximum: 9007199254740991 name: type: string @@ -269,6 +270,7 @@ paths: properties: id: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - id @@ -290,6 +292,7 @@ paths: properties: id: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - id @@ -476,6 +479,7 @@ paths: type: string size: type: integer + minimum: 0 maximum: 9007199254740991 mime: type: string @@ -550,6 +554,7 @@ paths: properties: length: type: integer + minimum: 0 maximum: 9007199254740991 required: - length @@ -607,6 +612,7 @@ paths: properties: data: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 event: type: string @@ -615,6 +621,7 @@ paths: type: string retry: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - data @@ -668,6 +675,7 @@ paths: properties: crc: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - crc diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 5ae700a0b..756fa5251 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "^4.0.0-beta.20250503T014749" + "zod": "^4.0.0-beta.20250505T012514" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index d95cfdb81..5da0a03ad 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -170,7 +170,12 @@ export const getTransformedType = R.tryCatch( R.always(undefined), ); -/** @link https://github.com/colinhacks/zod/issues/4159 */ +/** + * @link https://github.com/colinhacks/zod/issues/4159 + * @todo replace undefined check with using using ._zod.optionality + * @see https://github.com/RobinTail/express-zod-api/pull/2600/files#r2073174475 + * @link https://v4.zod.dev/v4/changelog#changes-zunknown-optionality + * */ export const doesAccept = R.tryCatch( (schema: $ZodType, value: undefined | null) => { z.parse(schema, value); diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index a1654109c..36e442ee4 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -1,4 +1,4 @@ -import type { $ZodType } from "@zod/core"; +import type { $ZodType, JSONSchema } from "@zod/core"; import * as R from "ramda"; import { globalRegistry, z } from "zod"; import { ezDateInBrand } from "./date-in-schema"; @@ -34,6 +34,30 @@ export const findNestedSchema = ( (err: DeepCheckError) => err.cause, )(); +/** not using cycle:"throw" because it also affects parenting objects */ +export const hasCycle = ( + subject: $ZodType, + { io }: Pick, +) => { + const json = z.toJSONSchema(subject, { + io, + unrepresentable: "any", + override: ({ jsonSchema }) => { + if (typeof jsonSchema.default === "bigint") delete jsonSchema.default; + }, + }); + const stack: unknown[] = [json]; + while (stack.length) { + const entry = stack.shift()!; + if (R.is(Object, entry)) { + if ((entry as JSONSchema.BaseSchema).$ref === "#") return true; + stack.push(...R.values(entry)); + } + if (R.is(Array, entry)) stack.push(...R.values(entry)); + } + return false; +}; + export const findRequestTypeDefiningSchema = (subject: IOSchema) => findNestedSchema(subject, { condition: (schema) => { diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index d849f610c..152e7258d 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -101,12 +101,15 @@ const samples = { export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const depictDefault: Depicter = ({ zodSchema, jsonSchema }) => ({ - ...jsonSchema, - default: +export const depictDefault: Depicter = ({ zodSchema, jsonSchema }) => { + const value = globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? - jsonSchema.default, -}); + jsonSchema.default; + return { + ...jsonSchema, + default: typeof value === "bigint" ? String(value) : value, + }; +}; export const depictUpload: Depicter = ({}, ctx) => { if (ctx.isResponse) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 062f45d48..5f06dde24 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -5,7 +5,7 @@ import * as R from "ramda"; export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { - examples: z.$input[]; + examples: unknown[]; /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 134b6c310..ce0e7e8f3 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -13,7 +13,7 @@ import { z, globalRegistry } from "zod"; import { FlatObject } from "./common-helpers"; import { Metadata, metaSymbol } from "./metadata"; import { Intact, Remap } from "./mapping-helpers"; -import type { $ZodType, $ZodShape } from "@zod/core"; +import type { $ZodType, $ZodShape, $ZodLooseShape } from "@zod/core"; declare module "@zod/core" { interface GlobalMeta { @@ -34,8 +34,9 @@ declare module "zod" { } interface ZodObject< // @ts-expect-error -- external issue - out Shape extends $ZodShape = $ZodShape, - Extra extends Record = Record, + out Shape extends $ZodShape = $ZodLooseShape, + OutExtra extends Record = Record, + InExtra extends Record = Record, > extends ZodType { remap( mapping: U, @@ -44,7 +45,7 @@ declare module "zod" { this, z.ZodTransform // internal type simplified >, - z.ZodObject & Intact, Extra> + z.ZodObject & Intact, OutExtra, InExtra> >; remap( mapper: (subject: Shape) => U, @@ -110,7 +111,6 @@ const objectMapper = function ( const nextShape = transformer(R.clone(this._zod.def.shape)); // immutable const hasPassThrough = this._zod.def.catchall instanceof z.ZodUnknown; const output = (hasPassThrough ? z.looseObject : z.object)(nextShape); // proxies unknown keys when set to "passthrough" - // @ts-expect-error -- ignoring inconsistency of Extra type return this.transform(transformer).pipe(output); }; diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 0a1a1e40d..c1a4f8c15 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -4,7 +4,6 @@ import type { $ZodDefault, $ZodDiscriminatedUnion, $ZodEnum, - $ZodInterface, $ZodIntersection, $ZodLazy, $ZodLiteral, @@ -25,6 +24,7 @@ import { globalRegistry, z } from "zod"; import { doesAccept, getTransformedType, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; +import { hasCycle } from "./deep-checks"; import { ezFileBrand, FileSchema } from "./file-schema"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand, RawSchema } from "./raw-schema"; @@ -70,11 +70,16 @@ const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => { return values.length === 1 ? values[0] : f.createUnionTypeNode(values); }; -const onInterface: Producer = (int: $ZodInterface, { next, makeAlias }) => - makeAlias(int, () => { - const members = Object.entries(int._zod.def.shape).map( +const onObject: Producer = ( + obj: $ZodObject, + { isResponse, next, makeAlias }, +) => { + const fn = () => { + const members = Object.entries(obj._zod.def.shape).map( ([key, value]) => { - const isOptional = int._zod.def.optional.includes(key); + const isOptional = isResponse + ? isSchema<$ZodOptional>(value, "optional") + : doesAccept(value, undefined); const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; return makeInterfaceProp(key, next(value), { @@ -85,27 +90,10 @@ const onInterface: Producer = (int: $ZodInterface, { next, makeAlias }) => }, ); return f.createTypeLiteralNode(members); - }); - -const onObject: Producer = ( - { _zod: { def } }: $ZodObject, - { isResponse, next }, -) => { - const members = Object.entries(def.shape).map( - ([key, value]) => { - const isOptional = isResponse - ? isSchema<$ZodOptional>(value, "optional") - : doesAccept(value, undefined); - const { description: comment, deprecated: isDeprecated } = - globalRegistry.get(value) || {}; - return makeInterfaceProp(key, next(value), { - comment, - isDeprecated, - isOptional, - }); - }, - ); - return f.createTypeLiteralNode(members); + }; + return hasCycle(obj, { io: isResponse ? "output" : "input" }) + ? makeAlias(obj, fn) + : fn(); }; const onArray: Producer = ({ _zod: { def } }: $ZodArray, { next }) => @@ -239,7 +227,6 @@ const producers: HandlingRules< tuple: onTuple, record: onRecord, object: onObject, - interface: onInterface, literal: onLiteral, intersection: onIntersection, union: onSomeUnion, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 98d3ce5d6..cb45adc06 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -631,6 +631,7 @@ paths: maxItems: 3 items: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 - name: unlimited in: query @@ -883,6 +884,7 @@ paths: properties: five: type: integer + minimum: 0 maximum: 9007199254740991 six: type: string @@ -975,6 +977,7 @@ paths: type: - integer - "null" + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 default: 123 responses: @@ -1087,6 +1090,7 @@ paths: type: string two: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - one @@ -1096,6 +1100,7 @@ paths: two: type: integer exclusiveMinimum: -9007199254740991 + exclusiveMaximum: 0 three: type: string required: @@ -1122,6 +1127,7 @@ paths: anyOf: - type: string - type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - or @@ -1275,7 +1281,7 @@ servers: " `; -exports[`Documentation > Basic cases > should handle circular schemas via z.interface() 1`] = ` +exports[`Documentation > Basic cases > should handle circular schemas via z.object() 1`] = ` "openapi: 3.1.0 info: title: Testing Lazy @@ -1453,8 +1459,10 @@ paths: type: number doublePositive: type: number + exclusiveMinimum: 0 doubleNegative: type: number + exclusiveMaximum: 0 doubleLimited: type: number minimum: -0.5 @@ -1465,16 +1473,20 @@ paths: maximum: 9007199254740991 intPositive: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 intNegative: type: integer exclusiveMinimum: -9007199254740991 + exclusiveMaximum: 0 intLimited: type: integer minimum: -100 maximum: 100 zero: type: integer + minimum: 0 + maximum: 0 required: - double - doublePositive @@ -1942,6 +1954,7 @@ paths: type: string two: type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - one @@ -2047,6 +2060,7 @@ paths: - type: boolean - type: string - type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 items: not: {} @@ -2219,6 +2233,7 @@ paths: required: true description: GET /v1/getSomething Parameter schema: + minimum: 0 maximum: 9007199254740991 format: integer (preprocessed) responses: @@ -3845,6 +3860,7 @@ paths: result: description: some positive integer type: integer + exclusiveMinimum: 0 exclusiveMaximum: 9007199254740991 required: - result diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 0e3f7d995..71ea7bcfa 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -231,6 +231,42 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri } `; +exports[`Environment checks > Zod imperfections > circular object schema has no sign of getter in its shape 1`] = ` +{ + "features": { + "configurable": true, + "enumerable": true, + "value": { + "items": { + "properties": { + "features": { + "$ref": "#", + }, + "name": { + "type": "string", + }, + }, + "required": [ + "name", + "features", + ], + "type": "object", + }, + "type": "array", + }, + "writable": true, + }, + "name": { + "configurable": true, + "enumerable": true, + "value": { + "type": "string", + }, + "writable": true, + }, +} +`; + exports[`Environment checks > Zod imperfections > input examples of transformations 1`] = ` { "examples": [ diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index 220e0a348..ed0163083 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -14,6 +14,7 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = }, "retry": { "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": 0, "type": "integer", }, }, @@ -46,6 +47,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss }, "retry": { "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": 0, "type": "integer", }, }, @@ -68,6 +70,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss }, "retry": { "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": 0, "type": "integer", }, }, @@ -121,6 +124,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss }, "retry": { "exclusiveMaximum": 9007199254740991, + "exclusiveMinimum": 0, "type": "integer", }, }, diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index b140d6bc9..f4695711e 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -2,7 +2,7 @@ import { UploadedFile } from "express-fileupload"; import { globalRegistry, z } from "zod"; import type { $brand, $ZodType } from "@zod/core"; import { ez } from "../src"; -import { findNestedSchema } from "../src/deep-checks"; +import { findNestedSchema, hasCycle } from "../src/deep-checks"; import { metaSymbol } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; @@ -61,4 +61,20 @@ describe("Checks", () => { expect(check.mock.calls.length).toBe(1); }); }); + + describe("hasCycle()", () => { + test.each(["input", "output"] as const)( + "can find circular references %#", + (io) => { + const schema = z.object({ + name: z.string(), + get features() { + return schema.array(); + }, + }); + const result = hasCycle(schema, { io }); + expect(result).toBeTruthy(); + }, + ); + }); }); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index ec4186209..00dc3d278 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -461,8 +461,8 @@ describe("Documentation", () => { expect(boolean.parse(null)).toBe(false); }); - test("should handle circular schemas via z.interface()", () => { - const category = z.interface({ + test("should handle circular schemas via z.object()", () => { + const category = z.object({ name: z.string(), get subcategories() { return z.array(category); diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index adbe97863..165d921e9 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -526,8 +526,12 @@ describe("Endpoint", () => { path: ["dynamicValue"], }, ), + /** + * @todo revert to looseObject when fixed + * @link https://github.com/colinhacks/zod/issues/4320 + */ output: z - .looseObject({}) + .record(z.string(), z.unknown()) .refine((obj) => !("emitOutputValidationFailure" in obj), { message: "failure on demand", }), diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index cf797e862..9cc682e1c 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -1,6 +1,5 @@ import createHttpError from "http-errors"; import * as R from "ramda"; -import { expectTypeOf } from "vitest"; import { z } from "zod"; describe("Environment checks", () => { @@ -76,28 +75,53 @@ describe("Environment checks", () => { expect(schema.meta()).toMatchSnapshot(); }); - test("output of empty object schema is too abstract object", () => { - const schema = z.strictObject({}); - expectTypeOf(schema._zod.output).toEqualTypeOf(); - expectTypeOf(schema._zod.output).not.toExtend>(); + /** @link https://github.com/colinhacks/zod/issues/4320 */ + test("input type of a loose object does not allow extra keys", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- this is fine + const schema = z.looseObject({}); + expectTypeOf>().toEqualTypeOf< + Record // ok + >(); + expectTypeOf>().not.toEqualTypeOf< + Record // not ok + >(); }); - /** @link https://github.com/colinhacks/zod/issues/4152 */ - test("qin presence", () => { - const s = z.number().optional(); - expect(s._zod.qout).toBe("true"); - expect(s._zod.qin).toBeUndefined(); + test("circular object schema has no sign of getter in its shape", () => { + const schema = z.object({ + name: z.string(), + get features() { + return schema.array(); + }, + }); + expect( + Object.getOwnPropertyDescriptors(schema._zod.def.shape), + ).toMatchSnapshot(); }); }); describe("Zod new features", () => { - test("interface shape does not contain question marks, but there is a list of them", () => { - const schema = z.interface({ + test("object shape conveys the keys optionality", () => { + const schema = z.object({ one: z.boolean(), - "two?": z.boolean(), + two: z.boolean().optional(), + three: z.boolean().default(true), + four: z + .boolean() + .optional() + .transform(() => false), }); - expect(Object.keys(schema._zod.def.shape)).toEqual(["one", "two"]); - expect(schema._zod.def.optional).toEqual(["two"]); + expect(Object.keys(schema._zod.def.shape)).toEqual([ + "one", + "two", + "three", + "four", + ]); + expect(schema._zod.def.shape.one._zod.optionality).toBeUndefined(); + expect(schema._zod.def.shape.two._zod.optionality).toBe("optional"); + expect(schema._zod.def.shape.three._zod.optionality).toBe("defaulted"); + /** @link https://github.com/colinhacks/zod/issues/4322 */ + expect(schema._zod.def.shape.four._zod.optionality).not.toBe("optional"); // <— undefined }); test("coerce is safe for nullable and optional", () => { diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 1afd29689..ec9beb660 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -15,7 +15,7 @@ describe("Integration", () => { features: recursive1, }), ); - const recursive2 = z.interface({ + const recursive2 = z.object({ name: z.string(), get features() { return recursive2; diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 3a4bd7a5d..bf3259258 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -13,15 +13,6 @@ describe("I/O Schema and related helpers", () => { expectTypeOf(z.object({}).strict()).toExtend(); expectTypeOf(z.object({}).loose()).toExtend(); }); - test("accepts interface", () => { - expectTypeOf(z.interface({})).toExtend(); - expectTypeOf(z.interface({ "some?": z.string() })).toExtend(); - expectTypeOf(z.interface({}).strip()).toExtend(); - expectTypeOf(z.interface({}).loose()).toExtend(); - expectTypeOf(z.looseInterface({})).toExtend(); - expectTypeOf(z.interface({}).strict()).toExtend(); - expectTypeOf(z.strictInterface({})).toExtend(); - }); test("accepts ez.raw()", () => { expectTypeOf(ez.raw()).toExtend(); expectTypeOf(ez.raw({ something: z.any() })).toExtend(); diff --git a/express-zod-api/tests/middleware.spec.ts b/express-zod-api/tests/middleware.spec.ts index efc2210df..0505f57da 100644 --- a/express-zod-api/tests/middleware.spec.ts +++ b/express-zod-api/tests/middleware.spec.ts @@ -16,7 +16,7 @@ describe("Middleware", () => { handler: vi.fn(), }); expect(mw).toBeInstanceOf(AbstractMiddleware); - expectTypeOf(mw.schema._zod.output).toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ something: number; }>(); }); diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index b0a1f529b..7bc689cd8 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -98,7 +98,7 @@ describe("zod-to-ts", () => { }), ); - const circular2 = z.interface({ + const circular2 = z.object({ name: z.string(), get subcategories() { return z.array(circular2); diff --git a/package.json b/package.json index 1fb9f7a9d..592877aa8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", "vitest": "^3.1.2", - "zod": "^4.0.0-beta.20250503T014749" + "zod": "^4.0.0-beta.20250505T012514" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 58d9f251a..362873049 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,10 +825,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.10.1.tgz#305c4ce1e0a551c3272f628b5f1bd68f45d6c829" - integrity sha512-EmgYiJLMfZ3Dop9Wp7SadkEGYxbjGvrB/qRCT6PhGft9Eh1TbtNQYO9wEBgw4RE9JsmkolZ5Ah+tHu0EwoIy5g== +"@zod/core@0.11.4": + version "0.11.4" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.11.4.tgz#c3715dde26a00c303aae43463b5e7b578a85d00f" + integrity sha512-ezfAaaxgjSXZw9sH5QJ4/uqFmg8PbwBFtdSlzz1OoXWcSUR4fj4meS491+lk9ZGxCymjJ/pbOSu7nzcxvHtG0g== accepts@^1.3.7: version "1.3.8" @@ -3079,9 +3079,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250503T014749: - version "4.0.0-beta.20250503T014749" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250503T014749.tgz#1941c77ed8bdc29e4244449ebcd330d509f23640" - integrity sha512-ND9JjNpf2IaTZlHr4xgvWbOmzOwjDzrlCqBlhpnYSpXcx6DFzmLJrWhCZc4xgNGieD7MCx/ZoWIHDGZzmg/gnA== +zod@^4.0.0-beta.20250505T012514: + version "4.0.0-beta.20250505T012514" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250505T012514.tgz#f9f1f8b337efdd7123ff83f9606357ec76052e3a" + integrity sha512-b9Oif/j2uIFuimTO3xqTZP71cfNcv49G7sSDF8wp4+MH2tSCDgRDy5RKEMbLtD0LTrmGznL/gYqqDW7U80PudA== dependencies: - "@zod/core" "0.10.1" + "@zod/core" "0.11.4" From 54d841bcc140edcc8dc7b0bb1ff92689fa363061 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 5 May 2025 14:18:55 +0200 Subject: [PATCH 100/187] v24: Utilize `optionality` (#2604) All properties are now required in Zod 4 unless they are `.optional()` or `.default()`. That fact is now reflected by `._zod.optionality` Direction matters. External bugs: - https://github.com/colinhacks/zod/issues/4322 --- CHANGELOG.md | 6 +- example/example.client.ts | 8 +-- express-zod-api/src/common-helpers.ts | 31 +++++----- express-zod-api/src/documentation-helpers.ts | 7 +-- express-zod-api/src/zts.ts | 12 +--- .../documentation-helpers.spec.ts.snap | 21 ++++++- .../__snapshots__/documentation.spec.ts.snap | 4 +- .../__snapshots__/integration.spec.ts.snap | 4 +- .../tests/__snapshots__/zts.spec.ts.snap | 60 +++++++++---------- .../tests/documentation-helpers.spec.ts | 16 +++-- express-zod-api/tests/zts.spec.ts | 8 ++- 11 files changed, 103 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 995f972b0..0b99bf85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ - The `numericRange` option removed from `Documentation` class constructor argument; - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type signature changed; -- The `optionalPropStyle` option removed from `Integration` class constructor. +- The `optionalPropStyle` option removed from `Integration` class constructor: + - Use `.optional()` to add question mark to the object property; + - 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. - Changes to the plugin: - Brand is the only kind of metadata that withstands refinements and checks. diff --git a/example/example.client.ts b/example/example.client.ts index 61a7d056b..d3e9c7c65 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -1,6 +1,6 @@ type Type1 = { title: string; - features?: Type1[] | undefined; + features?: Type1[]; }; type SomeOf = T[keyof T]; @@ -265,15 +265,15 @@ interface PostV1AvatarRawNegativeResponseVariants { /** get /v1/events/stream */ type GetV1EventsStreamInput = { /** @deprecated for testing error response */ - trigger?: string | undefined; + trigger?: string; }; /** get /v1/events/stream */ type GetV1EventsStreamPositiveVariant1 = { data: number; event: "time"; - id?: string | undefined; - retry?: number | undefined; + id?: string; + retry?: number; }; /** get /v1/events/stream */ diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 5da0a03ad..991351170 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,4 +1,9 @@ -import type { $ZodObject, $ZodTransform, $ZodType } from "@zod/core"; +import type { + $ZodObject, + $ZodTransform, + $ZodType, + $ZodTypeInternals, +} from "@zod/core"; import { Request } from "express"; import * as R from "ramda"; import { globalRegistry, z } from "zod"; @@ -170,19 +175,17 @@ export const getTransformedType = R.tryCatch( R.always(undefined), ); -/** - * @link https://github.com/colinhacks/zod/issues/4159 - * @todo replace undefined check with using using ._zod.optionality - * @see https://github.com/RobinTail/express-zod-api/pull/2600/files#r2073174475 - * @link https://v4.zod.dev/v4/changelog#changes-zunknown-optionality - * */ -export const doesAccept = R.tryCatch( - (schema: $ZodType, value: undefined | null) => { - z.parse(schema, value); - return true; - }, - R.always(false), -); +const requestOptionality: Array<$ZodTypeInternals["optionality"]> = [ + "optional", + "defaulted", +]; +export const isOptional = ( + { _zod: { optionality } }: $ZodType, + { isResponse }: { isResponse: boolean }, +) => + isResponse + ? optionality === "optional" + : optionality && requestOptionality.includes(optionality); /** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */ export const isObject = (subject: unknown) => diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 152e7258d..dfd03fb23 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -26,12 +26,12 @@ import * as R from "ramda"; import { globalRegistry, z } from "zod"; import { ResponseVariant } from "./api-response"; import { - doesAccept, FlatObject, getExamples, getRoutePathParams, getTransformedType, isObject, + isOptional, isSchema, makeCleanId, routePathParamsRegex, @@ -174,7 +174,8 @@ export const depictObject: Depicter = ( const result: string[] = []; for (const key of required) { const valueSchema = zodSchema._zod.def.shape[key]; - if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key); + if (valueSchema && !isOptional(valueSchema, { isResponse })) + result.push(key); } return { ...jsonSchema, required: result }; }; @@ -415,8 +416,6 @@ const depicters: Partial> = const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { const result = { ...jsonSchema }; - if (!isResponse && doesAccept(zodSchema, null)) - Object.assign(result, { type: makeNullableType(jsonSchema.type) }); const examples = getExamples({ schema: zodSchema, variant: isResponse ? "parsed" : "original", diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index c1a4f8c15..cc890007d 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -21,7 +21,7 @@ import type { import * as R from "ramda"; import ts from "typescript"; import { globalRegistry, z } from "zod"; -import { doesAccept, getTransformedType, isSchema } from "./common-helpers"; +import { getTransformedType, isOptional, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { hasCycle } from "./deep-checks"; @@ -77,15 +77,12 @@ const onObject: Producer = ( const fn = () => { const members = Object.entries(obj._zod.def.shape).map( ([key, value]) => { - const isOptional = isResponse - ? isSchema<$ZodOptional>(value, "optional") - : doesAccept(value, undefined); const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; return makeInterfaceProp(key, next(value), { comment, isDeprecated, - isOptional, + isOptional: isOptional(value, { isResponse }), }); }, ); @@ -118,10 +115,7 @@ const makeSample = (produced: ts.TypeNode) => samples?.[produced.kind as keyof typeof samples]; const onOptional: Producer = ({ _zod: { def } }: $ZodOptional, { next }) => - f.createUnionTypeNode([ - next(def.innerType), - ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), - ]); + next(def.innerType); const onNullable: Producer = ({ _zod: { def } }: $ZodNullable, { next }) => f.createUnionTypeNode([next(def.innerType), makeLiteralType(null)]); 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 d4657b06d..367203042 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -216,12 +216,27 @@ exports[`Documentation helpers > depictNullable() > should add null type to the } `; -exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1`] = ` +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 0 1`] = ` { "type": "null", } `; +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1 1`] = ` +{ + "type": "null", +} +`; + +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 2 1`] = ` +{ + "type": [ + "string", + "null", + ], +} +`; + exports[`Documentation helpers > depictObject() > should remove optional props from required for request 0 1`] = ` { "properties": { @@ -250,7 +265,9 @@ exports[`Documentation helpers > depictObject() > should remove optional props f "type": "string", }, }, - "required": [], + "required": [ + "b", + ], "type": "object", } `; diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index cb45adc06..f789b9d7f 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -2147,7 +2147,7 @@ paths: parameters: - name: any in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: {} responses: @@ -2224,7 +2224,7 @@ paths: parameters: - name: string in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: format: string (preprocessed) diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 38e79e133..4e4a2c452 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -282,14 +282,14 @@ exports[`Integration > Should treat optionals the same way as z.infer() by defau /** post /v1/test-with-dashes */ type PostV1TestWithDashesInput = { - opt?: string | undefined; + opt?: string; }; /** post /v1/test-with-dashes */ type PostV1TestWithDashesPositiveVariant1 = { status: "success"; data: { - similar?: number | undefined; + similar?: number; }; }; diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 4213a12e0..c0035bf4b 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -16,20 +16,20 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` enum: "hi" | "bye"; intersectionWithTransform: (number & bigint) & (number & string); date: any; - undefined?: undefined; + undefined: undefined; null: null; - void?: any; - any?: any; - unknown?: any; + void: any; + any: any; + unknown: any; never: any; - optionalString?: string | undefined; + optionalString?: string; nullablePartialObject: { - string?: string | undefined; - number?: number | undefined; - fixedArrayOfString?: string[] | undefined; + string?: string; + number?: number; + fixedArrayOfString?: string[]; object?: { string: string; - } | undefined; + }; } | null; tuple: [ string, @@ -57,8 +57,8 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` set: any; intersection: (string & number) | bigint; promise: any; - optDefaultString?: string | undefined; - refinedStringWithSomeBullshit: (string | number) & ((bigint | null) | undefined); + optDefaultString?: string; + refinedStringWithSomeBullshit: (string | number) & (bigint | null); nativeEnum: "A" | "apple" | "banana" | "cantaloupe" | 5; lazy: SomeType; discUnion: { @@ -73,7 +73,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` y: number; }; branded: string; - catch?: number; + catch: number; pipeline: string; readonly: string; }" @@ -127,7 +127,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > "{ query: string; } & { - query?: string | undefined; + query?: string; }" `; @@ -135,7 +135,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > "{ query: string; } & { - query?: string | undefined; + query?: string; }" `; @@ -145,11 +145,11 @@ exports[`zod-to-ts > PrimitiveSchema > outputs correct typescript 1`] = ` number: number; boolean: boolean; date: any; - undefined?: undefined; + undefined: undefined; null: null; - void?: any; - any?: any; - unknown?: any; + void: any; + any: any; + unknown: any; never: any; }" `; @@ -230,10 +230,10 @@ exports[`zod-to-ts > z.object() > escapes correctly 1`] = ` "'": string; "\`": string; "\\n": number; - $e?: any; - "4t"?: any; - _r?: any; - "-r"?: undefined; + $e: any; + "4t": any; + _r: any; + "-r": undefined; }" `; @@ -268,21 +268,21 @@ exports[`zod-to-ts > z.object() > supports zod.describe() 1`] = ` }" `; -exports[`zod-to-ts > z.optional() > outputs correct typescript 1`] = `"string | undefined"`; +exports[`zod-to-ts > z.optional() > Zod 4: does not add undefined to it, unwrap as is 1`] = `"string"`; -exports[`zod-to-ts > z.optional() > should output \`?:\` and undefined union for optional properties 1`] = ` +exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to optional props 1`] = ` "{ - optional?: string | undefined; + optional?: string; required: string; - transform?: number | undefined; - or?: (number | undefined) | string; + transform: number; + or: number | string; tuple?: [ - string | undefined, + string, number, { - optional?: string | undefined; + optional?: string; required: string; } - ] | undefined; + ]; }" `; diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index b09e553bc..90a3c6d9f 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -309,10 +309,18 @@ describe("Documentation helpers", () => { }, ); - test("should not add null type when it's already there", () => { - const jsonSchema: JSONSchema.BaseSchema = { + test.each([ + { type: "null" }, + { anyOf: [{ type: "null" }, { type: "null" }], - }; + }, + { + anyOf: [ + { type: ["string", "null"] as unknown as string }, // nullable of nullable case + { type: "null" }, + ], + }, + ])("should not add null type when it's already there %#", (jsonSchema) => { expect( depictNullable({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); @@ -356,7 +364,7 @@ describe("Documentation helpers", () => { jsonSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "string" } }, - required: ["b"], + required: ["b"], // Zod 4: coerce remains }, }, ])( diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 7bc689cd8..e1649ac48 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -202,12 +202,16 @@ describe("zod-to-ts", () => { .optional(), }); - test("outputs correct typescript", () => { + test("Zod 4: does not add undefined to it, unwrap as is", () => { const node = zodToTs(optionalStringSchema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); - test("should output `?:` and undefined union for optional properties", () => { + /** + * @todo revisit when optional+transform fixed + * @link https://github.com/colinhacks/zod/issues/4322 + * */ + test("Zod 4: should add question mark only to optional props", () => { const node = zodToTs(objectWithOptionals, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); From d0581ca52d224f4a0249e1eacf5f58d14359cb3e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 5 May 2025 23:01:36 +0200 Subject: [PATCH 101/187] Updating zod, core 0.11.6, no significant changes. --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1dda52ca4..58782d8f6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", "vitest": "^3.1.3", - "zod": "^4.0.0-beta.20250505T012514" + "zod": "^4.0.0-beta.20250505T195954" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index ff366a8ac..8e6ec686f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,10 +825,10 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.11.4": - version "0.11.4" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.11.4.tgz#c3715dde26a00c303aae43463b5e7b578a85d00f" - integrity sha512-ezfAaaxgjSXZw9sH5QJ4/uqFmg8PbwBFtdSlzz1OoXWcSUR4fj4meS491+lk9ZGxCymjJ/pbOSu7nzcxvHtG0g== +"@zod/core@0.11.6": + version "0.11.6" + resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.11.6.tgz#9216e98848dc9364eda35e3da90f5362f10e8887" + integrity sha512-03Bv82fFSfjDAvMfdHHdGSS6SOJs0iCcJlWJv1kJHRtoTT02hZpyip/2Lk6oo4l4FtjuwTrsEQTwg/LD8I7dJA== accepts@^1.3.7: version "1.3.8" @@ -3079,9 +3079,9 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250505T012514: - version "4.0.0-beta.20250505T012514" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250505T012514.tgz#f9f1f8b337efdd7123ff83f9606357ec76052e3a" - integrity sha512-b9Oif/j2uIFuimTO3xqTZP71cfNcv49G7sSDF8wp4+MH2tSCDgRDy5RKEMbLtD0LTrmGznL/gYqqDW7U80PudA== +zod@^4.0.0-beta.20250505T195954: + version "4.0.0-beta.20250505T195954" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250505T195954.tgz#ba9da025671de2dde9d4d033089f03c37a35022f" + integrity sha512-iB8WvxkobVIXMARvQu20fKvbS7mUTiYRpcD8OQV1xjRhxO0EEpYIRJBk6yfBzHAHEdOSDh3SxDITr5Eajr2vtg== dependencies: - "@zod/core" "0.11.4" + "@zod/core" "0.11.6" From fc990b8be2fbe18a86afa3f135c80baf0f2b941d Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 6 May 2025 12:14:21 +0200 Subject: [PATCH 102/187] Optionals: Restore `undefined`, but depending on `optionality` (#2607) Partially reverts 773b212f51921da7704105c8a45cf2373908e752 from #2604 But this implementation does it depending on `optionality` which in its turn depends on direction. This takes Collin explanaition into account https://x.com/colinhacks/status/1919292504861491252 And adds env test to ensure that. --- CHANGELOG.md | 2 +- example/example.client.ts | 8 +++---- express-zod-api/src/typescript-api.ts | 8 ++++++- .../__snapshots__/integration.spec.ts.snap | 4 ++-- .../tests/__snapshots__/zts.spec.ts.snap | 22 +++++++++---------- express-zod-api/tests/env.spec.ts | 12 ++++++++++ 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b99bf85b..9ab511321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type signature changed; - The `optionalPropStyle` option removed from `Integration` class constructor: - - Use `.optional()` to add question mark to the object property; + - Use `.optional()` to add question mark to the object property as well as `undefined` to its type; - 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. diff --git a/example/example.client.ts b/example/example.client.ts index d3e9c7c65..61a7d056b 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -1,6 +1,6 @@ type Type1 = { title: string; - features?: Type1[]; + features?: Type1[] | undefined; }; type SomeOf = T[keyof T]; @@ -265,15 +265,15 @@ interface PostV1AvatarRawNegativeResponseVariants { /** get /v1/events/stream */ type GetV1EventsStreamInput = { /** @deprecated for testing error response */ - trigger?: string; + trigger?: string | undefined; }; /** get /v1/events/stream */ type GetV1EventsStreamPositiveVariant1 = { data: number; event: "time"; - id?: string; - retry?: number; + id?: string | undefined; + retry?: number | undefined; }; /** get /v1/events/stream */ diff --git a/express-zod-api/src/typescript-api.ts b/express-zod-api/src/typescript-api.ts index 5b45ef980..a104c4b86 100644 --- a/express-zod-api/src/typescript-api.ts +++ b/express-zod-api/src/typescript-api.ts @@ -142,11 +142,17 @@ export const makeInterfaceProp = ( comment, }: { isOptional?: boolean; isDeprecated?: boolean; comment?: string } = {}, ) => { + const propType = ensureTypeNode(value); const node = f.createPropertySignature( undefined, makePropertyIdentifier(name), isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - ensureTypeNode(value), + isOptional + ? f.createUnionTypeNode([ + propType, + ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), + ]) + : propType, ); const jsdoc = R.reject(R.isNil, [ isDeprecated ? "@deprecated" : undefined, diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 4e4a2c452..38e79e133 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -282,14 +282,14 @@ exports[`Integration > Should treat optionals the same way as z.infer() by defau /** post /v1/test-with-dashes */ type PostV1TestWithDashesInput = { - opt?: string; + opt?: string | undefined; }; /** post /v1/test-with-dashes */ type PostV1TestWithDashesPositiveVariant1 = { status: "success"; data: { - similar?: number; + similar?: number | undefined; }; }; diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index c0035bf4b..ccf5869f3 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -22,14 +22,14 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` any: any; unknown: any; never: any; - optionalString?: string; + optionalString?: string | undefined; nullablePartialObject: { - string?: string; - number?: number; - fixedArrayOfString?: string[]; + string?: string | undefined; + number?: number | undefined; + fixedArrayOfString?: string[] | undefined; object?: { string: string; - }; + } | undefined; } | null; tuple: [ string, @@ -57,7 +57,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` set: any; intersection: (string & number) | bigint; promise: any; - optDefaultString?: string; + optDefaultString?: string | undefined; refinedStringWithSomeBullshit: (string | number) & (bigint | null); nativeEnum: "A" | "apple" | "banana" | "cantaloupe" | 5; lazy: SomeType; @@ -127,7 +127,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > "{ query: string; } & { - query?: string; + query?: string | undefined; }" `; @@ -135,7 +135,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > "{ query: string; } & { - query?: string; + query?: string | undefined; }" `; @@ -272,7 +272,7 @@ exports[`zod-to-ts > z.optional() > Zod 4: does not add undefined to it, unwrap exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to optional props 1`] = ` "{ - optional?: string; + optional?: string | undefined; required: string; transform: number; or: number | string; @@ -280,9 +280,9 @@ exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to opti string, number, { - optional?: string; + optional?: string | undefined; required: string; } - ]; + ] | undefined; }" `; diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 9cc682e1c..9bea65916 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -122,6 +122,18 @@ describe("Environment checks", () => { expect(schema._zod.def.shape.three._zod.optionality).toBe("defaulted"); /** @link https://github.com/colinhacks/zod/issues/4322 */ expect(schema._zod.def.shape.four._zod.optionality).not.toBe("optional"); // <— undefined + expectTypeOf>().toEqualTypeOf<{ + one: boolean; + two?: boolean | undefined; + three?: boolean | undefined; + four: boolean | undefined; + }>(); + expectTypeOf>().toEqualTypeOf<{ + one: boolean; + two?: boolean | undefined; + three: boolean; + four: boolean; + }>(); }); test("coerce is safe for nullable and optional", () => { From b62e51b0e70dc58d42f6d79dee942c23ad2df3b6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 15 May 2025 08:32:43 +0200 Subject: [PATCH 103/187] changelog: minor, grammar. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2662ba0be..da35de17c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Switched to Zod 4: - Minimum supported version of `zod` is 4.0.0; - ⚠️This version might not support all new features of Zod 4; - - `IOSchema` type had to be simplified down to a schema resulting in a `object`, but not an `array`; + - `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()`: From 5e9460d732989f0a09319bdd575020989d9c86ba Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 15 May 2025 08:44:17 +0200 Subject: [PATCH 104/187] Fix validations CI - using zod 4 version instead of dist tag next. --- .github/workflows/validations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validations.yml b/.github/workflows/validations.yml index 86de13a3f..e2e703ebf 100644 --- a/.github/workflows/validations.yml +++ b/.github/workflows/validations.yml @@ -49,7 +49,7 @@ jobs: name: dist - name: Add dependencies run: | - yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod@next + yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod@^4.0.0-beta.20250505T195954 yarn add -D eslint@9.0 typescript-eslint@8.0 vitest tsx yarn add express-zod-api@./dist.tgz - name: Run tests From c375c65770bb9854fbf309a3c4e5d817ee46e0c1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 18 May 2025 07:13:57 +0200 Subject: [PATCH 105/187] Using v4 from 3.25 (#2631) The title of this PR is insane :) --- .github/workflows/validations.yml | 2 +- CHANGELOG.md | 4 +- README.md | 30 ++++----- example/endpoints/accept-raw.ts | 2 +- example/endpoints/create-user.ts | 2 +- example/endpoints/delete-user.ts | 2 +- example/endpoints/list-users.ts | 2 +- example/endpoints/retrieve-user.ts | 2 +- example/endpoints/send-avatar.ts | 2 +- example/endpoints/stream-avatar.ts | 2 +- example/endpoints/submit-feedback.ts | 2 +- example/endpoints/time-subscription.ts | 2 +- example/endpoints/update-user.ts | 2 +- example/endpoints/upload-avatar.ts | 2 +- example/example.documentation.yaml | 10 +-- example/factories.ts | 2 +- example/middlewares.ts | 2 +- express-zod-api/package.json | 2 +- express-zod-api/src/api-response.ts | 2 +- express-zod-api/src/common-helpers.ts | 4 +- express-zod-api/src/date-in-schema.ts | 2 +- express-zod-api/src/date-out-schema.ts | 2 +- express-zod-api/src/deep-checks.ts | 4 +- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation-helpers.ts | 7 +- express-zod-api/src/endpoint.ts | 2 +- express-zod-api/src/endpoints-factory.ts | 2 +- express-zod-api/src/errors.ts | 4 +- express-zod-api/src/file-schema.ts | 2 +- express-zod-api/src/form-schema.ts | 4 +- express-zod-api/src/integration.ts | 2 +- express-zod-api/src/io-schema.ts | 2 +- express-zod-api/src/json-schema-helpers.ts | 20 ++---- express-zod-api/src/metadata.ts | 2 +- express-zod-api/src/middleware.ts | 2 +- express-zod-api/src/migration.ts | 9 +++ express-zod-api/src/raw-schema.ts | 4 +- express-zod-api/src/result-handler.ts | 2 +- express-zod-api/src/result-helpers.ts | 2 +- express-zod-api/src/schema-walker.ts | 4 +- express-zod-api/src/sse.ts | 2 +- express-zod-api/src/upload-schema.ts | 2 +- express-zod-api/src/zod-plugin.ts | 22 ++++--- express-zod-api/src/zts.ts | 4 +- .../__snapshots__/documentation.spec.ts.snap | 22 +++---- .../tests/__snapshots__/endpoint.spec.ts.snap | 2 + .../endpoints-factory.spec.ts.snap | 9 +++ .../tests/__snapshots__/env.spec.ts.snap | 66 +++++++------------ .../__snapshots__/file-schema.spec.ts.snap | 2 - .../__snapshots__/form-schema.spec.ts.snap | 4 +- .../__snapshots__/io-schema.spec.ts.snap | 5 ++ .../tests/__snapshots__/sse.spec.ts.snap | 13 ++-- .../tests/__snapshots__/system.spec.ts.snap | 8 +-- express-zod-api/tests/common-helpers.spec.ts | 2 +- express-zod-api/tests/date-in-schema.spec.ts | 2 +- express-zod-api/tests/date-out-schema.spec.ts | 2 +- express-zod-api/tests/deep-checks.spec.ts | 4 +- .../tests/depends-on-method.spec.ts | 2 +- .../tests/documentation-helpers.spec.ts | 4 +- express-zod-api/tests/documentation.spec.ts | 2 +- express-zod-api/tests/endpoint.spec.ts | 2 +- .../tests/endpoints-factory.spec.ts | 2 +- express-zod-api/tests/env.spec.ts | 64 +++++++++--------- express-zod-api/tests/errors.spec.ts | 2 +- express-zod-api/tests/file-schema.spec.ts | 2 +- express-zod-api/tests/form-schema.spec.ts | 2 +- express-zod-api/tests/index.spec.ts | 4 +- express-zod-api/tests/integration.spec.ts | 2 +- express-zod-api/tests/io-schema.spec.ts | 4 +- .../tests/json-schema-helpers.spec.ts | 2 +- express-zod-api/tests/metadata.spec.ts | 2 +- express-zod-api/tests/middleware.spec.ts | 2 +- express-zod-api/tests/migration.spec.ts | 15 ++++- express-zod-api/tests/raw-schema.spec.ts | 2 +- express-zod-api/tests/result-handler.spec.ts | 2 +- express-zod-api/tests/result-helpers.spec.ts | 2 +- express-zod-api/tests/routable.spec.ts | 2 +- express-zod-api/tests/routing.spec.ts | 2 +- express-zod-api/tests/server.spec.ts | 2 +- express-zod-api/tests/sse.spec.ts | 2 +- express-zod-api/tests/system.spec.ts | 2 +- express-zod-api/tests/testing.spec.ts | 2 +- express-zod-api/tests/upload-schema.spec.ts | 2 +- express-zod-api/tests/zod-plugin.spec.ts | 33 ++++++---- express-zod-api/tests/zts.spec.ts | 2 +- express-zod-api/vitest.setup.ts | 10 ++- package.json | 2 +- yarn.lock | 15 ++--- 88 files changed, 275 insertions(+), 251 deletions(-) diff --git a/.github/workflows/validations.yml b/.github/workflows/validations.yml index e2e703ebf..86de13a3f 100644 --- a/.github/workflows/validations.yml +++ b/.github/workflows/validations.yml @@ -49,7 +49,7 @@ jobs: name: dist - name: Add dependencies run: | - yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod@^4.0.0-beta.20250505T195954 + yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod@next yarn add -D eslint@9.0 typescript-eslint@8.0 vitest tsx yarn add express-zod-api@./dist.tgz - name: Run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index f99c7a991..8dace0daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ ### v24.0.0 - Switched to Zod 4: - - Minimum supported version of `zod` is 4.0.0; - - ⚠️This version might not support all new features of Zod 4; + - Minimum supported version of `zod` is 3.25.0, BUT imports MUST be from `zod/v4`; + - Find out why it's so weird here: https://github.com/colinhacks/zod/issues/4371 - `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; diff --git a/README.md b/README.md index fb0dbb102..1947b0dbf 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ import { defaultEndpointsFactory } from "express-zod-api"; The endpoint responds with "Hello, World" or "Hello, {name}" if the name is supplied within `GET` request payload. ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; const helloWorldEndpoint = defaultEndpointsFactory.build({ // method: "get" (default) or array ["get", "post", ...] @@ -325,7 +325,7 @@ Inputs of middlewares are also available to endpoint handlers within `input`. Here is an example of the authentication middleware, that checks a `key` from input and `token` from headers: ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import createHttpError from "http-errors"; import { Middleware } from "express-zod-api"; @@ -455,7 +455,7 @@ You can implement additional validations within schemas using refinements. Validation errors are reported in a response with a status code `400`. ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import { Middleware } from "express-zod-api"; const nicknameConstraintMiddleware = new Middleware({ @@ -496,7 +496,7 @@ Since parameters of GET requests come in the form of strings, there is often a n arrays of numbers. ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; const getUserEndpoint = endpointsFactory.build({ input: z.object({ @@ -527,7 +527,7 @@ Here is a recommended solution: it is important to use shallow transformations o ```ts import camelize from "camelize-ts"; import snakify from "snakify-ts"; -import { z } from "zod"; +import { z } from "zod/v4"; const endpoint = endpointsFactory.build({ input: z @@ -580,7 +580,7 @@ provides your endpoint handler or middleware with a `Date`. It supports the foll format for the response transmission. Consider the following simplified example for better understanding: ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import { ez, defaultEndpointsFactory } from "express-zod-api"; const updateUserEndpoint = defaultEndpointsFactory.build({ @@ -790,7 +790,7 @@ In a similar way you can enable request headers as the input source. This is an ```typescript import { createConfig, Middleware } from "express-zod-api"; -import { z } from "zod"; +import { z } from "zod/v4"; createConfig({ inputSources: { @@ -825,7 +825,7 @@ type DefaultResponse = You can create your own result handler by using this example as a template: ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import { ResultHandler, ensureHttpError, @@ -951,7 +951,7 @@ which is `express.urlencoded()` by default. The request content type should be ` ```ts import { defaultEndpointsFactory, ez } from "express-zod-api"; -import { z } from "zod"; +import { z } from "zod/v4"; export const submitFeedbackEndpoint = defaultEndpointsFactory.build({ method: "post", @@ -991,7 +991,7 @@ const config = createConfig({ Then use `ez.upload()` schema for a corresponding property. The request content type must be `multipart/form-data`: ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import { ez, defaultEndpointsFactory } from "express-zod-api"; const fileUploadEndpoint = defaultEndpointsFactory.build({ @@ -1069,7 +1069,7 @@ from outputs of previous middlewares, if the one being tested somehow depends on either by `errorHandler` configured within given `configProps` or `defaultResultHandler`. ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import { Middleware, testMiddleware } from "express-zod-api"; const middleware = new Middleware({ @@ -1180,7 +1180,7 @@ Client application can subscribe to the event stream using `EventSource` class i the implementation emitting the `time` event each second. ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; import { EventStreamFactory } from "express-zod-api"; import { setTimeout } from "node:timers/promises"; @@ -1320,7 +1320,7 @@ You can also deprecate all routes the `Endpoint` assigned to by setting `Endpoin ```ts import { Routing, DependsOnMethod } from "express-zod-api"; -import { z } from "zod"; +import { z } from "zod/v4"; const someEndpoint = factory.build({ deprecated: true, // deprecates all routes the endpoint assigned to @@ -1345,7 +1345,7 @@ need to reuse a handling rule for multiple brands, use the exposed types `Depict ```ts import ts from "typescript"; -import { z } from "zod"; +import { z } from "zod/v4"; import { Documentation, Integration, @@ -1391,7 +1391,7 @@ in this case during development. You can achieve this verification by assigning reusing it in forced type of the output: ```typescript -import { z } from "zod"; +import { z } from "zod/v4"; const output = z.object({ anything: z.number(), diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index 3b84eba20..d328436da 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { defaultEndpointsFactory, ez } from "express-zod-api"; export const rawAcceptingEndpoint = defaultEndpointsFactory.build({ diff --git a/example/endpoints/create-user.ts b/example/endpoints/create-user.ts index c48b09a08..56b4260f0 100644 --- a/example/endpoints/create-user.ts +++ b/example/endpoints/create-user.ts @@ -1,6 +1,6 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; -import { z } from "zod"; +import { z } from "zod/v4"; import { statusDependingFactory } from "../factories"; /** @desc depending on the thrown error, the custom result handler of the factory responds slightly differently */ diff --git a/example/endpoints/delete-user.ts b/example/endpoints/delete-user.ts index e655a3e4a..09053766c 100644 --- a/example/endpoints/delete-user.ts +++ b/example/endpoints/delete-user.ts @@ -1,6 +1,6 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; -import { z } from "zod"; +import { z } from "zod/v4"; import { noContentFactory } from "../factories"; /** @desc The endpoint demonstrates no content response established by its factory */ diff --git a/example/endpoints/list-users.ts b/example/endpoints/list-users.ts index c99e2a57a..4a6313519 100644 --- a/example/endpoints/list-users.ts +++ b/example/endpoints/list-users.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { arrayRespondingFactory } from "../factories"; /** diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 838ee69b1..2f08cb251 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -1,6 +1,6 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; -import { z } from "zod"; +import { z } from "zod/v4"; import { defaultEndpointsFactory } from "express-zod-api"; import { methodProviderMiddleware } from "../middlewares"; diff --git a/example/endpoints/send-avatar.ts b/example/endpoints/send-avatar.ts index 9bc91b442..6f6890b13 100644 --- a/example/endpoints/send-avatar.ts +++ b/example/endpoints/send-avatar.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { fileSendingEndpointsFactory } from "../factories"; import { readFile } from "node:fs/promises"; diff --git a/example/endpoints/stream-avatar.ts b/example/endpoints/stream-avatar.ts index 677edc57b..2444741d5 100644 --- a/example/endpoints/stream-avatar.ts +++ b/example/endpoints/stream-avatar.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { fileStreamingEndpointsFactory } from "../factories"; export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({ diff --git a/example/endpoints/submit-feedback.ts b/example/endpoints/submit-feedback.ts index cc70895aa..24388721b 100644 --- a/example/endpoints/submit-feedback.ts +++ b/example/endpoints/submit-feedback.ts @@ -1,5 +1,5 @@ import { defaultEndpointsFactory, ez } from "express-zod-api"; -import { z } from "zod"; +import { z } from "zod/v4"; export const submitFeedbackEndpoint = defaultEndpointsFactory.build({ method: "post", diff --git a/example/endpoints/time-subscription.ts b/example/endpoints/time-subscription.ts index ca8625630..ef2e54135 100644 --- a/example/endpoints/time-subscription.ts +++ b/example/endpoints/time-subscription.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { setTimeout } from "node:timers/promises"; import { eventsFactory } from "../factories"; diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index 2d86697a0..bcbc3b110 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"; +import { z } from "zod/v4"; import { ez } from "express-zod-api"; import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories"; diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index 671c9da78..28e1e84d7 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { defaultEndpointsFactory, ez } from "express-zod-api"; import { createHash } from "node:crypto"; diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index a0ec40b6f..31a78f557 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -271,7 +271,7 @@ paths: id: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - id required: @@ -293,7 +293,7 @@ paths: id: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - id required: @@ -613,7 +613,7 @@ paths: data: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 event: type: string const: time @@ -622,7 +622,7 @@ paths: retry: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - data - event @@ -676,7 +676,7 @@ paths: crc: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - crc required: diff --git a/example/factories.ts b/example/factories.ts index 3eed6809c..3d807df23 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -9,7 +9,7 @@ import { } from "express-zod-api"; import { authMiddleware } from "./middlewares"; import { createReadStream } from "node:fs"; -import { z } from "zod"; +import { z } from "zod/v4"; /** @desc This factory extends the default one by enforcing the authentication using the specified middleware */ export const keyAndTokenAuthenticatedEndpointsFactory = diff --git a/example/middlewares.ts b/example/middlewares.ts index e3b68021f..2fa2351fe 100644 --- a/example/middlewares.ts +++ b/example/middlewares.ts @@ -1,6 +1,6 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; -import { z } from "zod"; +import { z } from "zod/v4"; import { Method, Middleware } from "express-zod-api"; export const authMiddleware = new Middleware({ diff --git a/express-zod-api/package.json b/express-zod-api/package.json index c1df9edef..307cfd2e8 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "^4.0.0-beta.20250505T012514" + "zod": "3.25.0-beta.20250518T002810" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/express-zod-api/src/api-response.ts b/express-zod-api/src/api-response.ts index 0d43926a8..4f914e0ab 100644 --- a/express-zod-api/src/api-response.ts +++ b/express-zod-api/src/api-response.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; export const defaultStatusCodes = { positive: 200, diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 991351170..d70c00569 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -3,10 +3,10 @@ import type { $ZodTransform, $ZodType, $ZodTypeInternals, -} from "@zod/core"; +} from "zod/v4/core"; import { Request } from "express"; import * as R from "ramda"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; import { OutputValidationError } from "./errors"; diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index 2222c80c1..26ecff8e9 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; export const ezDateInBrand = Symbol("DateIn"); diff --git a/express-zod-api/src/date-out-schema.ts b/express-zod-api/src/date-out-schema.ts index 6ab5e3e69..8f19d20c2 100644 --- a/express-zod-api/src/date-out-schema.ts +++ b/express-zod-api/src/date-out-schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; export const ezDateOutBrand = Symbol("DateOut"); diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index 36e442ee4..a1fec9e39 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -1,6 +1,6 @@ -import type { $ZodType, JSONSchema } from "@zod/core"; +import type { $ZodType, JSONSchema } from "zod/v4/core"; import * as R from "ramda"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { DeepCheckError } from "./errors"; diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 024f4ffcf..a0580c2c3 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { responseVariants } from "./api-response"; import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index dfd03fb23..1848a5b11 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -5,7 +5,7 @@ import type { $ZodTuple, $ZodType, JSONSchema, -} from "@zod/core"; +} from "zod/v4/core"; import { ExamplesObject, isReferenceObject, @@ -23,7 +23,7 @@ import { TagObject, } from "openapi3-ts/oas31"; import * as R from "ramda"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { FlatObject, @@ -45,7 +45,7 @@ import { ezDateOutBrand } from "./date-out-schema"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; -import { flattenIO, unref } from "./json-schema-helpers"; +import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; @@ -463,7 +463,6 @@ const depict = ( unrepresentable: "any", io: ctx.isResponse ? "output" : "input", override: (zodCtx) => { - unref(zodCtx.jsonSchema); const { brand } = globalRegistry.get(zodCtx.zodSchema)?.[metaSymbol] ?? {}; const depicter = diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index b30db131f..7d3692fa7 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import * as R from "ramda"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { NormalizedResponse, ResponseVariant } from "./api-response"; import { findRequestTypeDefiningSchema } from "./deep-checks"; import { diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index a88169324..0cd7654e9 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { z } from "zod"; +import { z } from "zod/v4"; import { EmptyObject, EmptySchema, FlatObject, Tag } from "./common-helpers"; import { Endpoint, Handler } from "./endpoint"; import { IOSchema, getFinalEndpointInputSchema } from "./io-schema"; diff --git a/express-zod-api/src/errors.ts b/express-zod-api/src/errors.ts index 4a34b205e..26aaaae2d 100644 --- a/express-zod-api/src/errors.ts +++ b/express-zod-api/src/errors.ts @@ -1,5 +1,5 @@ -import type { $ZodType } from "@zod/core"; -import { z } from "zod"; +import type { $ZodType } from "zod/v4/core"; +import { z } from "zod/v4"; import { getMessageFromError } from "./common-helpers"; import { OpenAPIContext } from "./documentation-helpers"; import type { Method } from "./method"; diff --git a/express-zod-api/src/file-schema.ts b/express-zod-api/src/file-schema.ts index 7b9cd5956..bfbeab196 100644 --- a/express-zod-api/src/file-schema.ts +++ b/express-zod-api/src/file-schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; export const ezFileBrand = Symbol("File"); diff --git a/express-zod-api/src/form-schema.ts b/express-zod-api/src/form-schema.ts index ba19a9cbb..2a482dcdd 100644 --- a/express-zod-api/src/form-schema.ts +++ b/express-zod-api/src/form-schema.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import type { $ZodShape } from "@zod/core"; +import { z } from "zod/v4"; +import type { $ZodShape } from "zod/v4/core"; export const ezFormBrand = Symbol("Form"); diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 8910163a5..e74e6b164 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -1,6 +1,6 @@ import * as R from "ramda"; import ts from "typescript"; -import { z } from "zod"; +import { z } from "zod/v4"; import { ResponseVariant, responseVariants } from "./api-response"; import { IntegrationBase } from "./integration-base"; import { diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index ea8160b51..e56e9ab89 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,5 +1,5 @@ import * as R from "ramda"; -import { z } from "zod"; +import { z } from "zod/v4"; import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 3aeaf3219..06dfda115 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -1,19 +1,7 @@ -import type { JSONSchema } from "@zod/core"; +import type { JSONSchema } from "zod/v4/core"; import * as R from "ramda"; import { combinations, isObject } from "./common-helpers"; -/** @link https://github.com/colinhacks/zod/issues/4275 */ -export const unref = ( - subject: JSONSchema.BaseSchema, -): Omit => { - while (subject._ref) { - const copy = { ...subject._ref }; - delete subject._ref; - Object.assign(subject, copy); - } - return subject; -}; - const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; @@ -36,7 +24,7 @@ const canMerge = R.pipe( R.isEmpty, ); -const nestOptional = R.pipe(unref, R.pair(true)); +const nestOptional = R.pair(true); export const flattenIO = ( jsonSchema: JSONSchema.BaseSchema, @@ -54,8 +42,8 @@ export const flattenIO = ( if (entry.description) flat.description ??= entry.description; if (entry.allOf) { stack.push( - ...entry.allOf.map(unref).map((one) => { - if (mode === "throw" && !(one.type == "object" && canMerge(one))) + ...entry.allOf.map((one) => { + if (mode === "throw" && !(one.type === "object" && canMerge(one))) throw new Error("Can not merge"); return R.pair(isOptional, one); }), diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 5f06dde24..4df62780f 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -1,5 +1,5 @@ import { combinations } from "./common-helpers"; -import { z } from "zod"; +import { z } from "zod/v4"; import * as R from "ramda"; export const metaSymbol = Symbol.for("express-zod-api"); diff --git a/express-zod-api/src/middleware.ts b/express-zod-api/src/middleware.ts index d8afb140b..ec0614b45 100644 --- a/express-zod-api/src/middleware.ts +++ b/express-zod-api/src/middleware.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { z } from "zod"; +import { z } from "zod/v4"; import { EmptySchema, FlatObject } from "./common-helpers"; import { InputValidationError } from "./errors"; import { IOSchema } from "./io-schema"; diff --git a/express-zod-api/src/migration.ts b/express-zod-api/src/migration.ts index c7e614b65..721aab09e 100644 --- a/express-zod-api/src/migration.ts +++ b/express-zod-api/src/migration.ts @@ -14,6 +14,7 @@ interface Queries { optionalPropStyle: NamedProp; depicter: TSESTree.ArrowFunctionExpression; nextCall: TSESTree.CallExpression; + zod: TSESTree.ImportDeclaration; } type Listener = keyof Queries; @@ -31,6 +32,7 @@ const queries: Record = { nextCall: `${NT.VariableDeclarator}[id.typeAnnotation.typeAnnotation.typeName.name='Depicter'] > ` + `${NT.ArrowFunctionExpression} ${NT.CallExpression}[callee.name='next']`, + zod: `${NT.ImportDeclaration}[source.value='zod']`, }; const listen = < @@ -120,6 +122,13 @@ const v24 = ESLintUtils.RuleCreator.withoutDocs({ data: { subject: "statement", from: "next()", to: "jsonSchema" }, fix: (fixer) => fixer.replaceText(node, "jsonSchema"), }), + zod: (node) => + ctx.report({ + node: node.source, + messageId: "change", + data: { subject: "import", from: "zod", to: "zod/v4" }, + fix: (fixer) => fixer.replaceText(node.source, `"zod/v4"`), + }), }), }); diff --git a/express-zod-api/src/raw-schema.ts b/express-zod-api/src/raw-schema.ts index 5becaf798..98b682dad 100644 --- a/express-zod-api/src/raw-schema.ts +++ b/express-zod-api/src/raw-schema.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import type { $ZodShape } from "@zod/core"; +import { z } from "zod/v4"; +import type { $ZodShape } from "zod/v4/core"; import { file } from "./file-schema"; export const ezRawBrand = Symbol("Raw"); diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index fcf5ccfae..930308c3c 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { z } from "zod"; +import { z } from "zod/v4"; import { ApiResponse, defaultStatusCodes, diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index ff686a41b..cd69d8e83 100644 --- a/express-zod-api/src/result-helpers.ts +++ b/express-zod-api/src/result-helpers.ts @@ -1,6 +1,6 @@ import { Request } from "express"; import createHttpError, { HttpError, isHttpError } from "http-errors"; -import { z } from "zod"; +import { z } from "zod/v4"; import { NormalizedResponse, ResponseVariant } from "./api-response"; import { FlatObject, diff --git a/express-zod-api/src/schema-walker.ts b/express-zod-api/src/schema-walker.ts index 3ba4535fe..1c726fb27 100644 --- a/express-zod-api/src/schema-walker.ts +++ b/express-zod-api/src/schema-walker.ts @@ -1,5 +1,5 @@ -import type { $ZodType, $ZodTypeDef } from "@zod/core"; -import { globalRegistry } from "zod"; +import type { $ZodType, $ZodTypeDef } from "zod/v4/core"; +import { globalRegistry } from "zod/v4"; import type { EmptyObject, FlatObject } from "./common-helpers"; import { metaSymbol } from "./metadata"; diff --git a/express-zod-api/src/sse.ts b/express-zod-api/src/sse.ts index 45a1da93f..587967fe3 100644 --- a/express-zod-api/src/sse.ts +++ b/express-zod-api/src/sse.ts @@ -1,5 +1,5 @@ import { Response } from "express"; -import { z } from "zod"; +import { z } from "zod/v4"; import { EmptySchema, FlatObject } from "./common-helpers"; import { contentTypes } from "./content-type"; import { EndpointsFactory } from "./endpoints-factory"; diff --git a/express-zod-api/src/upload-schema.ts b/express-zod-api/src/upload-schema.ts index e6e5d634d..0ff633013 100644 --- a/express-zod-api/src/upload-schema.ts +++ b/express-zod-api/src/upload-schema.ts @@ -1,5 +1,5 @@ import type { UploadedFile } from "express-fileupload"; -import { z } from "zod"; +import { z } from "zod/v4"; export const ezUploadBrand = Symbol("Upload"); diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index ce0e7e8f3..43f6ca956 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -9,20 +9,25 @@ * @desc Ensures that the brand withstands additional refinements or checks * */ import * as R from "ramda"; -import { z, globalRegistry } from "zod"; +import { z, globalRegistry } from "zod/v4"; import { FlatObject } from "./common-helpers"; import { Metadata, metaSymbol } from "./metadata"; import { Intact, Remap } from "./mapping-helpers"; -import type { $ZodType, $ZodShape, $ZodLooseShape } from "@zod/core"; +import type { + $ZodType, + $ZodShape, + $ZodLooseShape, + $ZodObjectConfig, +} from "zod/v4/core"; -declare module "@zod/core" { +declare module "zod/v4/core" { interface GlobalMeta { [metaSymbol]?: Metadata; deprecated?: boolean; } } -declare module "zod" { +declare module "zod/v4" { interface ZodType { /** @desc Add an example value (before any transformations, can be called multiple times) */ example(example: z.input): this; @@ -35,8 +40,7 @@ declare module "zod" { interface ZodObject< // @ts-expect-error -- external issue out Shape extends $ZodShape = $ZodLooseShape, - OutExtra extends Record = Record, - InExtra extends Record = Record, + out Config extends $ZodObjectConfig = $ZodObjectConfig, > extends ZodType { remap( mapping: U, @@ -45,7 +49,7 @@ declare module "zod" { this, z.ZodTransform // internal type simplified >, - z.ZodObject & Intact, OutExtra, InExtra> + z.ZodObject & Intact, Config> >; remap( mapper: (subject: Shape) => U, @@ -108,7 +112,9 @@ const objectMapper = function ( R.map(([key, value]) => R.pair(tool[String(key)] || key, value)), R.fromPairs, ); - const nextShape = transformer(R.clone(this._zod.def.shape)); // immutable + const nextShape = transformer( + R.map(R.invoker(0, "clone"), this._zod.def.shape), // immutable, changed from R.clone due to failure + ); const hasPassThrough = this._zod.def.catchall instanceof z.ZodUnknown; const output = (hasPassThrough ? z.looseObject : z.object)(nextShape); // proxies unknown keys when set to "passthrough" return this.transform(transformer).pipe(output); diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index cc890007d..ba5827551 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -17,10 +17,10 @@ import type { $ZodTransform, $ZodTuple, $ZodUnion, -} from "@zod/core"; +} from "zod/v4/core"; import * as R from "ramda"; import ts from "typescript"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { getTransformedType, isOptional, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index f789b9d7f..0e723377e 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -632,7 +632,7 @@ paths: items: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 - name: unlimited in: query required: true @@ -977,9 +977,9 @@ paths: type: - integer - "null" - exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 default: 123 + exclusiveMinimum: 0 + maximum: 9007199254740991 responses: "200": description: GET /v1/getSomething Positive response @@ -1091,7 +1091,7 @@ paths: two: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - one - two @@ -1099,7 +1099,7 @@ paths: properties: two: type: integer - exclusiveMinimum: -9007199254740991 + minimum: -9007199254740991 exclusiveMaximum: 0 three: type: string @@ -1128,7 +1128,7 @@ paths: - type: string - type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - or required: @@ -1474,10 +1474,10 @@ paths: intPositive: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 intNegative: type: integer - exclusiveMinimum: -9007199254740991 + minimum: -9007199254740991 exclusiveMaximum: 0 intLimited: type: integer @@ -1955,7 +1955,7 @@ paths: two: type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - one - two @@ -2061,7 +2061,7 @@ paths: - type: string - type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 items: not: {} required: @@ -3861,7 +3861,7 @@ paths: description: some positive integer type: integer exclusiveMinimum: 0 - exclusiveMaximum: 9007199254740991 + maximum: 9007199254740991 required: - result required: diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 667dbd891..3d945f20c 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -21,6 +21,7 @@ exports[`Endpoint > .getResponses() > should return the negative responses (read "application/json", ], "schema": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "error": { "properties": { @@ -57,6 +58,7 @@ exports[`Endpoint > .getResponses() > should return the positive responses (read "application/json", ], "schema": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "data": { "properties": { diff --git a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap index 694f854dd..e34c9bf4d 100644 --- a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap @@ -2,6 +2,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "allOf": [ @@ -46,6 +47,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 2`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "b": { "type": "boolean", @@ -60,6 +62,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "properties": { @@ -90,6 +93,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 2`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "o": { "type": "boolean", @@ -104,6 +108,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "properties": { @@ -133,6 +138,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 2`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "b": { "type": "boolean", @@ -147,6 +153,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "anyOf": [ @@ -191,6 +198,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 2`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "b": { "type": "boolean", @@ -205,6 +213,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd exports[`EndpointsFactory > .buildVoid() > Should be a shorthand for empty object output 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": {}, "required": [], "type": "object", diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 71ea7bcfa..386fe6914 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -2,11 +2,11 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmail' definition 1`] = ` { - "check": [Function], - "computed": { + "bag": { "format": "email", "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, }, + "check": [Function], "constr": [Function], "def": { "abort": false, @@ -40,9 +40,8 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmai exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumber' definition 1`] = ` { - "computed": { + "bag": { "format": "safeint", - "inclusive": true, "maximum": 9007199254740991, "minimum": -9007199254740991, "pattern": /\\^\\\\d\\+\\$/, @@ -51,6 +50,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "def": { "checks": [ { + "$schema": "https://json-schema.org/draft-2020-12/schema", "maximum": 9007199254740991, "minimum": -9007199254740991, "type": "integer", @@ -73,14 +73,13 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumberFormat' definition 1`] = ` { - "check": [Function], - "computed": { + "bag": { "format": "safeint", - "inclusive": true, "maximum": 9007199254740991, "minimum": -9007199254740991, "pattern": /\\^\\\\d\\+\\$/, }, + "check": [Function], "constr": [Function], "def": { "abort": false, @@ -109,14 +108,13 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumberFormat' definition 2`] = ` { - "check": [Function], - "computed": { + "bag": { "format": "int32", - "inclusive": true, "maximum": 2147483647, "minimum": -2147483648, "pattern": /\\^\\\\d\\+\\$/, }, + "check": [Function], "constr": [Function], "def": { "abort": false, @@ -145,38 +143,19 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumberFormat' definition 3`] = ` { - "check": [Function], - "computed": { + "bag": { "format": "safeint", - "inclusive": true, "maximum": 1000, "minimum": -9007199254740991, "pattern": /\\^\\\\d\\+\\$/, }, + "check": [Function], "constr": [Function], "def": { "abort": false, "check": "number_format", "checks": [ - $ZodCheckLessThan { - "_zod": { - "check": [Function], - "constr": [Function], - "def": { - "check": "less_than", - "inclusive": true, - "value": 1000, - }, - "deferred": [], - "onattach": [ - [Function], - ], - "traits": Set { - "$ZodCheckLessThan", - "$ZodCheck", - }, - }, - }, + $ZodCheckLessThan {}, ], "format": "safeint", "type": "number", @@ -202,7 +181,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodString' definition 1`] = ` { - "computed": { + "bag": { "format": "email", "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, }, @@ -210,6 +189,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri "def": { "checks": [ { + "$schema": "https://json-schema.org/draft-2020-12/schema", "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "type": "string", @@ -237,6 +217,7 @@ exports[`Environment checks > Zod imperfections > circular object schema has no "configurable": true, "enumerable": true, "value": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "items": { "properties": { "features": { @@ -260,6 +241,7 @@ exports[`Environment checks > Zod imperfections > circular object schema has no "configurable": true, "enumerable": true, "value": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "type": "string", }, "writable": true, @@ -267,23 +249,25 @@ exports[`Environment checks > Zod imperfections > circular object schema has no } `; -exports[`Environment checks > Zod imperfections > input examples of transformations 1`] = ` +exports[`Environment checks > Zod imperfections > meta overrides, does not merge 1`] = ` { - "examples": [ - 4, - ], - "type": "string", + "title": "last", } `; -exports[`Environment checks > Zod imperfections > meta overrides, does not merge 1`] = ` +exports[`Environment checks > Zod new features > input examples of transformations 1`] = ` { - "title": "last", + "$schema": "https://json-schema.org/draft-2020-12/schema", + "examples": [ + "test", + ], + "type": "string", } `; -exports[`Environment checks > Zod imperfections > output examples of transformations 1`] = ` +exports[`Environment checks > Zod new features > output examples of transformations 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "examples": [ 4, ], diff --git a/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap index af5811b91..b1c8171d6 100644 --- a/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap @@ -6,9 +6,7 @@ exports[`ez.file() > parsing > should perform additional check for base64 file 1 "code": "invalid_format", "format": "base64", "message": "Invalid base64-encoded string", - "origin": "string", "path": [], - "pattern": "/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/", }, ] `; diff --git a/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap index 3a0d6a9dd..54d144473 100644 --- a/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ez.form() > parsing > should throw for missing props as a regular object schema 1`] = ` -ZodError { +ZodError({ "issues": [ { "code": "invalid_type", @@ -12,5 +12,5 @@ ZodError { ], }, ], -} +}) `; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 16bdb933c..45022ce85 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -2,6 +2,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should handle no middlewares 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "four": { "type": "boolean", @@ -16,6 +17,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge input object schemas 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "allOf": [ @@ -75,6 +77,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge intersection object schemas 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "allOf": [ @@ -164,6 +167,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge mixed object schemas 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "allOf": [ @@ -238,6 +242,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge union object schemas 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "allOf": [ { "allOf": [ diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index ed0163083..18c5d84ff 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -2,6 +2,7 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = ` { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "data": { "type": "string", @@ -13,8 +14,8 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = "type": "string", }, "retry": { - "exclusiveMaximum": 9007199254740991, "exclusiveMinimum": 0, + "maximum": 9007199254740991, "type": "integer", }, }, @@ -33,6 +34,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "anyOf": [ { "properties": { @@ -46,8 +48,8 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "type": "string", }, "retry": { - "exclusiveMaximum": 9007199254740991, "exclusiveMinimum": 0, + "maximum": 9007199254740991, "type": "integer", }, }, @@ -69,8 +71,8 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "type": "string", }, "retry": { - "exclusiveMaximum": 9007199254740991, "exclusiveMinimum": 0, + "maximum": 9007199254740991, "type": "integer", }, }, @@ -96,6 +98,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/plain", ], "schema": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "type": "string", }, "statusCodes": [ @@ -112,6 +115,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "properties": { "data": { "type": "string", @@ -123,8 +127,8 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "type": "string", }, "retry": { - "exclusiveMaximum": 9007199254740991, "exclusiveMinimum": 0, + "maximum": 9007199254740991, "type": "integer", }, }, @@ -148,6 +152,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/plain", ], "schema": { + "$schema": "https://json-schema.org/draft-2020-12/schema", "type": "string", }, "statusCodes": [ diff --git a/express-zod-api/tests/__snapshots__/system.spec.ts.snap b/express-zod-api/tests/__snapshots__/system.spec.ts.snap index c22eb5407..19d959f79 100644 --- a/express-zod-api/tests/__snapshots__/system.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/system.spec.ts.snap @@ -122,7 +122,7 @@ exports[`App in production mode > Validation > Problem 787: Should NOT treat Zod "Server side error", { "error": InternalServerError({ - "cause": ZodError { + "cause": ZodError({ "issues": [ { "code": "invalid_type", @@ -131,7 +131,7 @@ exports[`App in production mode > Validation > Problem 787: Should NOT treat Zod "path": [], }, ], - }, + }), "message": "Invalid input: expected number, received string", }), "payload": { @@ -166,7 +166,7 @@ exports[`App in production mode > Validation > Should fail on handler output typ "Server side error", { "error": InternalServerError({ - "cause": ZodError { + "cause": ZodError({ "issues": [ { "code": "too_small", @@ -179,7 +179,7 @@ exports[`App in production mode > Validation > Should fail on handler output typ ], }, ], - }, + }), "message": "output/anything: Too small: expected number to be >0", }), "payload": { diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index d697b3bca..48508be52 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -10,7 +10,7 @@ import { pullExampleProps, getRoutePathParams, } from "../src/common-helpers"; -import { z } from "zod"; +import { z } from "zod/v4"; import { makeRequestMock } from "../src/testing"; describe("Common Helpers", () => { diff --git a/express-zod-api/tests/date-in-schema.spec.ts b/express-zod-api/tests/date-in-schema.spec.ts index 60384c1ae..432dc8b08 100644 --- a/express-zod-api/tests/date-in-schema.spec.ts +++ b/express-zod-api/tests/date-in-schema.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { ezDateInBrand } from "../src/date-in-schema"; import { ez } from "../src"; import { metaSymbol } from "../src/metadata"; diff --git a/express-zod-api/tests/date-out-schema.spec.ts b/express-zod-api/tests/date-out-schema.spec.ts index 25dd350a4..0ad0b0eb7 100644 --- a/express-zod-api/tests/date-out-schema.spec.ts +++ b/express-zod-api/tests/date-out-schema.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { ezDateOutBrand } from "../src/date-out-schema"; import { ez } from "../src"; import { metaSymbol } from "../src/metadata"; diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index f4695711e..5819791ce 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -1,6 +1,6 @@ import { UploadedFile } from "express-fileupload"; -import { globalRegistry, z } from "zod"; -import type { $brand, $ZodType } from "@zod/core"; +import { globalRegistry, z } from "zod/v4"; +import type { $brand, $ZodType } from "zod/v4/core"; import { ez } from "../src"; import { findNestedSchema, hasCycle } from "../src/deep-checks"; import { metaSymbol } from "../src/metadata"; diff --git a/express-zod-api/tests/depends-on-method.spec.ts b/express-zod-api/tests/depends-on-method.spec.ts index 0b1f862a7..cf9841c8f 100644 --- a/express-zod-api/tests/depends-on-method.spec.ts +++ b/express-zod-api/tests/depends-on-method.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { DependsOnMethod, EndpointsFactory, diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 90a3c6d9f..89b679eb3 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -1,7 +1,7 @@ -import { JSONSchema } from "@zod/core"; +import { JSONSchema } from "zod/v4/core"; import { SchemaObject } from "openapi3-ts/oas31"; import * as R from "ramda"; -import { z } from "zod"; +import { z } from "zod/v4"; import { ez } from "../src"; import { OpenAPIContext, diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 00dc3d278..2241cb666 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -12,7 +12,7 @@ import { Depicter, } from "../src"; import { contentTypes } from "../src/content-type"; -import { z } from "zod"; +import { z } from "zod/v4"; import { givePort } from "../../tools/ports"; describe("Documentation", () => { diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index 165d921e9..bb33e2602 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { EndpointsFactory, Middleware, diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 466993538..8780588b7 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -9,7 +9,7 @@ import { } from "../src"; import { EmptyObject, EmptySchema } from "../src/common-helpers"; import { Endpoint } from "../src/endpoint"; -import { z } from "zod"; +import { z } from "zod/v4"; describe("EndpointsFactory", () => { const resultHandlerMock = new ResultHandler({ diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 9bea65916..6316390c8 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -1,6 +1,6 @@ import createHttpError from "http-errors"; import * as R from "ramda"; -import { z } from "zod"; +import { z } from "zod/v4"; describe("Environment checks", () => { describe("Zod Dates", () => { @@ -46,26 +46,10 @@ describe("Environment checks", () => { }); test("bigint is not representable", () => { - expect(z.toJSONSchema(z.bigint(), { unrepresentable: "any" })).toEqual( - {}, - ); + const json = z.toJSONSchema(z.bigint(), { unrepresentable: "any" }); + expect(R.omit(["$schema"], json)).toEqual({}); }); - /** @link https://github.com/colinhacks/zod/issues/4274 */ - test.each(["input", "output"] as const)( - "%s examples of transformations", - (io) => { - const schema = z - .string() - .meta({ examples: ["test"] }) - .transform(Number) - .meta({ examples: [4] }); - expect( - z.toJSONSchema(schema, { io, unrepresentable: "any" }), - ).toMatchSnapshot(); - }, - ); - test("meta overrides, does not merge", () => { const schema = z .string() @@ -75,18 +59,6 @@ describe("Environment checks", () => { expect(schema.meta()).toMatchSnapshot(); }); - /** @link https://github.com/colinhacks/zod/issues/4320 */ - test("input type of a loose object does not allow extra keys", () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- this is fine - const schema = z.looseObject({}); - expectTypeOf>().toEqualTypeOf< - Record // ok - >(); - expectTypeOf>().not.toEqualTypeOf< - Record // not ok - >(); - }); - test("circular object schema has no sign of getter in its shape", () => { const schema = z.object({ name: z.string(), @@ -98,6 +70,18 @@ describe("Environment checks", () => { Object.getOwnPropertyDescriptors(schema._zod.def.shape), ).toMatchSnapshot(); }); + + /** + * told Colin directly + * @todo adjust vitest.setup.ts on custom serialization if fixed + * */ + test("ZodError inequality", () => { + try { + z.number().parse("test"); + } catch (caught) { + expect(z.number().safeParse("test").error).not.toEqual(caught); + } + }); }); describe("Zod new features", () => { @@ -141,6 +125,24 @@ describe("Environment checks", () => { expect(boolSchema.isOptional()).toBeTruthy(); expect(boolSchema.isNullable()).toBeTruthy(); }); + + /** + * @link https://github.com/colinhacks/zod/issues/4274 + * @todo this fact can be used for switching to native examples + * */ + test.each(["input", "output"] as const)( + "%s examples of transformations", + (io) => { + const schema = z + .string() + .meta({ examples: ["test"] }) + .transform(Number) + .meta({ examples: [4] }); + expect( + z.toJSONSchema(schema, { io, unrepresentable: "any" }), + ).toMatchSnapshot(); + }, + ); }); describe("Vitest error comparison", () => { diff --git a/express-zod-api/tests/errors.spec.ts b/express-zod-api/tests/errors.spec.ts index 552978cde..6ab84605d 100644 --- a/express-zod-api/tests/errors.spec.ts +++ b/express-zod-api/tests/errors.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { DocumentationError, RoutingError } from "../src"; import { IOSchemaError, diff --git a/express-zod-api/tests/file-schema.spec.ts b/express-zod-api/tests/file-schema.spec.ts index 1c5e95c6b..c9c30b708 100644 --- a/express-zod-api/tests/file-schema.spec.ts +++ b/express-zod-api/tests/file-schema.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { ezFileBrand } from "../src/file-schema"; import { ez } from "../src"; import { readFile } from "node:fs/promises"; diff --git a/express-zod-api/tests/form-schema.spec.ts b/express-zod-api/tests/form-schema.spec.ts index de2aa3768..44e09d2d1 100644 --- a/express-zod-api/tests/form-schema.spec.ts +++ b/express-zod-api/tests/form-schema.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { ez } from "../src"; import { ezFormBrand } from "../src/form-schema"; import { metaSymbol } from "../src/metadata"; diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index 037a309c5..d835b7d07 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -1,8 +1,8 @@ -import type { $ZodType, JSONSchema } from "@zod/core"; +import type { $ZodType, JSONSchema } from "zod/v4/core"; import { IRouter } from "express"; import ts from "typescript"; import { expectTypeOf } from "vitest"; -import { z } from "zod"; +import { z } from "zod/v4"; import * as entrypoint from "../src"; import { ApiResponse, diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index ec9beb660..06349e4e3 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -1,5 +1,5 @@ import ts from "typescript"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { EndpointsFactory, Integration, diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index bf3259258..b53ebb601 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,5 +1,5 @@ import { expectTypeOf } from "vitest"; -import { z } from "zod"; +import { z } from "zod/v4"; import { IOSchema, Middleware, ez } from "../src"; import { getFinalEndpointInputSchema } from "../src/io-schema"; import { metaSymbol } from "../src/metadata"; @@ -119,7 +119,7 @@ describe("I/O Schema and related helpers", () => { expectTypeOf(z.object({}).transform(() => [])).not.toExtend(); }); test("does not accept piping into another kind of schema", () => { - expectTypeOf(z.unknown({}).pipe(z.string())).not.toExtend(); + expectTypeOf(z.unknown().pipe(z.string())).not.toExtend(); expectTypeOf( z .object({ s: z.string() }) diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index defeeed76..bca124739 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { flattenIO } from "../src/json-schema-helpers"; describe("JSON Schema helpers", () => { diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts index c099d0eac..f78820dd0 100644 --- a/express-zod-api/tests/metadata.spec.ts +++ b/express-zod-api/tests/metadata.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { mixExamples, metaSymbol } from "../src/metadata"; describe("Metadata", () => { diff --git a/express-zod-api/tests/middleware.spec.ts b/express-zod-api/tests/middleware.spec.ts index 0505f57da..bbebfb1bc 100644 --- a/express-zod-api/tests/middleware.spec.ts +++ b/express-zod-api/tests/middleware.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { InputValidationError, Middleware } from "../src"; import { EmptyObject } from "../src/common-helpers"; import { AbstractMiddleware, ExpressMiddleware } from "../src/middleware"; diff --git a/express-zod-api/tests/migration.spec.ts b/express-zod-api/tests/migration.spec.ts index 89862d9ca..17b259e46 100644 --- a/express-zod-api/tests/migration.spec.ts +++ b/express-zod-api/tests/migration.spec.ts @@ -20,8 +20,9 @@ describe("Migration", () => { tester.run("v24", migration.rules.v24, { valid: [ `new Documentation({});`, - `new Integration({})`, - `const rule: Depicter = () => {}`, + `new Integration({});`, + `const rule: Depicter = () => {};`, + `import {} from "zod/v4";`, ], invalid: [ { @@ -66,6 +67,16 @@ describe("Migration", () => { }, ], }, + { + code: `import {} from "zod";`, + output: `import {} from "zod/v4";`, + errors: [ + { + messageId: "change", + data: { subject: "import", from: "zod", to: "zod/v4" }, + }, + ], + }, ], }); }); diff --git a/express-zod-api/tests/raw-schema.spec.ts b/express-zod-api/tests/raw-schema.spec.ts index c308f54fb..a0b17cffc 100644 --- a/express-zod-api/tests/raw-schema.spec.ts +++ b/express-zod-api/tests/raw-schema.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { ez } from "../src"; import { metaSymbol } from "../src/metadata"; import { ezRawBrand } from "../src/raw-schema"; diff --git a/express-zod-api/tests/result-handler.spec.ts b/express-zod-api/tests/result-handler.spec.ts index 7fe11286a..7a22c8acc 100644 --- a/express-zod-api/tests/result-handler.spec.ts +++ b/express-zod-api/tests/result-handler.spec.ts @@ -1,6 +1,6 @@ import { Response } from "express"; import createHttpError from "http-errors"; -import { z } from "zod"; +import { z } from "zod/v4"; import { InputValidationError, arrayResultHandler, diff --git a/express-zod-api/tests/result-helpers.spec.ts b/express-zod-api/tests/result-helpers.spec.ts index 4aa6dbc77..fcaa08d25 100644 --- a/express-zod-api/tests/result-helpers.spec.ts +++ b/express-zod-api/tests/result-helpers.spec.ts @@ -1,5 +1,5 @@ import createHttpError from "http-errors"; -import { z } from "zod"; +import { z } from "zod/v4"; import { InputValidationError, OutputValidationError } from "../src"; import { ensureHttpError, diff --git a/express-zod-api/tests/routable.spec.ts b/express-zod-api/tests/routable.spec.ts index ac14ac457..0e3046670 100644 --- a/express-zod-api/tests/routable.spec.ts +++ b/express-zod-api/tests/routable.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { defaultEndpointsFactory, DependsOnMethod } from "../src"; const endpoint = defaultEndpointsFactory.build({ diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 9a6b67f3c..3a8b50ff0 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -4,7 +4,7 @@ import { staticHandler, staticMock, } from "./express-mock"; -import { z } from "zod"; +import { z } from "zod/v4"; import { DependsOnMethod, EndpointsFactory, diff --git a/express-zod-api/tests/server.spec.ts b/express-zod-api/tests/server.spec.ts index 6d2c9bc01..cbf7a7b29 100644 --- a/express-zod-api/tests/server.spec.ts +++ b/express-zod-api/tests/server.spec.ts @@ -13,7 +13,7 @@ import { httpListenSpy, httpsListenSpy, } from "./http-mock"; -import { z } from "zod"; +import { z } from "zod/v4"; import { AppConfig, BuiltinLogger, diff --git a/express-zod-api/tests/sse.spec.ts b/express-zod-api/tests/sse.spec.ts index ef90eb00c..77f6b965d 100644 --- a/express-zod-api/tests/sse.spec.ts +++ b/express-zod-api/tests/sse.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { FlatObject, Middleware, diff --git a/express-zod-api/tests/system.spec.ts b/express-zod-api/tests/system.spec.ts index 7b52085e8..b6f4bcab1 100644 --- a/express-zod-api/tests/system.spec.ts +++ b/express-zod-api/tests/system.spec.ts @@ -2,7 +2,7 @@ import cors from "cors"; import depd from "depd"; import express from "express"; import { readFile } from "node:fs/promises"; -import { z } from "zod"; +import { z } from "zod/v4"; import { EndpointsFactory, Method, diff --git a/express-zod-api/tests/testing.spec.ts b/express-zod-api/tests/testing.spec.ts index 41e96b6ba..68e11768d 100644 --- a/express-zod-api/tests/testing.spec.ts +++ b/express-zod-api/tests/testing.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { CommonConfig, defaultEndpointsFactory, diff --git a/express-zod-api/tests/upload-schema.spec.ts b/express-zod-api/tests/upload-schema.spec.ts index eb58f093d..6177bcfc9 100644 --- a/express-zod-api/tests/upload-schema.spec.ts +++ b/express-zod-api/tests/upload-schema.spec.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { ez } from "../src"; import { metaSymbol } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index 587456728..fff0e3f07 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -1,5 +1,5 @@ import camelize from "camelize-ts"; -import { z } from "zod"; +import { z } from "zod/v4"; import { metaSymbol } from "../src/metadata"; describe("Zod Runtime Plugin", () => { @@ -102,9 +102,10 @@ describe("Zod Runtime Plugin", () => { const schema = z.object({ user_id: z.string() }); const mappedSchema = schema.remap({ user_id: "userId" }); expect(mappedSchema.in.in).toEqual(schema); - expect(mappedSchema.out.shape).toEqual({ - userId: schema.shape.user_id, - }); + expect(mappedSchema.out.shape).toHaveProperty( + "userId", + expect.any(z.ZodString), + ); expect(mappedSchema._zod.def.out.shape.userId).not.toBe( schema.shape.user_id, ); @@ -118,10 +119,14 @@ describe("Zod Runtime Plugin", () => { (mapping) => { const schema = z.object({ user_id: z.string(), name: z.string() }); const mappedSchema = schema.remap(mapping); - expect(mappedSchema._zod.def.out.shape).toEqual({ - userId: schema.shape.user_id, - name: schema.shape.name, - }); + expect(mappedSchema._zod.def.out.shape).toHaveProperty( + "userId", + expect.any(z.ZodString), + ); + expect(mappedSchema._zod.def.out.shape).toHaveProperty( + "name", + expect.any(z.ZodString), + ); expect(mappedSchema.parse({ user_id: "test", name: "some" })).toEqual({ userId: "test", name: "some", @@ -132,10 +137,14 @@ describe("Zod Runtime Plugin", () => { test("should support a mapping function", () => { const schema = z.object({ user_id: z.string(), name: z.string() }); const mappedSchema = schema.remap((shape) => camelize(shape, true)); - expect(mappedSchema._zod.def.out.shape).toEqual({ - userId: schema.shape.user_id, - name: schema.shape.name, - }); + expect(mappedSchema._zod.def.out.shape).toHaveProperty( + "userId", + expect.any(z.ZodString), + ); + expect(mappedSchema._zod.def.out.shape).toHaveProperty( + "name", + expect.any(z.ZodString), + ); expect(mappedSchema.parse({ user_id: "test", name: "some" })).toEqual({ userId: "test", name: "some", diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index e1649ac48..5a12c5e94 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -1,5 +1,5 @@ import ts from "typescript"; -import { z } from "zod"; +import { z } from "zod/v4"; import { ez } from "../src"; import { f, printNode } from "../src/typescript-api"; import { zodToTs } from "../src/zts"; diff --git a/express-zod-api/vitest.setup.ts b/express-zod-api/vitest.setup.ts index b7c867470..d52c1e218 100644 --- a/express-zod-api/vitest.setup.ts +++ b/express-zod-api/vitest.setup.ts @@ -1,6 +1,6 @@ import "./src/zod-plugin"; // required for tests importing sources using the plugin methods import type { NewPlugin } from "@vitest/pretty-format"; -import { globalRegistry, z } from "zod"; +import { globalRegistry, z } from "zod/v4"; import { ResultHandlerError } from "./src/errors"; import { metaSymbol } from "./src/metadata"; @@ -10,12 +10,16 @@ const errorSerializer: NewPlugin = { serialize: (error: Error, config, indentation, depth, refs, printer) => { const { name, message, cause } = error; const { handled } = error instanceof ResultHandlerError ? error : {}; + const { issues } = error instanceof z.ZodError ? error : {}; const obj = Object.assign( - { message }, + {}, + message && { message }, cause && { cause }, handled && { handled }, + issues && { issues }, ); - return `${name}(${printer(obj, config, indentation, depth, refs)})`; + // @todo external issue with ZodError.name + return `${issues ? "ZodError" : name}(${printer(obj, config, indentation, depth, refs)})`; }, }; diff --git a/package.json b/package.json index 4cdc490e7..c8481106f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", "vitest": "^3.1.3", - "zod": "^4.0.0-beta.20250505T195954" + "zod": "3.25.0-beta.20250518T002810" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 07c5a1e14..4d0c0076f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,11 +825,6 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" -"@zod/core@0.11.6": - version "0.11.6" - resolved "https://registry.yarnpkg.com/@zod/core/-/core-0.11.6.tgz#9216e98848dc9364eda35e3da90f5362f10e8887" - integrity sha512-03Bv82fFSfjDAvMfdHHdGSS6SOJs0iCcJlWJv1kJHRtoTT02hZpyip/2Lk6oo4l4FtjuwTrsEQTwg/LD8I7dJA== - accepts@^1.3.7: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -3128,9 +3123,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.0.0-beta.20250505T195954: - version "4.0.0-beta.20250505T195954" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.0.0-beta.20250505T195954.tgz#ba9da025671de2dde9d4d033089f03c37a35022f" - integrity sha512-iB8WvxkobVIXMARvQu20fKvbS7mUTiYRpcD8OQV1xjRhxO0EEpYIRJBk6yfBzHAHEdOSDh3SxDITr5Eajr2vtg== - dependencies: - "@zod/core" "0.11.6" +zod@3.25.0-beta.20250518T002810: + version "3.25.0-beta.20250518T002810" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250518T002810.tgz#9ac105aea4b6e0d072a382893c3e6556670048c8" + integrity sha512-3/aIqMbUXG9EjTelJkDcWd+izJP5MxFgQEMSYI8n41pwYhRDYYxy2dnbkgfNcnLbFZ9uByZn9XXqHTh05QHqSQ== From b27afd3191b296f119420e3aecce88abfd65deb5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 18 May 2025 07:19:27 +0200 Subject: [PATCH 106/187] A couple more assertions on ZodError. --- express-zod-api/tests/env.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 6316390c8..5d38ef561 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -79,7 +79,10 @@ describe("Environment checks", () => { try { z.number().parse("test"); } catch (caught) { - expect(z.number().safeParse("test").error).not.toEqual(caught); + const returned = z.number().safeParse("test").error; + expect(returned).not.toEqual(caught); + expect(returned).toBeInstanceOf(z.ZodError); + expect(caught).toBeInstanceOf(z.ZodError); } }); }); From 04c9b6c921af88da4c12b3897d18a8e6f3843623 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 18 May 2025 07:44:21 +0200 Subject: [PATCH 107/187] rm 3 from zod installation command. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1947b0dbf..d106835b0 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Install the framework, its peer dependencies and type assistance packages using ```shell # example for yarn: -yarn add express-zod-api express zod@3 typescript http-errors +yarn add express-zod-api express zod typescript http-errors yarn add -D @types/express @types/node @types/http-errors ``` From 6248795791261c9a6fa70426d00db608ccf36a26 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 18 May 2025 07:59:49 +0200 Subject: [PATCH 108/187] Changelog: add migration sample, explaining breaking change. --- CHANGELOG.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dace0daf..2484bb0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,12 @@ - Switched to Zod 4: - Minimum supported version of `zod` is 3.25.0, BUT imports MUST be from `zod/v4`; - - Find out why it's so weird here: https://github.com/colinhacks/zod/issues/4371 + - Explanation of the versioning strategy: https://github.com/colinhacks/zod/issues/4371; + - Express Zod API, however, is not aiming to support both Zod 3 and Zod 4 simultaneously due to: + - incompatibility of data structures; + - operating composite schemas (need to avoid mixing schemas of different versions); + - 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; @@ -23,6 +28,23 @@ - `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality. - Changes to the plugin: - Brand is the only kind of metadata that withstands refinements and checks. +- Consider the automated migration using the built-in ESLint rule. + +```js +// eslint.config.mjs — minimal ESLint 9 config to apply migrations automatically using "eslint --fix" +import parser from "@typescript-eslint/parser"; +import migration from "express-zod-api/migration"; + +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v24": "error" } }, +]; +``` + +```diff +- import { z } from "zod"; ++ import { z } from "zod/v4"; +``` ## Version 23 From 46adaf2ee8e18d061b706c003f10c33585b9e03c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 19 May 2025 09:19:31 +0200 Subject: [PATCH 109/187] Changes to optionality (#2633) - address replaced `optionaility` with `optin` and `optout` - that also fixed question marks on optional schemas with transformations - https://github.com/colinhacks/zod/issues/4322 - new bug: https://github.com/colinhacks/zod/pull/4407 - also, it appears to change the way `.meta()` works: now it seems to merge the metadata --- express-zod-api/package.json | 2 +- express-zod-api/src/common-helpers.ts | 18 ++-------- express-zod-api/src/zod-plugin.ts | 2 +- .../tests/__snapshots__/env.spec.ts.snap | 14 +++++--- .../tests/__snapshots__/zts.spec.ts.snap | 2 +- .../tests/documentation-helpers.spec.ts | 2 +- express-zod-api/tests/env.spec.ts | 35 ++++++++++--------- express-zod-api/tests/zts.spec.ts | 4 --- package.json | 2 +- yarn.lock | 8 ++--- 10 files changed, 40 insertions(+), 49 deletions(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 307cfd2e8..1051db9fc 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "3.25.0-beta.20250518T002810" + "zod": "3.25.0-beta.20250519T051133" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index d70c00569..e77f37007 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,9 +1,4 @@ -import type { - $ZodObject, - $ZodTransform, - $ZodType, - $ZodTypeInternals, -} from "zod/v4/core"; +import type { $ZodObject, $ZodTransform, $ZodType } from "zod/v4/core"; import { Request } from "express"; import * as R from "ramda"; import { globalRegistry, z } from "zod/v4"; @@ -175,17 +170,10 @@ export const getTransformedType = R.tryCatch( R.always(undefined), ); -const requestOptionality: Array<$ZodTypeInternals["optionality"]> = [ - "optional", - "defaulted", -]; export const isOptional = ( - { _zod: { optionality } }: $ZodType, + { _zod: { optin, optout } }: $ZodType, { isResponse }: { isResponse: boolean }, -) => - isResponse - ? optionality === "optional" - : optionality && requestOptionality.includes(optionality); +) => (isResponse ? optout : optin) === "optional"; /** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */ export const isObject = (subject: unknown) => diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 43f6ca956..a3f831178 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -93,7 +93,7 @@ const brandSetter = function ( const { [metaSymbol]: internal = { examples: [] }, ...rest } = this.meta() || {}; return this.meta({ - ...rest, + ...rest, // @todo this may no longer be required since it seems that .meta() merges now, not just overrides [metaSymbol]: { ...internal, brand }, }); }; diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 386fe6914..31dc5dd09 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -249,19 +249,23 @@ exports[`Environment checks > Zod imperfections > circular object schema has no } `; -exports[`Environment checks > Zod imperfections > meta overrides, does not merge 1`] = ` +exports[`Environment checks > Zod new features > input examples of transformations 1`] = ` { - "title": "last", + "$schema": "https://json-schema.org/draft-2020-12/schema", + "examples": [ + "test", + ], + "type": "string", } `; -exports[`Environment checks > Zod new features > input examples of transformations 1`] = ` +exports[`Environment checks > Zod new features > meta() merge, not just overrides 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "description": "some", "examples": [ "test", ], - "type": "string", + "title": "last", } `; diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index ccf5869f3..076d7c567 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -274,7 +274,7 @@ exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to opti "{ optional?: string | undefined; required: string; - transform: number; + transform?: number | undefined; or: number | string; tuple?: [ string, diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 89b679eb3..285dfaa00 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -177,7 +177,7 @@ describe("Documentation helpers", () => { describe("depictUnion()", () => { test("should set discriminator prop for such union", () => { - const zodSchema = z.discriminatedUnion([ + const zodSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.any() }), z.object({ status: z.literal("error"), diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 5d38ef561..6162e26b1 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -34,7 +34,7 @@ describe("Environment checks", () => { test("discriminated unions are not depicted well", () => { expect( z.toJSONSchema( - z.discriminatedUnion([ + z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.any() }), z.object({ status: z.literal("error"), @@ -50,15 +50,6 @@ describe("Environment checks", () => { expect(R.omit(["$schema"], json)).toEqual({}); }); - test("meta overrides, does not merge", () => { - const schema = z - .string() - .meta({ examples: ["test"] }) - .meta({ description: "some" }) - .meta({ title: "last" }); - expect(schema.meta()).toMatchSnapshot(); - }); - test("circular object schema has no sign of getter in its shape", () => { const schema = z.object({ name: z.string(), @@ -88,6 +79,15 @@ describe("Environment checks", () => { }); describe("Zod new features", () => { + test("meta() merge, not just overrides", () => { + const schema = z + .string() + .meta({ examples: ["test"] }) + .meta({ description: "some" }) + .meta({ title: "last" }); + expect(schema.meta()).toMatchSnapshot(); + }); + test("object shape conveys the keys optionality", () => { const schema = z.object({ one: z.boolean(), @@ -104,16 +104,19 @@ describe("Environment checks", () => { "three", "four", ]); - expect(schema._zod.def.shape.one._zod.optionality).toBeUndefined(); - expect(schema._zod.def.shape.two._zod.optionality).toBe("optional"); - expect(schema._zod.def.shape.three._zod.optionality).toBe("defaulted"); - /** @link https://github.com/colinhacks/zod/issues/4322 */ - expect(schema._zod.def.shape.four._zod.optionality).not.toBe("optional"); // <— undefined + expect(schema._zod.def.shape.one._zod.optin).toBeUndefined(); + expect(schema._zod.def.shape.one._zod.optout).toBeUndefined(); + expect(schema._zod.def.shape.two._zod.optin).toBe("optional"); + expect(schema._zod.def.shape.two._zod.optout).toBe("optional"); + expect(schema._zod.def.shape.three._zod.optin).toBe("optional"); + expect(schema._zod.def.shape.three._zod.optout).toBe(undefined); + expect(schema._zod.def.shape.four._zod.optin).toBe("optional"); + expect(schema._zod.def.shape.four._zod.optout).toBe(undefined); expectTypeOf>().toEqualTypeOf<{ one: boolean; two?: boolean | undefined; three?: boolean | undefined; - four: boolean | undefined; + four?: boolean | undefined; }>(); expectTypeOf>().toEqualTypeOf<{ one: boolean; diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 5a12c5e94..ffddf3387 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -207,10 +207,6 @@ describe("zod-to-ts", () => { expect(printNodeTest(node)).toMatchSnapshot(); }); - /** - * @todo revisit when optional+transform fixed - * @link https://github.com/colinhacks/zod/issues/4322 - * */ test("Zod 4: should add question mark only to optional props", () => { const node = zodToTs(objectWithOptionals, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); diff --git a/package.json b/package.json index c8481106f..ae97504c1 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", "vitest": "^3.1.3", - "zod": "3.25.0-beta.20250518T002810" + "zod": "3.25.0-beta.20250519T051133" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 4d0c0076f..e08d9b51a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,7 +3123,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@3.25.0-beta.20250518T002810: - version "3.25.0-beta.20250518T002810" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250518T002810.tgz#9ac105aea4b6e0d072a382893c3e6556670048c8" - integrity sha512-3/aIqMbUXG9EjTelJkDcWd+izJP5MxFgQEMSYI8n41pwYhRDYYxy2dnbkgfNcnLbFZ9uByZn9XXqHTh05QHqSQ== +zod@3.25.0-beta.20250519T051133: + version "3.25.0-beta.20250519T051133" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250519T051133.tgz#e2bf36f81255419f761f591ca983b792beb5ac0a" + integrity sha512-kiLlHJfDDF2JJ2RcCvxhysRDnrSEHtOuHMLtG+PESvLEScEujB8ccvZ6KgvBxS1ifwpjlL/hkjqwvDzKKoryjQ== From 95b19d660351718d67616711925d2fca46e20a59 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 19 May 2025 09:36:26 +0200 Subject: [PATCH 110/187] rev: using looseObject again, after 4320 fixed. --- express-zod-api/tests/endpoint.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index bb33e2602..068cc1cc0 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -526,12 +526,8 @@ describe("Endpoint", () => { path: ["dynamicValue"], }, ), - /** - * @todo revert to looseObject when fixed - * @link https://github.com/colinhacks/zod/issues/4320 - */ output: z - .record(z.string(), z.unknown()) + .looseObject({}) .refine((obj) => !("emitOutputValidationFailure" in obj), { message: "failure on demand", }), From 6c7aa0d07b7966bc680a4387d3cc81180edbc58b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 19 May 2025 11:22:43 +0200 Subject: [PATCH 111/187] beta update. --- express-zod-api/package.json | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 1051db9fc..015169df2 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "3.25.0-beta.20250519T051133" + "zod": "3.25.0-beta.20250519T084128" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/package.json b/package.json index 0d9667fdb..8775345e7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", "vitest": "^3.1.3", - "zod": "3.25.0-beta.20250519T051133" + "zod": "3.25.0-beta.20250519T084128" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index dcd261fb6..491f08a29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,7 +3123,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@3.25.0-beta.20250519T051133: - version "3.25.0-beta.20250519T051133" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250519T051133.tgz#e2bf36f81255419f761f591ca983b792beb5ac0a" - integrity sha512-kiLlHJfDDF2JJ2RcCvxhysRDnrSEHtOuHMLtG+PESvLEScEujB8ccvZ6KgvBxS1ifwpjlL/hkjqwvDzKKoryjQ== +zod@3.25.0-beta.20250519T084128: + version "3.25.0-beta.20250519T084128" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250519T084128.tgz#15f28855550f3eca6df7a55aa48d6baad8f9b815" + integrity sha512-0FeFg/uxmQgji0d7hrZy7DX1T6yfLabGIihFmms38vbAuP8AVS3EnBHE7W7mncgo9eQzaj+RaCB5+sFrMae3Bw== From ba97100553a09921215ca9b5e30b786b8e7a9fcc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 19 May 2025 14:47:55 +0200 Subject: [PATCH 112/187] beta update, --- express-zod-api/package.json | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 015169df2..30a59cf1b 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "3.25.0-beta.20250519T084128" + "zod": "3.25.0-beta.20250519T094321" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/package.json b/package.json index 8775345e7..8300112ab 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", "vitest": "^3.1.3", - "zod": "3.25.0-beta.20250519T084128" + "zod": "3.25.0-beta.20250519T094321" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 491f08a29..4d24bf2e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,7 +3123,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@3.25.0-beta.20250519T084128: - version "3.25.0-beta.20250519T084128" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250519T084128.tgz#15f28855550f3eca6df7a55aa48d6baad8f9b815" - integrity sha512-0FeFg/uxmQgji0d7hrZy7DX1T6yfLabGIihFmms38vbAuP8AVS3EnBHE7W7mncgo9eQzaj+RaCB5+sFrMae3Bw== +zod@3.25.0-beta.20250519T094321: + version "3.25.0-beta.20250519T094321" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250519T094321.tgz#9e3bb9b86902227adc65f69c0d271a8cba186447" + integrity sha512-FvDMTcBUhM/CZjeT0HJQ8M6KbSGRPHqEx2yLWx9kDU3ufoTiq7tQAI8UyBJ/82CBp1mv6tKVWp00ll6zV/WxmA== From c86572fd9d3bd8b3c097f6a49d3553eecdd5cf03 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 19 May 2025 15:26:49 +0200 Subject: [PATCH 113/187] Using stable 3.25. --- CHANGELOG.md | 2 +- express-zod-api/package.json | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2484bb0e8..ec472e757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### v24.0.0 - Switched to Zod 4: - - Minimum supported version of `zod` is 3.25.0, BUT imports MUST be from `zod/v4`; + - Minimum supported version of `zod` is 3.25.1, BUT imports MUST be from `zod/v4`; - Explanation of the versioning strategy: https://github.com/colinhacks/zod/issues/4371; - Express Zod API, however, is not aiming to support both Zod 3 and Zod 4 simultaneously due to: - incompatibility of data structures; diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 30a59cf1b..62aaa6e1d 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -76,7 +76,7 @@ "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", - "zod": "3.25.0-beta.20250519T094321" + "zod": "^3.25.1" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/package.json b/package.json index 8300112ab..747af5964 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", "vitest": "^3.1.3", - "zod": "3.25.0-beta.20250519T094321" + "zod": "^3.25.1" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 4d24bf2e5..6a583a3b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,7 +3123,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@3.25.0-beta.20250519T094321: - version "3.25.0-beta.20250519T094321" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.0-beta.20250519T094321.tgz#9e3bb9b86902227adc65f69c0d271a8cba186447" - integrity sha512-FvDMTcBUhM/CZjeT0HJQ8M6KbSGRPHqEx2yLWx9kDU3ufoTiq7tQAI8UyBJ/82CBp1mv6tKVWp00ll6zV/WxmA== +zod@^3.25.1: + version "3.25.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.1.tgz#c8938a5788b725b50feb4a87fc5b68f9ddb817d9" + integrity sha512-bkxUGQiqWDTXHSgqtevYDri5ee2GPC9szPct4pqpzLEpswgDQmuseDz81ZF0AnNu1xsmnBVmbtv/t/WeUIHlpg== From a7c37f445233370eec6caf98eb6c4056443f5880 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 19 May 2025 20:15:24 +0200 Subject: [PATCH 114/187] Branding: no altering refinements (#2637) For that, brand should not be a part of metadata, but somewhere else. Presumably, in `def` because it withstands `.clone()`. Or in `_zod.bag` as suggested by Colin. --- CHANGELOG.md | 2 - express-zod-api/src/deep-checks.ts | 8 +-- express-zod-api/src/documentation-helpers.ts | 5 +- express-zod-api/src/endpoint.ts | 6 +- express-zod-api/src/metadata.ts | 13 +++- express-zod-api/src/schema-walker.ts | 5 +- express-zod-api/src/zod-plugin.ts | 61 ++++++++++--------- express-zod-api/tests/date-in-schema.spec.ts | 4 +- express-zod-api/tests/date-out-schema.spec.ts | 4 +- express-zod-api/tests/deep-checks.spec.ts | 6 +- express-zod-api/tests/file-schema.spec.ts | 4 +- express-zod-api/tests/form-schema.spec.ts | 4 +- express-zod-api/tests/integration.spec.ts | 4 +- express-zod-api/tests/raw-schema.spec.ts | 4 +- express-zod-api/tests/upload-schema.spec.ts | 4 +- express-zod-api/tests/zod-plugin.spec.ts | 17 ++---- express-zod-api/vitest.setup.ts | 11 ++-- 17 files changed, 81 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec472e757..054cb7c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,6 @@ - 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. -- Changes to the plugin: - - Brand is the only kind of metadata that withstands refinements and checks. - Consider the automated migration using the built-in ESLint rule. ```js diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index a1fec9e39..16e5a0bb8 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -1,12 +1,12 @@ import type { $ZodType, JSONSchema } from "zod/v4/core"; import * as R from "ramda"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { DeepCheckError } from "./errors"; import { ezFormBrand } from "./form-schema"; import { IOSchema } from "./io-schema"; -import { metaSymbol } from "./metadata"; +import { getBrand } from "./metadata"; import { FirstPartyKind } from "./schema-walker"; import { ezUploadBrand } from "./upload-schema"; import { ezRawBrand } from "./raw-schema"; @@ -61,7 +61,7 @@ export const hasCycle = ( export const findRequestTypeDefiningSchema = (subject: IOSchema) => findNestedSchema(subject, { condition: (schema) => { - const { brand } = globalRegistry.get(schema)?.[metaSymbol] || {}; + const brand = getBrand(schema); return ( typeof brand === "symbol" && [ezUploadBrand, ezRawBrand, ezFormBrand].includes(brand) @@ -88,7 +88,7 @@ export const findJsonIncompatible = ( findNestedSchema(subject, { io, condition: (zodSchema) => { - const { brand } = globalRegistry.get(zodSchema)?.[metaSymbol] || {}; + const brand = getBrand(zodSchema); const { type } = zodSchema._zod.def; if (unsupported.includes(type)) return true; if (io === "input") { diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 1848a5b11..5bc4ad796 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -47,7 +47,7 @@ import { ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; -import { metaSymbol } from "./metadata"; +import { getBrand, metaSymbol } from "./metadata"; import { Method } from "./method"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand } from "./raw-schema"; @@ -463,8 +463,7 @@ const depict = ( unrepresentable: "any", io: ctx.isResponse ? "output" : "input", override: (zodCtx) => { - const { brand } = - globalRegistry.get(zodCtx.zodSchema)?.[metaSymbol] ?? {}; + const brand = getBrand(zodCtx.zodSchema); const depicter = rules[ brand && brand in rules ? brand : zodCtx.zodSchema._zod.def.type diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index 7d3692fa7..00872027a 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import * as R from "ramda"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import { NormalizedResponse, ResponseVariant } from "./api-response"; import { findRequestTypeDefiningSchema } from "./deep-checks"; import { @@ -20,7 +20,7 @@ import { IOSchema } from "./io-schema"; import { lastResortHandler } from "./last-resort"; import { ActualLogger } from "./logger-helpers"; import { LogicalContainer } from "./logical-container"; -import { metaSymbol } from "./metadata"; +import { getBrand } from "./metadata"; import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; @@ -144,7 +144,7 @@ export class Endpoint< public override get requestType() { const found = findRequestTypeDefiningSchema(this.#def.inputSchema); if (found) { - const { brand } = globalRegistry.get(found)?.[metaSymbol] || {}; + const brand = getBrand(found); if (brand === ezUploadBrand) return "upload"; if (brand === ezRawBrand) return "raw"; if (brand === ezFormBrand) return "form"; diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 4df62780f..5eae6659a 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -1,3 +1,4 @@ +import type { $ZodType } from "zod/v4/core"; import { combinations } from "./common-helpers"; import { z } from "zod/v4"; import * as R from "ramda"; @@ -8,7 +9,6 @@ export interface Metadata { examples: unknown[]; /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ defaultLabel?: string; - brand?: string | number | symbol; } export const mixExamples = ( @@ -34,3 +34,14 @@ export const mixExamples = ( [metaSymbol]: { ...destMeta?.[metaSymbol], examples }, }); }; + +export const getBrand = (subject: $ZodType) => { + const { brand } = subject._zod.bag; + if ( + typeof brand === "symbol" || + typeof brand === "string" || + typeof brand === "number" + ) + return brand; + return undefined; +}; diff --git a/express-zod-api/src/schema-walker.ts b/express-zod-api/src/schema-walker.ts index 1c726fb27..53b147fe1 100644 --- a/express-zod-api/src/schema-walker.ts +++ b/express-zod-api/src/schema-walker.ts @@ -1,7 +1,6 @@ import type { $ZodType, $ZodTypeDef } from "zod/v4/core"; -import { globalRegistry } from "zod/v4"; import type { EmptyObject, FlatObject } from "./common-helpers"; -import { metaSymbol } from "./metadata"; +import { getBrand } from "./metadata"; export type FirstPartyKind = $ZodTypeDef["type"]; @@ -51,7 +50,7 @@ export const walkSchema = < onMissing: SchemaHandler; }, ): U => { - const brand = globalRegistry.get(schema)?.[metaSymbol]?.brand; + const brand = getBrand(schema); const handler = brand && brand in rules ? rules[brand as keyof typeof rules] diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index a3f831178..2baf1308e 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -6,10 +6,9 @@ * @desc Enables .label() on ZodDefault * @desc Enables .remap() on ZodObject * @desc Stores the argument supplied to .brand() on all schema (runtime distinguishable branded types) - * @desc Ensures that the brand withstands additional refinements or checks * */ import * as R from "ramda"; -import { z, globalRegistry } from "zod/v4"; +import { z } from "zod/v4"; import { FlatObject } from "./common-helpers"; import { Metadata, metaSymbol } from "./metadata"; import { Intact, Remap } from "./mapping-helpers"; @@ -18,6 +17,9 @@ import type { $ZodShape, $ZodLooseShape, $ZodObjectConfig, + $ZodCheck, + $ZodCheckInternals, + $ZodCheckDef, } from "zod/v4/core"; declare module "zod/v4/core" { @@ -60,6 +62,32 @@ declare module "zod/v4" { } } +interface $EZBrandCheckDef extends $ZodCheckDef { + check: "$EZBrandCheck"; + brand?: string | number | symbol; +} + +interface $EZBrandCheckInternals extends $ZodCheckInternals { + def: $EZBrandCheckDef; +} + +interface $EZBrandCheck extends $ZodCheck { + _zod: $EZBrandCheckInternals; +} + +/** + * This approach was suggested to me by Colin in a PM on Twitter. + * Refrained from storing the brand in Metadata because it should withstand refinements. + * */ +const $EZBrandCheck = z.core.$constructor<$EZBrandCheck>( + "$EZBrandCheck", + (inst, def) => { + z.core.$ZodCheck.init(inst, def); + inst._zod.onattach.push((schema) => (schema._zod.bag.brand = def.brand)); + inst._zod.check = () => {}; + }, +); + const exampleSetter = function (this: z.ZodType, value: z.input) { const { [metaSymbol]: internal, ...rest } = this.meta() || {}; const copy = internal?.examples.slice() || []; @@ -81,7 +109,7 @@ const labelSetter = function (this: z.ZodDefault, defaultLabel: string) { const { [metaSymbol]: internal = { examples: [] }, ...rest } = this.meta() || {}; return this.meta({ - ...rest, + ...rest, // @todo this may no longer be required since it seems that .meta() merges now, not just overrides [metaSymbol]: { ...internal, defaultLabel }, }); }; @@ -90,12 +118,7 @@ const brandSetter = function ( this: z.ZodType, brand?: string | number | symbol, ) { - const { [metaSymbol]: internal = { examples: [] }, ...rest } = - this.meta() || {}; - return this.meta({ - ...rest, // @todo this may no longer be required since it seems that .meta() merges now, not just overrides - [metaSymbol]: { ...internal, brand }, - }); + return this.check(new $EZBrandCheck({ brand, check: "$EZBrandCheck" })); }; const objectMapper = function ( @@ -127,7 +150,6 @@ if (!(metaSymbol in globalThis)) { if (/(Success|Error|Function)$/.test(entry)) continue; const Cls = z[entry as keyof typeof z]; if (typeof Cls !== "function") continue; - let originalCheck: z.ZodType["check"]; Object.defineProperties(Cls.prototype, { ["example" satisfies keyof z.ZodType]: { get(): z.ZodType["example"] { @@ -145,25 +167,6 @@ if (!(metaSymbol in globalThis)) { return brandSetter.bind(this) as z.ZodType["brand"]; }, }, - ["check" satisfies keyof z.ZodType]: { - set(fn) { - originalCheck = fn; - }, - get(): z.ZodType["check"] { - return function ( - this: z.ZodType, - ...args: Parameters - ) { - /** @link https://v4.zod.dev/metadata#register */ - return originalCheck.apply(this, args).register(globalRegistry, { - [metaSymbol]: { - examples: [], - brand: this.meta()?.[metaSymbol]?.brand, - }, - }); - }; - }, - }, }); } diff --git a/express-zod-api/tests/date-in-schema.spec.ts b/express-zod-api/tests/date-in-schema.spec.ts index 432dc8b08..5216e40b3 100644 --- a/express-zod-api/tests/date-in-schema.spec.ts +++ b/express-zod-api/tests/date-in-schema.spec.ts @@ -1,14 +1,14 @@ import { z } from "zod/v4"; import { ezDateInBrand } from "../src/date-in-schema"; import { ez } from "../src"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; describe("ez.dateIn()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateIn(); expect(schema).toBeInstanceOf(z.ZodPipe); - expect(schema.meta()?.[metaSymbol]?.brand).toEqual(ezDateInBrand); + expect(getBrand(schema)).toBe(ezDateInBrand); }); }); diff --git a/express-zod-api/tests/date-out-schema.spec.ts b/express-zod-api/tests/date-out-schema.spec.ts index 0ad0b0eb7..f51db61cd 100644 --- a/express-zod-api/tests/date-out-schema.spec.ts +++ b/express-zod-api/tests/date-out-schema.spec.ts @@ -1,14 +1,14 @@ import { z } from "zod/v4"; import { ezDateOutBrand } from "../src/date-out-schema"; import { ez } from "../src"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; describe("ez.dateOut()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateOut(); expect(schema).toBeInstanceOf(z.ZodPipe); - expect(schema.meta()?.[metaSymbol]?.brand).toEqual(ezDateOutBrand); + expect(getBrand(schema)).toBe(ezDateOutBrand); }); }); diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index 5819791ce..5f6d00824 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -1,15 +1,15 @@ import { UploadedFile } from "express-fileupload"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import type { $brand, $ZodType } from "zod/v4/core"; import { ez } from "../src"; import { findNestedSchema, hasCycle } from "../src/deep-checks"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; describe("Checks", () => { describe("findNestedSchema()", () => { const condition = (subject: $ZodType) => - globalRegistry.get(subject)?.[metaSymbol]?.brand === ezUploadBrand; + getBrand(subject) === ezUploadBrand; test("should return true for given argument satisfying condition", () => { expect( diff --git a/express-zod-api/tests/file-schema.spec.ts b/express-zod-api/tests/file-schema.spec.ts index c9c30b708..d5ee30ba9 100644 --- a/express-zod-api/tests/file-schema.spec.ts +++ b/express-zod-api/tests/file-schema.spec.ts @@ -2,14 +2,14 @@ import { z } from "zod/v4"; import { ezFileBrand } from "../src/file-schema"; import { ez } from "../src"; import { readFile } from "node:fs/promises"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; describe("ez.file()", () => { describe("creation", () => { test("should create an instance being string by default", () => { const schema = ez.file(); expect(schema).toBeInstanceOf(z.ZodString); - expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezFileBrand); + expect(getBrand(schema)).toBe(ezFileBrand); }); test("should create a string file", () => { diff --git a/express-zod-api/tests/form-schema.spec.ts b/express-zod-api/tests/form-schema.spec.ts index 44e09d2d1..4047db778 100644 --- a/express-zod-api/tests/form-schema.spec.ts +++ b/express-zod-api/tests/form-schema.spec.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { ez } from "../src"; import { ezFormBrand } from "../src/form-schema"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; describe("ez.form()", () => { describe("creation", () => { @@ -10,7 +10,7 @@ describe("ez.form()", () => { (base) => { const schema = ez.form(base); expect(schema).toBeInstanceOf(z.ZodObject); - expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezFormBrand); + expect(getBrand(schema)).toBe(ezFormBrand); expect(schema._zod.def.shape).toHaveProperty( "name", expect.any(z.ZodString), diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 06349e4e3..dcdc061e7 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -1,5 +1,5 @@ import ts from "typescript"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import { EndpointsFactory, Integration, @@ -108,7 +108,7 @@ describe("Integration", () => { schema: ReturnType, { next }, ) => { - globalRegistry.remove(schema); + delete schema._zod.bag.brand; return next(schema); }; const client = new Integration({ diff --git a/express-zod-api/tests/raw-schema.spec.ts b/express-zod-api/tests/raw-schema.spec.ts index a0b17cffc..1ab590e51 100644 --- a/express-zod-api/tests/raw-schema.spec.ts +++ b/express-zod-api/tests/raw-schema.spec.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; import { ez } from "../src"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; import { ezRawBrand } from "../src/raw-schema"; describe("ez.raw()", () => { @@ -8,7 +8,7 @@ describe("ez.raw()", () => { test("should be an instance of branded object", () => { const schema = ez.raw(); expect(schema).toBeInstanceOf(z.ZodObject); - expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezRawBrand); + expect(getBrand(schema)).toBe(ezRawBrand); }); }); diff --git a/express-zod-api/tests/upload-schema.spec.ts b/express-zod-api/tests/upload-schema.spec.ts index 6177bcfc9..5ff1d6002 100644 --- a/express-zod-api/tests/upload-schema.spec.ts +++ b/express-zod-api/tests/upload-schema.spec.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; import { ez } from "../src"; -import { metaSymbol } from "../src/metadata"; +import { getBrand } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; describe("ez.upload()", () => { @@ -8,7 +8,7 @@ describe("ez.upload()", () => { test("should create an instance", () => { const schema = ez.upload(); expect(schema).toBeInstanceOf(z.ZodCustom); - expect(schema.meta()?.[metaSymbol]?.brand).toBe(ezUploadBrand); + expect(getBrand(schema)).toBe(ezUploadBrand); }); }); diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index fff0e3f07..859245771 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 { metaSymbol } from "../src/metadata"; +import { getBrand, metaSymbol } from "../src/metadata"; describe("Zod Runtime Plugin", () => { describe(".example()", () => { @@ -74,26 +74,19 @@ describe("Zod Runtime Plugin", () => { describe(".brand()", () => { test("should set the brand", () => { - expect(z.string().brand("test").meta()?.[metaSymbol]?.brand).toEqual( - "test", - ); + expect(getBrand(z.string().brand("test"))).toBe("test"); }); test("should withstand refinements", () => { const schema = z.string(); const schemaWithMeta = schema.brand("test"); - expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty( - "brand", - "test", - ); - expect( - schemaWithMeta.regex(/@example.com$/).meta()?.[metaSymbol], - ).toHaveProperty("brand", "test"); + expect(getBrand(schemaWithMeta)).toBe("test"); + expect(getBrand(schemaWithMeta.regex(/@example.com$/))).toBe("test"); }); test("should withstand describing", () => { const schema = z.string().brand("test").describe("something"); - expect(schema.meta()?.[metaSymbol]?.brand).toBe("test"); + expect(getBrand(schema)).toBe("test"); }); }); diff --git a/express-zod-api/vitest.setup.ts b/express-zod-api/vitest.setup.ts index d52c1e218..552849ccf 100644 --- a/express-zod-api/vitest.setup.ts +++ b/express-zod-api/vitest.setup.ts @@ -1,8 +1,8 @@ import "./src/zod-plugin"; // required for tests importing sources using the plugin methods import type { NewPlugin } from "@vitest/pretty-format"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import { ResultHandlerError } from "./src/errors"; -import { metaSymbol } from "./src/metadata"; +import { getBrand } from "./src/metadata"; /** Takes cause and certain props of custom errors into account */ const errorSerializer: NewPlugin = { @@ -29,11 +29,8 @@ const schemaSerializer: NewPlugin = { const serialization = z.toJSONSchema(entity, { unrepresentable: "any", override: ({ zodSchema, jsonSchema }) => { - if (zodSchema._zod.def.type === "custom") { - jsonSchema["x-brand"] = globalRegistry - .get(zodSchema) - ?.[metaSymbol]?.brand?.toString(); - } + if (zodSchema._zod.def.type === "custom") + jsonSchema["x-brand"] = getBrand(zodSchema); }, }); return printer(serialization, config, indentation, depth, refs); From 38cc4c1279e8cbc01ca29363f09dfa0544b15bec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 May 2025 06:56:30 +0000 Subject: [PATCH 115/187] v24.0.0-beta.1 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 62aaa6e1d..73c7bb922 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.0", + "version": "24.0.0-beta.1", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From be7dc372591066a9540efffc7d2bd9814363fb03 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 20 May 2025 10:02:50 +0200 Subject: [PATCH 116/187] Using native storage for default label (#2643) While working on #2632 I realized that native storage is actually the right place for it --- express-zod-api/src/documentation-helpers.ts | 15 ++------------- express-zod-api/src/metadata.ts | 2 -- express-zod-api/src/zod-plugin.ts | 9 ++++----- .../documentation-helpers.spec.ts.snap | 7 ------- .../__snapshots__/documentation.spec.ts.snap | 9 +++++++++ .../tests/documentation-helpers.spec.ts | 17 ----------------- express-zod-api/tests/documentation.spec.ts | 4 ++++ express-zod-api/tests/zod-plugin.spec.ts | 5 +---- 8 files changed, 20 insertions(+), 48 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 5bc4ad796..927bac2cf 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 { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { FlatObject, @@ -47,7 +47,7 @@ import { ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; -import { getBrand, metaSymbol } from "./metadata"; +import { getBrand } from "./metadata"; import { Method } from "./method"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand } from "./raw-schema"; @@ -101,16 +101,6 @@ const samples = { export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const depictDefault: Depicter = ({ zodSchema, jsonSchema }) => { - const value = - globalRegistry.get(zodSchema)?.[metaSymbol]?.defaultLabel ?? - jsonSchema.default; - return { - ...jsonSchema, - default: typeof value === "bigint" ? String(value) : value, - }; -}; - export const depictUpload: Depicter = ({}, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.upload() only for input.", ctx); @@ -398,7 +388,6 @@ export const depictRequestParams = ({ const depicters: Partial> = { nullable: depictNullable, - default: depictDefault, union: depictUnion, bigint: depictBigInt, intersection: depictIntersection, diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 5eae6659a..ac8e2aa4b 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -7,8 +7,6 @@ export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { examples: unknown[]; - /** @override ZodDefault::_zod.def.defaultValue() in depictDefault */ - defaultLabel?: string; } export const mixExamples = ( diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 2baf1308e..383cbfc49 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -26,6 +26,7 @@ declare module "zod/v4/core" { interface GlobalMeta { [metaSymbol]?: Metadata; deprecated?: boolean; + default?: unknown; // can be an actual value or a label like "Today" } } @@ -36,7 +37,7 @@ declare module "zod/v4" { deprecated(): this; } interface ZodDefault extends ZodType { - /** @desc Change the default value in the generated Documentation to a label */ + /** @desc Change the default value in the generated Documentation to a label, alias for .meta({ default }) */ label(label: string): this; } interface ZodObject< @@ -106,11 +107,9 @@ const deprecationSetter = function (this: z.ZodType) { }; const labelSetter = function (this: z.ZodDefault, defaultLabel: string) { - const { [metaSymbol]: internal = { examples: [] }, ...rest } = - this.meta() || {}; return this.meta({ - ...rest, // @todo this may no longer be required since it seems that .meta() merges now, not just overrides - [metaSymbol]: { ...internal, defaultLabel }, + ...this.meta(), // @todo this may no longer be required since it seems that .meta() merges now, not just overrides + default: defaultLabel, }); }; 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 367203042..2d4cc598f 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -45,13 +45,6 @@ DocumentationError({ }) `; -exports[`Documentation helpers > depictDefault() > Feature #1706: should override the default value by a label from metadata 1`] = ` -{ - "default": "Today", - "format": "date-time", -} -`; - exports[`Documentation helpers > depictEnum() > should set type 1`] = ` { "enum": [ diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 0e723377e..4e727a590 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -980,6 +980,15 @@ paths: default: 123 exclusiveMinimum: 0 maximum: 9007199254740991 + - name: labeledDate + in: query + required: false + description: GET /v1/getSomething Parameter + schema: + type: string + default: Today + format: date-time + pattern: ^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?(Z)$ responses: "200": description: GET /v1/getSomething Positive response diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 285dfaa00..ff5301ea6 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -14,7 +14,6 @@ import { defaultIsHeader, reformatParamsInPath, depictNullable, - depictDefault, depictRaw, depictUpload, depictFile, @@ -120,22 +119,6 @@ describe("Documentation helpers", () => { }); }); - describe("depictDefault()", () => { - test("Feature #1706: should override the default value by a label from metadata", () => { - const zodSchema = z.iso - .datetime() - .default(() => new Date().toISOString()) - .label("Today"); - const jsonSchema: JSONSchema.BaseSchema = { - default: "2025-05-21", - format: "date-time", - }; - expect( - depictDefault({ zodSchema, jsonSchema }, responseCtx), - ).toMatchSnapshot(); - }); - }); - describe("depictRaw()", () => { test("should extract the raw property", () => { const jsonSchema: JSONSchema.BaseSchema = { diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 2241cb666..f4dcdb575 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -87,6 +87,10 @@ describe("Documentation", () => { optDefault: z.string().optional().default("test"), nullish: z.boolean().nullish(), nuDefault: z.int().positive().nullish().default(123), + labeledDate: z.iso + .datetime() + .default(() => new Date().toISOString()) + .label("Today"), // Feature #1706 }), output: z.object({ nullable: z.string().nullable(), diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index 859245771..47eb23995 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -65,10 +65,7 @@ describe("Zod Runtime Plugin", () => { const schema = z.iso.datetime().default(() => new Date().toISOString()); expect(schema).toHaveProperty("label"); const schemaWithMeta = schema.label("Today"); - expect(schemaWithMeta.meta()?.[metaSymbol]).toHaveProperty( - "defaultLabel", - "Today", - ); + expect(schemaWithMeta.meta()).toHaveProperty("default", "Today"); }); }); From e0e3b5a026d7ae0d980d654453e7e066e13d0cc4 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 20 May 2025 21:11:10 +0200 Subject: [PATCH 117/187] Using native storage for examples (#2632) New approach to #2562. I'm considering to remove the feature of transforming examples and devote to users its proper placement. The native metadata examples are typed as `z.output<>` of the schema, which is the opposite of what it used to be established by the framework. --- CHANGELOG.md | 17 ++- README.md | 6 +- example/endpoints/update-user.ts | 12 +- example/example.documentation.yaml | 14 +-- express-zod-api/src/common-helpers.ts | 46 +------- express-zod-api/src/documentation-helpers.ts | 46 +++----- express-zod-api/src/index.ts | 2 +- express-zod-api/src/json-schema-helpers.ts | 16 +-- express-zod-api/src/metadata.ts | 25 ++-- express-zod-api/src/result-handler.ts | 20 +++- express-zod-api/src/zod-plugin.ts | 18 ++- .../documentation-helpers.spec.ts.snap | 56 ++++++++- .../__snapshots__/documentation.spec.ts.snap | 30 ++--- .../tests/__snapshots__/endpoint.spec.ts.snap | 8 ++ .../tests/__snapshots__/index.spec.ts.snap | 3 - express-zod-api/tests/common-helpers.spec.ts | 109 ------------------ .../tests/documentation-helpers.spec.ts | 28 ++++- 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 +- express-zod-api/tests/metadata.spec.ts | 21 ++-- express-zod-api/tests/result-handler.spec.ts | 5 +- express-zod-api/tests/zod-plugin.spec.ts | 19 +-- 23 files changed, 225 insertions(+), 314 deletions(-) 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", From ecb5b3f81240a3587f3844ef6893836570718937 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 20 May 2025 22:18:21 +0200 Subject: [PATCH 118/187] Ref: no reassigning meta (#2644) The stable Zod 4 merges metadata, not replaces it. So it's now safe to set only one entry without spreading the prev state. --- express-zod-api/src/metadata.ts | 14 ++++++++------ express-zod-api/src/zod-plugin.ts | 14 ++++---------- express-zod-api/tests/env.spec.ts | 5 +---- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 39b6bf95f..c0460f67c 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -9,13 +9,15 @@ export const mixExamples = ( src: A, dest: B, ): B => { - const srcExamples = - src.meta()?.examples || - (isSchema<$ZodObject>(src, "object") ? pullExampleProps(src) : undefined); + const { + examples: srcExamples = isSchema<$ZodObject>(src, "object") + ? pullExampleProps(src) + : undefined, + } = src.meta() || {}; if (!srcExamples?.length) return dest; - const destMeta = dest.meta(); + const { examples: destExamples = [] } = dest.meta() || {}; const examples = combinations & z.output>( - destMeta?.examples || [], + destExamples, srcExamples, ([destExample, srcExample]) => typeof destExample === "object" && @@ -25,7 +27,7 @@ export const mixExamples = ( ? R.mergeDeepRight(destExample, srcExample) : srcExample, // not supposed to be called on non-object schemas ); - return dest.meta({ ...destMeta, examples }); // @todo might not be required to spread since .meta() does it now + return dest.meta({ examples }); }; export const getBrand = (subject: $ZodType) => { diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 77ea3a459..44552860c 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -89,24 +89,18 @@ const $EZBrandCheck = z.core.$constructor<$EZBrandCheck>( ); const exampleSetter = function (this: z.ZodType, value: z.output) { - const { examples = [], ...rest } = this.meta() || {}; + const { examples = [] } = this.meta() || {}; const copy = examples.slice(); copy.push(value); - return this.meta({ ...rest, examples: copy }); + return this.meta({ examples: copy }); }; const deprecationSetter = function (this: z.ZodType) { - return this.meta({ - ...this.meta(), - deprecated: true, - }); + return this.meta({ deprecated: true }); }; const labelSetter = function (this: z.ZodDefault, defaultLabel: string) { - return this.meta({ - ...this.meta(), // @todo this may no longer be required since it seems that .meta() merges now, not just overrides - default: defaultLabel, - }); + return this.meta({ default: defaultLabel }); }; const brandSetter = function ( diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 596988433..d53285c39 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -128,10 +128,7 @@ describe("Environment checks", () => { expect(boolSchema.isNullable()).toBeTruthy(); }); - /** - * @link https://github.com/colinhacks/zod/issues/4274 - * @todo this fact can be used for switching to native examples - * */ + /** @link https://github.com/colinhacks/zod/issues/4274 */ test.each(["input", "output"] as const)( "%s examples of transformations", (io) => { From b1c7f08be617bbf4b0f467a99f569ab61dc41225 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 08:28:33 +0200 Subject: [PATCH 119/187] zod 3.25.13. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 460dbee84..ea9b1a9bd 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", "vitest": "^3.1.4", - "zod": "^3.25.7" + "zod": "^3.25.13" }, "resolutions": { "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" diff --git a/yarn.lock b/yarn.lock index 0df90bebe..8658fb007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,7 +3123,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.25.7: - version "3.25.7" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.7.tgz#74184ecfe03ef8c67a62393008117b3b8d9cc48c" - integrity sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg== +zod@^3.25.13: + version "3.25.13" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.13.tgz#53103b7eed402b2c7a0ad2f035acc34aab34526b" + integrity sha512-Q8mvk2iWi7rTDfpQBsu4ziE7A6AxgzJ5hzRyRYQkoV3A3niYsXVwDaP1Kbz3nWav6S+VZ6k2OznFn8ZyDHvIrg== From d9625e60f5db6b3df3d2c48ec3a4c5c0aa377785 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 06:32:30 +0000 Subject: [PATCH 120/187] v24.0.0-beta.2 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 73c7bb922..665a6d176 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.1", + "version": "24.0.0-beta.2", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From c95c8c89f43e0e06b7207445f2e3575b039f4842 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 21 May 2025 11:23:56 +0200 Subject: [PATCH 121/187] Cleanup: rm `depictObject` (#2645) Added in 9366d0ff0843efd14f1df31e4ccb62df9907b110 as part of #2595 for better treating optionals and required props. But it seems that stable Zod 4 does the good job without it. --- express-zod-api/src/common-helpers.ts | 1 + express-zod-api/src/documentation-helpers.ts | 20 ----------- .../documentation-helpers.spec.ts.snap | 35 ------------------- .../__snapshots__/documentation.spec.ts.snap | 3 ++ .../tests/documentation-helpers.spec.ts | 29 --------------- express-zod-api/tests/documentation.spec.ts | 1 + 6 files changed, 5 insertions(+), 84 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 9e58fb543..063999515 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -128,6 +128,7 @@ export const getTransformedType = R.tryCatch( R.always(undefined), ); +/** @todo check usage */ export const isOptional = ( { _zod: { optin, optout } }: $ZodType, { isResponse }: { isResponse: boolean }, diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 3b64a9afa..1c3232ced 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,5 +1,4 @@ import type { - $ZodObject, $ZodPipe, $ZodTransform, $ZodTuple, @@ -30,7 +29,6 @@ import { getRoutePathParams, getTransformedType, isObject, - isOptional, isSchema, makeCleanId, routePathParamsRegex, @@ -153,23 +151,6 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({ ...jsonSchema, }); -/** @todo might no longer be required */ -export const depictObject: Depicter = ( - { zodSchema, jsonSchema }, - { isResponse }, -) => { - if (isResponse) return jsonSchema; - if (!isSchema<$ZodObject>(zodSchema, "object")) return jsonSchema; - const { required = [] } = jsonSchema as JSONSchema.ObjectSchema; - const result: string[] = []; - for (const key of required) { - const valueSchema = zodSchema._zod.def.shape[key]; - if (valueSchema && !isOptional(valueSchema, { isResponse })) - result.push(key); - } - return { ...jsonSchema, required: result }; -}; - const ensureCompliance = ({ $ref, type, @@ -400,7 +381,6 @@ const depicters: Partial> = pipe: depictPipeline, literal: depictLiteral, enum: depictEnum, - object: depictObject, [ezDateInBrand]: depictDateIn, [ezDateOutBrand]: depictDateOut, [ezUploadBrand]: depictUpload, 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 530bb1329..12542a583 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -282,41 +282,6 @@ exports[`Documentation helpers > depictNullable() > should not add null type whe } `; -exports[`Documentation helpers > depictObject() > should remove optional props from required for request 0 1`] = ` -{ - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "string", - }, - }, - "required": [ - "a", - "b", - ], - "type": "object", -} -`; - -exports[`Documentation helpers > depictObject() > should remove optional props from required for request 1 1`] = ` -{ - "properties": { - "a": { - "type": "number", - }, - "b": { - "type": "string", - }, - }, - "required": [ - "b", - ], - "type": "object", -} -`; - exports[`Documentation helpers > depictPipeline > should depict as 'number (out)' 1`] = ` { "type": "number", diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 0b091ea35..fb4fd3ed8 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1496,6 +1496,8 @@ paths: type: integer minimum: 0 maximum: 0 + coercedNum: + type: number required: - double - doublePositive @@ -1505,6 +1507,7 @@ paths: - intPositive - intNegative - intLimited + - coercedNum required: true responses: "200": diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 29ef1255f..b739b4b37 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -28,7 +28,6 @@ import { depictEnum, depictLiteral, depictRequest, - depictObject, } from "../src/documentation-helpers"; describe("Documentation helpers", () => { @@ -332,34 +331,6 @@ describe("Documentation helpers", () => { ); }); - describe("depictObject()", () => { - test.each([ - { - zodSchema: z.object({ a: z.number(), b: z.string() }), - jsonSchema: { - type: "object", - properties: { a: { type: "number" }, b: { type: "string" } }, - required: ["a", "b"], - }, - }, - { - zodSchema: z.object({ a: z.number().optional(), b: z.coerce.string() }), - jsonSchema: { - type: "object", - properties: { a: { type: "number" }, b: { type: "string" } }, - required: ["b"], // Zod 4: coerce remains - }, - }, - ])( - "should remove optional props from required for request %#", - ({ zodSchema, jsonSchema }) => { - expect( - depictObject({ zodSchema, jsonSchema }, requestCtx), - ).toMatchSnapshot(); - }, - ); - }); - describe("depictBigInt()", () => { test("should set type:string and format:bigint", () => { expect( diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 16913228c..e281d5fb2 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -330,6 +330,7 @@ describe("Documentation", () => { intNegative: z.int().negative(), intLimited: z.int().min(-100).max(100), zero: z.int().nonnegative().nonpositive().optional(), + coercedNum: z.coerce.number(), // required prop in zod 4 }), output: z.object({ bigint: z.bigint(), From 99601a7380913b3e18f14da0402bc80c3cd221a5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 11:27:59 +0200 Subject: [PATCH 122/187] Ref: mv isOptional into zts::onObject. --- express-zod-api/src/common-helpers.ts | 6 ------ express-zod-api/src/zts.ts | 5 +++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 063999515..bf1555315 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -128,12 +128,6 @@ export const getTransformedType = R.tryCatch( R.always(undefined), ); -/** @todo check usage */ -export const isOptional = ( - { _zod: { optin, optout } }: $ZodType, - { isResponse }: { isResponse: boolean }, -) => (isResponse ? optout : optin) === "optional"; - /** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */ export const isObject = (subject: unknown) => typeof subject === "object" && subject !== null; diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index ba5827551..0fa1508cf 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -21,7 +21,7 @@ import type { import * as R from "ramda"; import ts from "typescript"; import { globalRegistry, z } from "zod/v4"; -import { getTransformedType, isOptional, isSchema } from "./common-helpers"; +import { getTransformedType, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { hasCycle } from "./deep-checks"; @@ -82,7 +82,8 @@ const onObject: Producer = ( return makeInterfaceProp(key, next(value), { comment, isDeprecated, - isOptional: isOptional(value, { isResponse }), + isOptional: + (isResponse ? value._zod.optout : value._zod.optin) === "optional", }); }, ); From f8aefc126bf91843b88b74b2d3a45bf4f11bcfc6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 11:56:25 +0200 Subject: [PATCH 123/187] Add test for top level examples of a flattened intersection. --- .../json-schema-helpers.spec.ts.snap | 24 +++++++++++++++++++ .../tests/json-schema-helpers.spec.ts | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap index 00528c2fc..04aa68abe 100644 --- a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap @@ -98,3 +98,27 @@ exports[`JSON Schema helpers > flattenIO() > should return object schema for the "type": "object", } `; + +exports[`JSON Schema helpers > flattenIO() > should use top level examples of the intersection 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": "jest", + }, + ], + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, + }, + "required": [ + "one", + "two", + ], + "type": "object", +} +`; diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index bca124739..dc3c554d9 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -53,6 +53,25 @@ describe("JSON Schema helpers", () => { expect(subject).toMatchSnapshot(); }); + test("should use top level examples of the intersection", () => { + const subject = flattenIO({ + examples: [{ one: "test", two: "jest" }], + allOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + }, + ], + }); + expect(subject).toMatchSnapshot(); + }); + test("should handle records", () => { const subject = z.toJSONSchema( z From 67cf26c6f09c2633789c36510a8d9f856ff57f0d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 12:25:09 +0200 Subject: [PATCH 124/187] Ref: combining tests (using each). --- .../__snapshots__/documentation.spec.ts.snap | 4 +- express-zod-api/tests/documentation.spec.ts | 86 +++++++------------ 2 files changed, 33 insertions(+), 57 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index fb4fd3ed8..9f3ce490a 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3558,7 +3558,7 @@ servers: " `; -exports[`Documentation > Metadata > should pass over examples of each param from the whole IO schema examples (GET) 1`] = ` +exports[`Documentation > Metadata > should pass over examples of each param from the whole IO schema examples (get method) 1`] = ` "openapi: 3.1.0 info: title: Testing Metadata:example on IO schema @@ -3648,7 +3648,7 @@ servers: " `; -exports[`Documentation > Metadata > should pass over examples of the whole IO schema (POST) 1`] = ` +exports[`Documentation > Metadata > should pass over examples of each param from the whole IO schema examples (post method) 1`] = ` "openapi: 3.1.0 info: title: Testing Metadata:example on IO schema diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index e281d5fb2..3639ed67c 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1,4 +1,5 @@ import camelize from "camelize-ts"; +import { Method } from "example/example.client"; import snakify from "snakify-ts"; import { Documentation, @@ -1053,62 +1054,37 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); - test("should pass over examples of each param from the whole IO schema examples (GET)", () => { - const spec = new Documentation({ - config: sampleConfig, - routing: { - v1: { - getSomething: defaultEndpointsFactory.build({ - input: z - .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 - }), - handler: async () => ({ numericStr: 123 }), - }), - }, - }, - version: "3.4.5", - title: "Testing Metadata:example on IO schema", - serverUrl: "https://example.com", - }).getSpecAsYaml(); - expect(spec).toMatchSnapshot(); - }); - - test("should pass over examples of the whole IO schema (POST)", () => { - const spec = new Documentation({ - config: sampleConfig, - routing: { - v1: { - getSomething: defaultEndpointsFactory.build({ - method: "post", - input: z - .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 output side of the transformation - }), - handler: async () => ({ numericStr: 123 }), - }), + test.each(["get", "post"])( + "should pass over examples of each param from the whole IO schema examples (%s method)", + (method) => { + const spec = new Documentation({ + config: sampleConfig, + routing: { + v1: { + getSomething: defaultEndpointsFactory.build({ + method, + input: z + .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 output side of the transformation + }), + handler: async () => ({ numericStr: 123 }), + }), + }, }, - }, - version: "3.4.5", - title: "Testing Metadata:example on IO schema", - serverUrl: "https://example.com", - }).getSpecAsYaml(); - expect(spec).toMatchSnapshot(); - }); + version: "3.4.5", + title: "Testing Metadata:example on IO schema", + serverUrl: "https://example.com", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }, + ); test("should merge endpoint handler examples with its middleware examples", () => { const spec = new Documentation({ From f1db18152ae933a1e20452b691a7bc71cb64f930 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 18:41:33 +0200 Subject: [PATCH 125/187] rm todo that is ok. --- 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 1c3232ced..35f7fe19d 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -359,7 +359,7 @@ export const depictRequestParams = ({ schema: result, examples: enumerateExamples( isSchemaObject(depicted) && depicted.examples?.length - ? depicted.examples // own examples or from the flat: // @todo check if both still needed + ? depicted.examples // own examples or from the flat: : R.pluck( name, flat.examples?.filter(R.both(isObject, R.has(name))) || [], From c1e111c612773703551d4ede65944d8596e62368 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 19:08:23 +0200 Subject: [PATCH 126/187] rm taking examples from flattened request in depictBody() because mixExamples() called by getFinalInputSchema. --- express-zod-api/src/documentation-helpers.ts | 11 +- .../__snapshots__/documentation.spec.ts.snap | 106 ++++++++++++++++++ express-zod-api/tests/documentation.spec.ts | 25 +++++ 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 35f7fe19d..4bc27d1ea 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -25,7 +25,6 @@ import * as R from "ramda"; import { globalRegistry, z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { - FlatObject, getRoutePathParams, getTransformedType, isObject, @@ -650,15 +649,7 @@ export const depictBody = ({ composition === "components" ? makeRef(schema, withoutParams, makeCleanId(description)) : withoutParams, - examples: enumerateExamples( - examples.length - ? examples - : flattenIO(request) // @todo this branch might no longer be required - .examples?.filter( - (one): one is FlatObject => isObject(one) && !Array.isArray(one), - ) - .map(R.omit(paramNames)) || [], - ), + examples: enumerateExamples(examples), }; const body: RequestBodyObject = { description, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 9f3ce490a..f22a8a41b 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3558,6 +3558,112 @@ servers: " `; +exports[`Documentation > Metadata > should merge prop examples with middlewares 1`] = ` +"openapi: 3.1.0 +info: + title: Testing Metadata:example on IO schema + middleware + version: 3.4.5 +paths: + /v1/getSomething: + post: + operationId: PostV1GetSomething + requestBody: + description: POST /v1/getSomething Request body + content: + application/json: + schema: + type: object + properties: + key: + examples: + - 1234-56789-01 + type: string + str: + examples: + - test + type: string + required: + - key + - str + examples: + example1: + value: + key: 1234-56789-01 + str: test + required: true + responses: + "200": + description: POST /v1/getSomething Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + properties: + num: + examples: + - 123 + type: number + required: + - num + examples: + - num: 123 + required: + - status + - data + examples: + example1: + value: + status: success + data: + num: 123 + "400": + description: POST /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + required: + - status + - error + examples: + example1: + value: + status: error + error: + message: Sample error message +components: + schemas: {} + responses: {} + parameters: {} + examples: {} + requestBodies: {} + headers: {} + securitySchemes: {} + links: {} + callbacks: {} +tags: [] +servers: + - url: https://example.com +" +`; + exports[`Documentation > Metadata > should pass over examples of each param from the whole IO schema examples (get method) 1`] = ` "openapi: 3.1.0 info: diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 3639ed67c..b0426955f 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1113,6 +1113,31 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); + test("should merge prop examples with middlewares", () => { + const spec = new Documentation({ + config: sampleConfig, + routing: { + v1: { + getSomething: defaultEndpointsFactory + .addMiddleware({ + input: z.object({ key: z.string().example("1234-56789-01") }), + handler: vi.fn(), + }) + .build({ + method: "post", + input: z.object({ str: z.string().example("test") }), + output: z.object({ num: z.number().example(123) }), + handler: async () => ({ num: 123 }), + }), + }, + }, + version: "3.4.5", + title: "Testing Metadata:example on IO schema + middleware", + serverUrl: "https://example.com", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }); + test("Issue #827: .example() should be immutable", () => { const zodSchema = z.object({ a: z.string() }); const spec = new Documentation({ From 9bcc366d0087a01635086a997c83bdf21d6c413d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 20:30:50 +0200 Subject: [PATCH 127/187] Adjusting env test to ensure no conflict between .meta() and .describe(). --- express-zod-api/tests/env.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index d53285c39..b2c2057cd 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -79,7 +79,7 @@ describe("Environment checks", () => { const schema = z .string() .meta({ examples: ["test"] }) - .meta({ description: "some" }) + .describe("some") .meta({ title: "last" }); expect(schema.meta()).toMatchSnapshot(); }); From 62ba8a4a54de4a19deb10f73699c7ca586aeccf6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 21 May 2025 21:46:25 +0200 Subject: [PATCH 128/187] Fix: Workaround for branded examples in updateUserEndpoint. --- example/endpoints/update-user.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index 7ca0c5d51..acaf4d899 100644 --- a/example/endpoints/update-user.ts +++ b/example/endpoints/update-user.ts @@ -1,9 +1,22 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; -import { $brand, z } from "zod/v4"; +import { z } from "zod/v4"; import { ez } from "express-zod-api"; import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories"; +/** + * Examples on branded schemas have also to be branded + * @see https://zod.dev/api?id=branded-types + * @todo remove if fixed: + * @see https://github.com/colinhacks/zod/issues/4441 + * */ +const birthdaySchema = ez.dateIn(); +const birthday = birthdaySchema.example(birthdaySchema.parse("1963-04-21")); +const createdAtSchema = ez.dateOut(); +const createdAt = createdAtSchema.example( + createdAtSchema.parse(new Date("2021-12-31")), +); + export const updateUserEndpoint = keyAndTokenAuthenticatedEndpointsFactory.build({ tag: "users", @@ -16,13 +29,11 @@ export const updateUserEndpoint = .transform((value) => parseInt(value, 10)) .refine((value) => value >= 0, "should be greater than or equal to 0"), name: z.string().nonempty().example("John Doe"), - birthday: ez.dateIn().example(new Date("1963-04-21") as Date & $brand), + birthday, }), output: z.object({ name: z.string().example("John Doe"), - createdAt: ez - .dateOut() - .example("2021-12-31T00:00:00.000Z" as string & $brand), + createdAt, }), handler: async ({ input: { id, name }, From 7db698e8173a6f0dcbefbc758aed01a967415703 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 22 May 2025 09:15:39 +0200 Subject: [PATCH 129/187] additional test for examples of individual prop in a depicted body. --- .../__snapshots__/documentation.spec.ts.snap | 98 ++++++++++++++++++- express-zod-api/tests/documentation.spec.ts | 56 ++++++----- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index f22a8a41b..f91069540 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3850,7 +3850,7 @@ servers: " `; -exports[`Documentation > Metadata > should pass over the example of an individual parameter 1`] = ` +exports[`Documentation > Metadata > should pass over the example of an individual prop in get request 1`] = ` "openapi: 3.1.0 info: title: Testing Metadata:example on IO parameter @@ -3944,6 +3944,102 @@ servers: " `; +exports[`Documentation > Metadata > should pass over the example of an individual prop in post request 1`] = ` +"openapi: 3.1.0 +info: + title: Testing Metadata:example on IO parameter + version: 3.4.5 +paths: + /v1/getSomething: + post: + operationId: PostV1GetSomething + requestBody: + description: POST /v1/getSomething Request body + content: + application/json: + schema: + type: object + properties: + strNum: + examples: + - "123" + type: string + required: + - strNum + required: true + responses: + "200": + description: POST /v1/getSomething Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + properties: + numericStr: + examples: + - "456" + type: string + required: + - numericStr + examples: + - numericStr: "456" + required: + - status + - data + examples: + example1: + value: + status: success + data: + numericStr: "456" + "400": + description: POST /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + required: + - status + - error + examples: + example1: + value: + status: error + error: + message: Sample error message +components: + schemas: {} + responses: {} + parameters: {} + examples: {} + requestBodies: {} + headers: {} + securitySchemes: {} + links: {} + callbacks: {} +tags: [] +servers: + - url: https://example.com +" +`; + exports[`Documentation > Metadata > should pass over the schema description 1`] = ` "openapi: 3.1.0 info: diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index b0426955f..ec56ab73d 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1025,34 +1025,38 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); - test("should pass over the example of an individual parameter", () => { - const spec = new Documentation({ - config: sampleConfig, - routing: { - v1: { - getSomething: defaultEndpointsFactory.build({ - input: z.object({ - strNum: z - .string() - .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("456"), // example for the output side of the transformation + test.each(["get", "post"])( + "should pass over the example of an individual prop in %s request", + (method) => { + const spec = new Documentation({ + config: sampleConfig, + routing: { + v1: { + getSomething: defaultEndpointsFactory.build({ + method, + input: z.object({ + strNum: z + .string() + .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("456"), // example for the output side of the transformation + }), + handler: async () => ({ numericStr: 123 }), }), - handler: async () => ({ numericStr: 123 }), - }), + }, }, - }, - version: "3.4.5", - title: "Testing Metadata:example on IO parameter", - serverUrl: "https://example.com", - }).getSpecAsYaml(); - expect(spec).toMatchSnapshot(); - }); + version: "3.4.5", + title: "Testing Metadata:example on IO parameter", + serverUrl: "https://example.com", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }, + ); test.each(["get", "post"])( "should pass over examples of each param from the whole IO schema examples (%s method)", From 0030d2bbd8bac40f3fb5fe42e7e6b4248f2cb17d Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 22 May 2025 11:58:47 +0200 Subject: [PATCH 130/187] Ref: Move mixing and pulling request examples to postprocessing (#2651) The need of it discovered during #2649 Examples set before transformation are not getting to body depiction. see https://github.com/RobinTail/express-zod-api/pull/2649#discussion_r2101460393 this is because mixing and pulling before in preprocess, while to should be devoted to Zod to depict them first, and then do mixing/pulling in a postprocess, e.g. `flattenIO` --- express-zod-api/src/common-helpers.ts | 17 +--- express-zod-api/src/documentation-helpers.ts | 11 ++- express-zod-api/src/io-schema.ts | 16 ++-- express-zod-api/src/json-schema-helpers.ts | 14 +++- express-zod-api/src/metadata.ts | 32 +------- express-zod-api/src/result-handler.ts | 10 +-- express-zod-api/src/result-helpers.ts | 18 ++++- .../__snapshots__/documentation.spec.ts.snap | 4 + .../json-schema-helpers.spec.ts.snap | 35 ++++++++ express-zod-api/tests/common-helpers.spec.ts | 19 ----- express-zod-api/tests/io-schema.spec.ts | 32 -------- .../tests/json-schema-helpers.spec.ts | 18 +++++ express-zod-api/tests/metadata.spec.ts | 81 +++---------------- express-zod-api/tests/result-helpers.spec.ts | 19 +++++ 14 files changed, 138 insertions(+), 188 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index bf1555315..832faf03b 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,7 +1,7 @@ -import type { $ZodObject, $ZodTransform, $ZodType } from "zod/v4/core"; import { Request } from "express"; import * as R from "ramda"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; +import type { $ZodTransform, $ZodType } from "zod/v4/core"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; import { OutputValidationError } from "./errors"; @@ -90,19 +90,6 @@ export const isSchema = ( type: T["_zod"]["def"]["type"], ): subject is T => subject._zod.def.type === type; -/** Takes the original unvalidated examples from the properties of ZodObject schema shape */ -export const pullExampleProps = (subject: T) => - 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]) => ({ - ...left, - ...right, - })); - }, - [], - ); - 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 4bc27d1ea..d24968c9f 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -25,6 +25,7 @@ import * as R from "ramda"; import { globalRegistry, z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { + FlatObject, getRoutePathParams, getTransformedType, isObject, @@ -649,7 +650,15 @@ export const depictBody = ({ composition === "components" ? makeRef(schema, withoutParams, makeCleanId(description)) : withoutParams, - examples: enumerateExamples(examples), + examples: enumerateExamples( + examples.length + ? examples + : flattenIO(request) + .examples?.filter( + (one): one is FlatObject => isObject(one) && !Array.isArray(one), + ) + .map(R.omit(paramNames)) || [], + ), }; const body: RequestBodyObject = { description, diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index e56e9ab89..cabadf807 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,6 +1,5 @@ import * as R from "ramda"; import { z } from "zod/v4"; -import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; type Base = object & { [Symbol.iterator]?: never }; @@ -13,7 +12,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 - * @see mixExamples + * @since 22.05.2025 does not mix examples in after switching to Zod 4 */ export const getFinalEndpointInputSchema = < MIN extends IOSchema, @@ -21,12 +20,7 @@ export const getFinalEndpointInputSchema = < >( middlewares: AbstractMiddleware[], input: IN, -): z.ZodIntersection => { - const allSchemas: IOSchema[] = R.pluck("schema", middlewares); - allSchemas.push(input); - const finalSchema = allSchemas.reduce((acc, schema) => acc.and(schema)); - return allSchemas.reduce( - (acc, schema) => mixExamples(schema, acc), - finalSchema, - ) as z.ZodIntersection; -}; +) => + R.pluck("schema", middlewares) + .concat(input) + .reduce((acc, schema) => acc.and(schema)) as z.ZodIntersection; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 17379ef04..b06e1777a 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -1,6 +1,6 @@ import type { JSONSchema } from "zod/v4/core"; import * as R from "ramda"; -import { combinations, isObject } from "./common-helpers"; +import { combinations, FlatObject, isObject } from "./common-helpers"; const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, @@ -63,6 +63,7 @@ export const flattenIO = ( } } if (!isJsonObjectSchema(entry)) continue; + stack.push([isOptional, { examples: pullRequestExamples(entry) }]); if (entry.properties) { flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( flat.properties, @@ -87,3 +88,14 @@ export const flattenIO = ( if (flatRequired.length) flat.required = [...new Set(flatRequired)]; return flat; }; + +/** @see pullResponseExamples */ +export const pullRequestExamples = (subject: JSONSchema.ObjectSchema) => + Object.entries(subject.properties || {}).reduce( + (acc, [key, { examples = [] }]) => + combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ + ...left, + ...right, + })), + [], + ); diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index c0460f67c..038155033 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -1,37 +1,9 @@ -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"; +import type { $ZodType } from "zod/v4/core"; export const metaSymbol = Symbol.for("express-zod-api"); -export const mixExamples = ( - src: A, - dest: B, -): B => { - const { - examples: srcExamples = isSchema<$ZodObject>(src, "object") - ? pullExampleProps(src) - : undefined, - } = src.meta() || {}; - if (!srcExamples?.length) return dest; - const { examples: destExamples = [] } = dest.meta() || {}; - const examples = combinations & z.output>( - destExamples, - srcExamples, - ([destExample, srcExample]) => - typeof destExample === "object" && - typeof srcExample === "object" && - destExample && - srcExample - ? R.mergeDeepRight(destExample, srcExample) - : srcExample, // not supposed to be called on non-object schemas - ); - return dest.meta({ examples }); -}; - export const getBrand = (subject: $ZodType) => { - const { brand } = subject._zod.bag; + const { brand } = subject._zod.bag || {}; if ( typeof brand === "symbol" || typeof brand === "string" || diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index 4ea3d6e25..a6cefa59a 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -6,12 +6,7 @@ import { defaultStatusCodes, NormalizedResponse, } from "./api-response"; -import { - FlatObject, - isObject, - isSchema, - pullExampleProps, -} from "./common-helpers"; +import { FlatObject, isObject, isSchema } from "./common-helpers"; import { contentTypes } from "./content-type"; import { IOSchema } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; @@ -21,6 +16,7 @@ import { getPublicErrorMessage, logServerError, normalize, + pullResponseExamples, ResultSchema, } from "./result-helpers"; @@ -104,7 +100,7 @@ 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)); + examples.push(...pullResponseExamples(output as $ZodObject)); if (examples.length && !globalRegistry.has(output)) globalRegistry.add(output, { examples }); const responseSchema = z.object({ diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index cd69d8e83..d8e08c7f1 100644 --- a/express-zod-api/src/result-helpers.ts +++ b/express-zod-api/src/result-helpers.ts @@ -1,8 +1,11 @@ import { Request } from "express"; import createHttpError, { HttpError, isHttpError } from "http-errors"; -import { z } from "zod/v4"; +import * as R from "ramda"; +import { globalRegistry, z } from "zod/v4"; +import type { $ZodObject } from "zod/v4/core"; import { NormalizedResponse, ResponseVariant } from "./api-response"; import { + combinations, FlatObject, getMessageFromError, isProduction, @@ -84,3 +87,16 @@ export const getPublicErrorMessage = (error: HttpError): string => isProduction() && !error.expose ? createHttpError(error.statusCode).message // default message for that code : error.message; + +/** @see pullRequestExamples */ +export const pullResponseExamples = (subject: T) => + 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]) => ({ + ...left, + ...right, + })); + }, + [], + ); diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index f91069540..4a910167e 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3966,6 +3966,10 @@ paths: type: string required: - strNum + examples: + example1: + value: + strNum: "123" required: true responses: "200": diff --git a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap index 04aa68abe..b23d5956b 100644 --- a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap @@ -53,6 +53,41 @@ exports[`JSON Schema helpers > flattenIO() > should pass the object schema throu } `; +exports[`JSON Schema helpers > flattenIO() > should pull examples up from object schema props 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": 123, + }, + { + "one": "jest", + "two": 123, + }, + ], + "properties": { + "one": { + "examples": [ + "test", + "jest", + ], + "type": "string", + }, + "two": { + "examples": [ + 123, + ], + "type": "number", + }, + }, + "required": [ + "one", + "two", + ], + "type": "object", +} +`; + exports[`JSON Schema helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` { "examples": [ diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index ba6cdccd4..bd62744c9 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -6,7 +6,6 @@ import { getMessageFromError, makeCleanId, ensureError, - pullExampleProps, getRoutePathParams, } from "../src/common-helpers"; import { z } from "zod/v4"; @@ -199,24 +198,6 @@ describe("Common Helpers", () => { }); }); - describe("pullExampleProps()", () => { - test("handles multiple examples per property", () => { - const schema = z.object({ - a: z.string().example("one").example("two").example("three"), - b: z.number().example(1).example(2), - c: z.boolean().example(false), - }); - expect(pullExampleProps(schema)).toEqual([ - { a: "one", b: 1, c: false }, - { a: "one", b: 2, c: false }, - { a: "two", b: 1, c: false }, - { a: "two", b: 2, c: false }, - { a: "three", b: 1, c: false }, - { a: "three", b: 2, c: false }, - ]); - }); - }); - 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/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 0e6ebc28b..a30c2ede6 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -242,37 +242,5 @@ describe("I/O Schema and related helpers", () => { expect(result).toBeInstanceOf(z.ZodIntersection); expect(result).toMatchSnapshot(); }); - - test("Should merge examples", () => { - const middlewares: AbstractMiddleware[] = [ - new Middleware({ - input: z - .object({ one: z.string() }) - .and(z.object({ two: z.number() })) - .example({ one: "test", two: 123 }), - handler: vi.fn(), - }), - new Middleware({ - input: z - .object({ three: z.null() }) - .or(z.object({ four: z.boolean() })) - .example({ three: null, four: true }), - handler: vi.fn(), - }), - ]; - const endpointInput = z - .object({ five: z.string() }) - .example({ five: "some" }); - const result = getFinalEndpointInputSchema(middlewares, endpointInput); - expect(result.meta()?.examples).toEqual([ - { - one: "test", - two: 123, - three: null, - four: true, - five: "some", - }, - ]); - }); }); }); diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index dc3c554d9..a815f504b 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -72,6 +72,24 @@ describe("JSON Schema helpers", () => { expect(subject).toMatchSnapshot(); }); + test("should pull examples up from object schema props", () => { + const subject = flattenIO({ + allOf: [ + { + type: "object", + properties: { one: { type: "string", examples: ["test", "jest"] } }, + required: ["one"], + }, + { + type: "object", + properties: { two: { type: "number", examples: [123] } }, + required: ["two"], + }, + ], + }); + expect(subject).toMatchSnapshot(); + }); + test("should handle records", () => { const subject = z.toJSONSchema( z diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts index c3a74433b..c9debe46b 100644 --- a/express-zod-api/tests/metadata.spec.ts +++ b/express-zod-api/tests/metadata.spec.ts @@ -1,75 +1,14 @@ -import { z } from "zod/v4"; -import { mixExamples } from "../src/metadata"; +import type { $ZodType } from "zod/v4/core"; +import { getBrand } from "../src/metadata"; describe("Metadata", () => { - describe("mixExamples()", () => { - test("should return the same dest schema in case src one has no meta", () => { - const src = z.string(); - const dest = z.number(); - const result = mixExamples(src, dest); - expect(result).toEqual(dest); - 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()?.examples).toEqual(src.meta()?.examples); - expect(result.meta()?.examples).toEqual(["some"]); - expect(result.description).toBe("another"); // preserves it - }); - - test("should merge the meta from src to dest", () => { - const src = z - .object({ a: z.string() }) - .example({ a: "some" }) - .example({ a: "another" }); - const dest = z - .object({ b: z.number() }) - .example({ b: 123 }) - .example({ b: 456 }) - .example({ b: 789 }); - const result = mixExamples(src, dest); - expect(result.meta()?.examples).toEqual([ - { a: "some", b: 123 }, - { a: "another", b: 123 }, - { a: "some", b: 456 }, - { a: "another", b: 456 }, - { a: "some", b: 789 }, - { a: "another", b: 789 }, - ]); - }); - - test("should merge deeply", () => { - const src = z - .object({ a: z.object({ b: z.string() }) }) - .example({ a: { b: "some" } }) - .example({ a: { b: "another" } }); - const dest = z - .object({ a: z.object({ c: z.number() }) }) - .example({ a: { c: 123 } }) - .example({ a: { c: 456 } }) - .example({ a: { c: 789 } }); - const result = mixExamples(src, dest); - expect(result.meta()?.examples).toEqual([ - { a: { b: "some", c: 123 } }, - { a: { b: "another", c: 123 } }, - { a: { b: "some", c: 456 } }, - { a: { b: "another", c: 456 } }, - { a: { b: "some", c: 789 } }, - { a: { b: "another", c: 789 } }, - ]); - }); - - test("should avoid non-object examples", () => { - const src = z.string().example("a").example("b"); - const dest = z - .object({ items: z.array(z.string()) }) - .example({ items: ["e", "f", "g"] }); - const result = mixExamples(src, dest); - expect(result.meta()?.examples).toEqual(["a", "b"]); - }); + describe("getBrand", () => { + test.each([{ brand: "test" }, {}, undefined])( + "should take it from bag", + (bag) => { + const mock = { _zod: { bag } }; + expect(getBrand(mock as unknown as $ZodType)).toBe(bag?.brand); + }, + ); }); }); diff --git a/express-zod-api/tests/result-helpers.spec.ts b/express-zod-api/tests/result-helpers.spec.ts index fcaa08d25..aa0a2296b 100644 --- a/express-zod-api/tests/result-helpers.spec.ts +++ b/express-zod-api/tests/result-helpers.spec.ts @@ -6,6 +6,7 @@ import { getPublicErrorMessage, logServerError, normalize, + pullResponseExamples, } from "../src/result-helpers"; import { makeLoggerMock, makeRequestMock } from "../src/testing"; @@ -93,6 +94,24 @@ describe("Result helpers", () => { }); }); + describe("pullResponseExamples()", () => { + test("handles multiple examples per property", () => { + const schema = z.object({ + a: z.string().example("one").example("two").example("three"), + b: z.number().example(1).example(2), + c: z.boolean().example(false), + }); + expect(pullResponseExamples(schema)).toEqual([ + { a: "one", b: 1, c: false }, + { a: "one", b: 2, c: false }, + { a: "two", b: 1, c: false }, + { a: "two", b: 2, c: false }, + { a: "three", b: 1, c: false }, + { a: "three", b: 2, c: false }, + ]); + }); + }); + describe.each(["development", "production"])( "getPublicErrorMessage() in %s mode", (mode) => { From d035ac1c3a92d6dc0e37c32e0f49c845e3334590 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 22 May 2025 12:39:23 +0200 Subject: [PATCH 131/187] feat(v24): metadata argument for `ez.dateIn()` and `ez.dateOut()` (#2649) This should bypass the need for parsing examples OR using `$brand` to set them --- CHANGELOG.md | 7 +++++++ README.md | 8 ++++---- example/endpoints/update-user.ts | 17 ++--------------- example/example.documentation.yaml | 10 +++++----- express-zod-api/src/date-in-schema.ts | 3 ++- express-zod-api/src/date-out-schema.ts | 11 +++++++++-- express-zod-api/src/documentation-helpers.ts | 8 ++------ .../documentation-helpers.spec.ts.snap | 2 +- .../tests/documentation-helpers.spec.ts | 11 +++-------- 9 files changed, 35 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecf6f9f6..7f8b7538c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ - 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; +- The transforming proprietary schemas `ez.dateIn()` and `ez.dateOut()` now accept metadata as its argument: + - This allows to set examples before transformation (`ez.dateIn()`) and to avoid the examples "branding"; - 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; @@ -57,6 +59,11 @@ export default [ + .example(123) ``` +```diff +- ez.dateIn().example("2021-12-31"); ++ ez.dateIn({ examples: ["2021-12-31"] }); +``` + ## Version 23 ### v23.5.0 diff --git a/README.md b/README.md index e30c187cf..a15447a54 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,7 @@ in actual response by calling which in turn calls [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString). It is also impossible to transmit the `Date` in its original form to your endpoints within JSON. Therefore, there is -confusion with original method ~~z.date()~~ that should not be used within IO schemas of your API. +confusion with original method ~~z.date()~~ that is not recommended to use without transformations. In order to solve this problem, the framework provides two custom methods for dealing with dates: `ez.dateIn()` and `ez.dateOut()` for using within input and output schemas accordingly. @@ -577,7 +577,7 @@ provides your endpoint handler or middleware with a `Date`. It supports the foll ``` `ez.dateOut()`, on the contrary, accepts a `Date` and provides `ResultHandler` with a `string` representation in ISO -format for the response transmission. Consider the following simplified example for better understanding: +format for the response transmission. Both schemas accept metadata as an argument. Consider the following example: ```typescript import { z } from "zod/v4"; @@ -587,10 +587,10 @@ const updateUserEndpoint = defaultEndpointsFactory.build({ method: "post", input: z.object({ userId: z.string(), - birthday: ez.dateIn(), // string -> Date in handler + birthday: ez.dateIn({ examples: ["1963-04-21"] }), // string -> Date in handler }), output: z.object({ - createdAt: ez.dateOut(), // Date -> string in response + createdAt: ez.dateOut({ examples: ["2021-12-31"] }), // Date -> string in response }), handler: async ({ input }) => ({ createdAt: new Date("2022-01-22"), // 2022-01-22T00:00:00.000Z diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index acaf4d899..c243b114e 100644 --- a/example/endpoints/update-user.ts +++ b/example/endpoints/update-user.ts @@ -4,19 +4,6 @@ import { z } from "zod/v4"; import { ez } from "express-zod-api"; import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories"; -/** - * Examples on branded schemas have also to be branded - * @see https://zod.dev/api?id=branded-types - * @todo remove if fixed: - * @see https://github.com/colinhacks/zod/issues/4441 - * */ -const birthdaySchema = ez.dateIn(); -const birthday = birthdaySchema.example(birthdaySchema.parse("1963-04-21")); -const createdAtSchema = ez.dateOut(); -const createdAt = createdAtSchema.example( - createdAtSchema.parse(new Date("2021-12-31")), -); - export const updateUserEndpoint = keyAndTokenAuthenticatedEndpointsFactory.build({ tag: "users", @@ -29,11 +16,11 @@ export const updateUserEndpoint = .transform((value) => parseInt(value, 10)) .refine((value) => value >= 0, "should be greater than or equal to 0"), name: z.string().nonempty().example("John Doe"), - birthday, + birthday: ez.dateIn({ examples: ["1963-04-21"] }), }), output: z.object({ name: z.string().example("John Doe"), - createdAt, + createdAt: ez.dateOut({ examples: ["2021-12-31"] }), }), handler: async ({ input: { id, name }, diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 7a1137c38..9fc39eebf 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -153,7 +153,7 @@ paths: externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString examples: - - 1963-04-21T00:00:00.000Z + - 1963-04-21 required: - key - name @@ -163,7 +163,7 @@ paths: value: key: 1234-5678-90 name: John Doe - birthday: 1963-04-21T00:00:00.000Z + birthday: 1963-04-21 required: true security: - APIKEY_1: [] @@ -193,13 +193,13 @@ paths: externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString examples: - - 2021-12-31T00:00:00.000Z + - 2021-12-31 required: - name - createdAt examples: - name: John Doe - createdAt: 2021-12-31T00:00:00.000Z + createdAt: 2021-12-31 required: - status - data @@ -209,7 +209,7 @@ paths: status: success data: name: John Doe - createdAt: 2021-12-31T00:00:00.000Z + createdAt: 2021-12-31 "400": description: PATCH /v1/user/:id Negative response content: diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index 26ecff8e9..7bb8e32a6 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -2,7 +2,7 @@ import { z } from "zod/v4"; export const ezDateInBrand = Symbol("DateIn"); -export const dateIn = () => { +export const dateIn = (meta: Parameters[0] = {}) => { const schema = z.union([ z.iso.date(), z.iso.datetime(), @@ -10,6 +10,7 @@ export const dateIn = () => { ]) as unknown as z.ZodUnion<[z.ZodString, z.ZodString, z.ZodString]>; // this fixes DTS build for ez export return schema + .meta(meta) .transform((str) => new Date(str)) .pipe(z.date()) .brand(ezDateInBrand as symbol); diff --git a/express-zod-api/src/date-out-schema.ts b/express-zod-api/src/date-out-schema.ts index 8f19d20c2..5e66f0ebe 100644 --- a/express-zod-api/src/date-out-schema.ts +++ b/express-zod-api/src/date-out-schema.ts @@ -2,10 +2,17 @@ import { z } from "zod/v4"; export const ezDateOutBrand = Symbol("DateOut"); -export const dateOut = () => +export const dateOut = ({ + examples, + ...rest +}: Parameters[0] = {}) => z .date() .transform((date) => date.toISOString()) - .brand(ezDateOutBrand as symbol); + .brand(ezDateOutBrand as symbol) + .meta({ + ...rest, + examples: examples as Array | undefined, + }); export type DateOutSchema = ReturnType; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index d24968c9f..5987958dc 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -22,7 +22,7 @@ import { TagObject, } from "openapi3-ts/oas31"; import * as R from "ramda"; -import { globalRegistry, z } from "zod/v4"; +import { z } from "zod/v4"; import { ResponseVariant } from "./api-response"; import { FlatObject, @@ -176,7 +176,7 @@ const ensureCompliance = ({ return valid; }; -export const depictDateIn: Depicter = ({ zodSchema }, ctx) => { +export const depictDateIn: Depicter = ({ jsonSchema: { examples } }, ctx) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); const jsonSchema: JSONSchema.StringSchema = { @@ -186,10 +186,6 @@ export const depictDateIn: Depicter = ({ zodSchema }, ctx) => { pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source, 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; }; 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 12542a583..d84c4519a 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -36,7 +36,7 @@ exports[`Documentation helpers > depictDateIn > should set type:string, pattern { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", "examples": [ - "2024-01-01T00:00:00.000Z", + "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 b739b4b37..a5b4655f7 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -1,4 +1,4 @@ -import { $brand, JSONSchema } from "zod/v4/core"; +import { JSONSchema } from "zod/v4/core"; import { SchemaObject } from "openapi3-ts/oas31"; import * as R from "ramda"; import { z } from "zod/v4"; @@ -505,16 +505,11 @@ describe("Documentation helpers", () => { test.each([ { examples: undefined }, { examples: [] }, - { examples: [new Date("2024-01-01")] }, + { examples: ["2024-01-01"] }, ])("should set type:string, pattern and format %#", ({ examples }) => { expect( depictDateIn( - { - zodSchema: ez - .dateIn() - .meta({ examples: examples as Array }), - jsonSchema: { anyOf: [], examples }, - }, + { zodSchema: z.never(), jsonSchema: { anyOf: [], examples } }, requestCtx, ), ).toMatchSnapshot(); From f60db9ec291738cc933b25e10dd765eecc24695d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 May 2025 10:42:47 +0000 Subject: [PATCH 132/187] v24.0.0-beta.3 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 665a6d176..a73e874c6 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.2", + "version": "24.0.0-beta.3", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From b02498971d7ee9e913a67ed19dd1efbb07ff743b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 22 May 2025 14:58:59 +0200 Subject: [PATCH 133/187] feat(v24): descriptions proprietary date schemas (#2652) --- example/endpoints/update-user.ts | 10 ++++++++-- example/example.client.ts | 2 ++ example/example.documentation.yaml | 4 ++-- express-zod-api/src/date-in-schema.ts | 10 +++++++--- express-zod-api/src/documentation-helpers.ts | 14 ++++++++++---- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index c243b114e..765480ae6 100644 --- a/example/endpoints/update-user.ts +++ b/example/endpoints/update-user.ts @@ -16,11 +16,17 @@ export const updateUserEndpoint = .transform((value) => parseInt(value, 10)) .refine((value) => value >= 0, "should be greater than or equal to 0"), name: z.string().nonempty().example("John Doe"), - birthday: ez.dateIn({ examples: ["1963-04-21"] }), + birthday: ez.dateIn({ + description: "the day of birth", + examples: ["1963-04-21"], + }), }), output: z.object({ name: z.string().example("John Doe"), - createdAt: ez.dateOut({ examples: ["2021-12-31"] }), + createdAt: ez.dateOut({ + description: "account creation date", + examples: ["2021-12-31"], + }), }), handler: async ({ input: { id, name }, diff --git a/example/example.client.ts b/example/example.client.ts index 61a7d056b..000db1a90 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -67,6 +67,7 @@ type PatchV1UserIdInput = { token: string; id: string; name: string; + /** the day of birth */ birthday: string; }; @@ -75,6 +76,7 @@ type PatchV1UserIdPositiveVariant1 = { status: "success"; data: { name: string; + /** account creation date */ createdAt: string; }; }; diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 9fc39eebf..6b35aa19c 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -146,7 +146,7 @@ paths: type: string minLength: 1 birthday: - description: YYYY-MM-DDTHH:mm:ss.sssZ + description: the day of birth type: string format: date-time pattern: ^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$ @@ -187,7 +187,7 @@ paths: - John Doe type: string createdAt: - description: YYYY-MM-DDTHH:mm:ss.sssZ + description: account creation date type: string format: date-time externalDocs: diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index 7bb8e32a6..77a5029bb 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -2,7 +2,10 @@ import { z } from "zod/v4"; export const ezDateInBrand = Symbol("DateIn"); -export const dateIn = (meta: Parameters[0] = {}) => { +export const dateIn = ({ + examples, + ...rest +}: Parameters[0] = {}) => { const schema = z.union([ z.iso.date(), z.iso.datetime(), @@ -10,10 +13,11 @@ export const dateIn = (meta: Parameters[0] = {}) => { ]) as unknown as z.ZodUnion<[z.ZodString, z.ZodString, z.ZodString]>; // this fixes DTS build for ez export return schema - .meta(meta) + .meta({ examples }) .transform((str) => new Date(str)) .pipe(z.date()) - .brand(ezDateInBrand as symbol); + .brand(ezDateInBrand as symbol) + .meta(rest); }; export type DateInSchema = ReturnType; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 5987958dc..48c107efd 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -176,11 +176,14 @@ const ensureCompliance = ({ return valid; }; -export const depictDateIn: Depicter = ({ jsonSchema: { examples } }, ctx) => { +export const depictDateIn: Depicter = ( + { jsonSchema: { examples, description } }, + ctx, +) => { if (ctx.isResponse) throw new DocumentationError("Please use ez.dateOut() for output.", ctx); const jsonSchema: JSONSchema.StringSchema = { - description: "YYYY-MM-DDTHH:mm:ss.sssZ", + description: 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, @@ -190,11 +193,14 @@ export const depictDateIn: Depicter = ({ jsonSchema: { examples } }, ctx) => { return jsonSchema; }; -export const depictDateOut: Depicter = ({ jsonSchema: { examples } }, ctx) => { +export const depictDateOut: Depicter = ( + { jsonSchema: { examples, description } }, + ctx, +) => { if (!ctx.isResponse) throw new DocumentationError("Please use ez.dateIn() for input.", ctx); const jsonSchema: JSONSchema.StringSchema = { - description: "YYYY-MM-DDTHH:mm:ss.sssZ", + description: description || "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", externalDocs: { url: isoDateDocumentationUrl }, From ad0ece5ce892cfd05d53d4a48f47efd395655b58 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 22 May 2025 15:00:49 +0200 Subject: [PATCH 134/187] restore zod installation command in validations CI --- .github/workflows/validations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validations.yml b/.github/workflows/validations.yml index 86de13a3f..1a181c84b 100644 --- a/.github/workflows/validations.yml +++ b/.github/workflows/validations.yml @@ -49,7 +49,7 @@ jobs: name: dist - name: Add dependencies run: | - yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod@next + yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod yarn add -D eslint@9.0 typescript-eslint@8.0 vitest tsx yarn add express-zod-api@./dist.tgz - name: Run tests From b8d8908aa297c2b2dd60d4fe6b3040de08f28693 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 22 May 2025 15:04:38 +0200 Subject: [PATCH 135/187] Ref: restore examples of createAt. --- example/endpoints/update-user.ts | 2 +- example/example.documentation.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index 765480ae6..5b812ac0d 100644 --- a/example/endpoints/update-user.ts +++ b/example/endpoints/update-user.ts @@ -25,7 +25,7 @@ export const updateUserEndpoint = name: z.string().example("John Doe"), createdAt: ez.dateOut({ description: "account creation date", - examples: ["2021-12-31"], + examples: ["2021-12-31T00:00:00.000Z"], }), }), handler: async ({ diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 6b35aa19c..1aa223c83 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -193,13 +193,13 @@ paths: externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString examples: - - 2021-12-31 + - 2021-12-31T00:00:00.000Z required: - name - createdAt examples: - name: John Doe - createdAt: 2021-12-31 + createdAt: 2021-12-31T00:00:00.000Z required: - status - data @@ -209,7 +209,7 @@ paths: status: success data: name: John Doe - createdAt: 2021-12-31 + createdAt: 2021-12-31T00:00:00.000Z "400": description: PATCH /v1/user/:id Negative response content: From b34d632866df9a6212924db9b3dec76a83ad9849 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 22 May 2025 16:30:24 +0200 Subject: [PATCH 136/187] Env assertions for ZodError and clarifications for ensureError. --- express-zod-api/src/common-helpers.ts | 2 +- express-zod-api/tests/env.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 832faf03b..4cfb90e7b 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -65,7 +65,7 @@ export const getInput = ( export const ensureError = (subject: unknown): Error => subject instanceof Error ? subject - : subject instanceof z.ZodError + : subject instanceof z.ZodError // its message is a JSON serialization of issues, so that I'm making it readable: ? new Error(getMessageFromError(subject), { cause: subject }) : new Error(String(subject)); diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index b2c2057cd..0e87d5d94 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -72,6 +72,8 @@ describe("Environment checks", () => { expect(returned).toEqual(caught); expect(returned).toBeInstanceOf(z.ZodError); expect(caught).toBeInstanceOf(z.ZodError); + expect(returned).toBeInstanceOf(Error); + expect(caught).toBeInstanceOf(Error); } }); From 29160e5105ed456a772e1806ac8c569e29850cee Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 22 May 2025 16:34:49 +0200 Subject: [PATCH 137/187] more details. --- express-zod-api/src/common-helpers.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 4cfb90e7b..7d065c1ba 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -65,8 +65,13 @@ export const getInput = ( export const ensureError = (subject: unknown): Error => subject instanceof Error ? subject - : subject instanceof z.ZodError // its message is a JSON serialization of issues, so that I'm making it readable: - ? new Error(getMessageFromError(subject), { cause: subject }) + : subject instanceof z.ZodError + ? /** + * its message is a serialization of issues, so that I'm making it readable here + * @todo rm if fixed: + * @link https://github.com/colinhacks/zod/pull/4436/ + * */ + new Error(getMessageFromError(subject), { cause: subject }) : new Error(String(subject)); export const getMessageFromError = (error: Error): string => { From 1a36e9c3164a25b90f63076137626102b9fceba1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 22 May 2025 17:51:39 +0200 Subject: [PATCH 138/187] minor: jsdoc --- express-zod-api/src/documentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index dadac4ac9..a9691b6c9 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -64,7 +64,7 @@ interface DocumentationParams { /** * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). - * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. + * @desc Values: functions having Zod context as first argument, second one is the framework context. * @example { MyBrand: ( { zodSchema, jsonSchema } ) => ({ type: "object" }) */ brandHandling?: BrandHandling; From ac2a0766061e6f0e380070fce92fe3dfbfc7aa6d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 22 May 2025 21:09:47 +0200 Subject: [PATCH 139/187] Removing unused exports and correcting RawSchema type. --- express-zod-api/src/date-in-schema.ts | 2 -- express-zod-api/src/date-out-schema.ts | 2 -- express-zod-api/src/form-schema.ts | 2 -- express-zod-api/src/raw-schema.ts | 2 +- express-zod-api/src/upload-schema.ts | 2 -- 5 files changed, 1 insertion(+), 9 deletions(-) diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index 77a5029bb..9c3e1467d 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -19,5 +19,3 @@ export const dateIn = ({ .brand(ezDateInBrand as symbol) .meta(rest); }; - -export type DateInSchema = ReturnType; diff --git a/express-zod-api/src/date-out-schema.ts b/express-zod-api/src/date-out-schema.ts index 5e66f0ebe..88fe3349f 100644 --- a/express-zod-api/src/date-out-schema.ts +++ b/express-zod-api/src/date-out-schema.ts @@ -14,5 +14,3 @@ export const dateOut = ({ ...rest, examples: examples as Array | undefined, }); - -export type DateOutSchema = ReturnType; diff --git a/express-zod-api/src/form-schema.ts b/express-zod-api/src/form-schema.ts index 2a482dcdd..f361ef07e 100644 --- a/express-zod-api/src/form-schema.ts +++ b/express-zod-api/src/form-schema.ts @@ -8,5 +8,3 @@ export const form = (base: S | z.ZodObject) => (base instanceof z.ZodObject ? base : z.object(base)).brand( ezFormBrand as symbol, ); - -export type FormSchema = ReturnType; diff --git a/express-zod-api/src/raw-schema.ts b/express-zod-api/src/raw-schema.ts index 98b682dad..b91d3fe31 100644 --- a/express-zod-api/src/raw-schema.ts +++ b/express-zod-api/src/raw-schema.ts @@ -18,4 +18,4 @@ export function raw(extra?: $ZodShape) { return extra ? extended(extra) : base.brand(ezRawBrand as symbol); } -export type RawSchema = ReturnType; +export type RawSchema = ReturnType>; diff --git a/express-zod-api/src/upload-schema.ts b/express-zod-api/src/upload-schema.ts index 0ff633013..4ed7f180e 100644 --- a/express-zod-api/src/upload-schema.ts +++ b/express-zod-api/src/upload-schema.ts @@ -34,5 +34,3 @@ export const upload = () => }, ) .brand(ezUploadBrand as symbol); - -export type UploadSchema = ReturnType; From 18d48be148282a9220ea6deaac0f388e66c37a5a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 07:12:29 +0200 Subject: [PATCH 140/187] Readme: changing deprecated refinements to the new shorthand methods. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a15447a54..6a9dcb14e 100644 --- a/README.md +++ b/README.md @@ -478,7 +478,7 @@ By the way, you can also refine the whole I/O object, for example in case you ne const endpoint = endpointsFactory.build({ input: z .object({ - email: z.string().email().optional(), + email: z.email().optional(), id: z.string().optional(), otherThing: z.string().optional(), }) @@ -957,7 +957,7 @@ export const submitFeedbackEndpoint = defaultEndpointsFactory.build({ method: "post", input: ez.form({ name: z.string().min(1), - email: z.string().email(), + email: z.email(), message: z.string().min(1), }), }); @@ -1109,7 +1109,7 @@ new ResultHandler({ negative: [ { statusCode: 409, // conflict: entity already exists - schema: z.object({ status: z.literal("exists"), id: z.number().int() }), + schema: z.object({ status: z.literal("exists"), id: z.int() }), }, { statusCode: [400, 500], // validation or internal error @@ -1147,7 +1147,7 @@ const rawAcceptingEndpoint = defaultEndpointsFactory.build({ input: ez.raw({ /* the place for additional inputs, like route params, if needed */ }), - output: z.object({ length: z.number().int().nonnegative() }), + output: z.object({ length: z.int().nonnegative() }), handler: async ({ input: { raw } }) => ({ length: raw.length, // raw is Buffer }), @@ -1185,7 +1185,7 @@ import { EventStreamFactory } from "express-zod-api"; import { setTimeout } from "node:timers/promises"; const subscriptionEndpoint = new EventStreamFactory({ - time: z.number().int().positive(), + time: z.int().positive(), }).buildVoid({ input: z.object({}), // optional input schema handler: async ({ options: { emit, isClosed } }) => { From 87431be456bc5a40a62bc4b0ff1c9687ed39ea6e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 10:45:03 +0200 Subject: [PATCH 141/187] Addressing `ZodError` does not extend Error (#2656) --- express-zod-api/src/common-helpers.ts | 7 +------ express-zod-api/tests/common-helpers.spec.ts | 10 +++++++++- express-zod-api/tests/env.spec.ts | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 7d065c1ba..6756f4c03 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -66,12 +66,7 @@ export const ensureError = (subject: unknown): Error => subject instanceof Error ? subject : subject instanceof z.ZodError - ? /** - * its message is a serialization of issues, so that I'm making it readable here - * @todo rm if fixed: - * @link https://github.com/colinhacks/zod/pull/4436/ - * */ - new Error(getMessageFromError(subject), { cause: subject }) + ? new z.ZodRealError(subject.issues) // ZodError is not an instance of Error, unlike ZodRealError that is : new Error(String(subject)); export const getMessageFromError = (error: Error): string => { diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index bd62744c9..04d0f8679 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -225,7 +225,15 @@ describe("Common Helpers", () => { message: "invalid type", }, ]), - "invalid type", + "[\n" + + " {\n" + + ' "code": "invalid_type",\n' + + ' "expected": "string",\n' + + ' "input": 123,\n' + + ' "path": [],\n' + + ' "message": "invalid type"\n' + + " }\n" + + "]", ], [createHttpError(500, "Internal Server Error"), "Internal Server Error"], [undefined, "undefined"], diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 0e87d5d94..84302e5c1 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -61,6 +61,23 @@ describe("Environment checks", () => { Object.getOwnPropertyDescriptors(schema._zod.def.shape), ).toMatchSnapshot(); }); + + test("ZodError inequality", () => { + const issue: z.core.$ZodIssue = { + code: "invalid_type", + expected: "string", + input: 123, + path: [], + message: "expected string, received number", + }; + const error = new z.ZodError([issue]); + const real = new z.ZodRealError([issue]); + expect(error).not.toBeInstanceOf(Error); // and this is important + expect(real).toBeInstanceOf(Error); + expect(real).toBeInstanceOf(z.ZodError); // important inheritance + expect(error).toHaveProperty("message"); + expect(real).toHaveProperty("message"); + }); }); describe("Zod new features", () => { From a5e5740d22550cbfac3e4c7ef18c3c62b91444f2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 11:52:58 +0200 Subject: [PATCH 142/187] Ref: DNRY for raw schema. --- express-zod-api/src/raw-schema.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/raw-schema.ts b/express-zod-api/src/raw-schema.ts index b91d3fe31..0482b2e97 100644 --- a/express-zod-api/src/raw-schema.ts +++ b/express-zod-api/src/raw-schema.ts @@ -5,12 +5,13 @@ import { file } from "./file-schema"; export const ezRawBrand = Symbol("Raw"); const base = z.object({ raw: file("buffer") }); +type Base = ReturnType>; const extended = (extra: S) => base.extend(extra).brand(ezRawBrand as symbol); /** Shorthand for z.object({ raw: ez.file("buffer") }) */ -export function raw(): ReturnType>; +export function raw(): Base; export function raw( extra: S, ): ReturnType>; @@ -18,4 +19,4 @@ export function raw(extra?: $ZodShape) { return extra ? extended(extra) : base.brand(ezRawBrand as symbol); } -export type RawSchema = ReturnType>; +export type RawSchema = Base; From 5c20f21a25e542d2045394b7a3939c4a97726798 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 16:35:21 +0200 Subject: [PATCH 143/187] Fix imports --- express-zod-api/tests/documentation-helpers.spec.ts | 2 +- express-zod-api/tests/documentation.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index a5b4655f7..7bb91f828 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 type { JSONSchema } from "zod/v4/core"; import { SchemaObject } from "openapi3-ts/oas31"; import * as R from "ramda"; import { z } from "zod/v4"; diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index ec56ab73d..d70ba2fd2 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1,5 +1,5 @@ import camelize from "camelize-ts"; -import { Method } from "example/example.client"; +import type { Method } from "../src/method"; import snakify from "snakify-ts"; import { Documentation, From 992b7125dc2b64170b6f019f5e8b75392cd6f231 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 16:36:22 +0200 Subject: [PATCH 144/187] restore using z.intersection in one test --- express-zod-api/tests/documentation.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index d70ba2fd2..1ce9bd764 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -124,9 +124,10 @@ describe("Documentation", () => { getSomething: defaultEndpointsFactory.build({ method: "post", input: z.object({ - intersection: z - .object({ one: z.string() }) - .and(z.object({ two: z.string() })), + intersection: z.intersection( + z.object({ one: z.string() }), + z.object({ two: z.string() }), + ), }), output: z.object({ and: z From fb562193923cebb4c82691c3cdcebd0185cecbfb Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 16:39:24 +0200 Subject: [PATCH 145/187] rm redundant import --- express-zod-api/tests/index.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/tests/index.spec.ts b/express-zod-api/tests/index.spec.ts index 925259e88..637741845 100644 --- a/express-zod-api/tests/index.spec.ts +++ b/express-zod-api/tests/index.spec.ts @@ -1,7 +1,6 @@ import type { $ZodType, JSONSchema } from "zod/v4/core"; import { IRouter } from "express"; import ts from "typescript"; -import { expectTypeOf } from "vitest"; import { z } from "zod/v4"; import * as entrypoint from "../src"; import { From 4cd77e125daa5420952f6c1dc4ed836551874c0b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 16:40:19 +0200 Subject: [PATCH 146/187] rm redundant import --- express-zod-api/tests/io-schema.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index a30c2ede6..6b532bb44 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,4 +1,3 @@ -import { expectTypeOf } from "vitest"; import { z } from "zod/v4"; import { IOSchema, Middleware, ez } from "../src"; import { getFinalEndpointInputSchema } from "../src/io-schema"; From efd9aa96229fba9dc7f9877344490ca13c995018 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 16:44:00 +0200 Subject: [PATCH 147/187] restore original assertions in io schema test --- express-zod-api/tests/io-schema.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 6b532bb44..5f4fafd6c 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -31,10 +31,12 @@ describe("I/O Schema and related helpers", () => { ).not.toExtend(); }); test("accepts intersection of objects", () => { - expectTypeOf(z.object({}).and(z.object({}))).toExtend(); + expectTypeOf( + z.intersection(z.object({}), z.object({})), + ).toExtend(); expectTypeOf(z.object({}).and(z.object({}))).toExtend(); expectTypeOf( - z.object({}).and(z.object({})).and(z.object({})), + z.object({}).and(z.object({}).and(z.object({}))), ).toExtend(); }); test("does not accepts intersection of object with array of objects", () => { From 160e621ae818ccb434aaa1e3d3bec7ce2d95a933 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 23 May 2025 16:51:03 +0200 Subject: [PATCH 148/187] restore using z.intersection in one test --- express-zod-api/tests/zts.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index ffddf3387..9895acc80 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -153,7 +153,7 @@ describe("zod-to-ts", () => { ), map: z.map(z.string(), z.array(z.object({ string: z.string() }))), set: z.set(z.string()), - intersection: z.string().and(z.number()).or(z.bigint()), + intersection: z.intersection(z.string(), z.number()).or(z.bigint()), promise: z.promise(z.number()), optDefaultString: z.string().optional().default("hi"), refinedStringWithSomeBullshit: z From 9f7cba87c5db6b62b9f5348467e8ed5e2b1a6b45 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 16:55:46 +0200 Subject: [PATCH 149/187] Ref: avoiding ZodError::message in snapshots. --- .../__snapshots__/form-schema.spec.ts.snap | 10 ---------- .../__snapshots__/result-helpers.spec.ts.snap | 16 --------------- .../tests/__snapshots__/system.spec.ts.snap | 20 ------------------- express-zod-api/vitest.setup.ts | 2 +- 4 files changed, 1 insertion(+), 47 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap index bcdff95d8..54d144473 100644 --- a/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap @@ -12,15 +12,5 @@ ZodError({ ], }, ], - "message": "[ - { - "expected": "string", - "code": "invalid_type", - "path": [ - "name" - ], - "message": "Invalid input: expected string, received undefined" - } -]", }) `; diff --git a/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap index 26449d5d8..e8b613436 100644 --- a/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap @@ -20,14 +20,6 @@ BadRequestError({ "path": [], }, ], - "message": "[ - { - "expected": "string", - "code": "invalid_type", - "path": [], - "message": "Invalid input: expected string, received number" - } -]", }), "message": "Invalid input: expected string, received number", }) @@ -50,14 +42,6 @@ InternalServerError({ "path": [], }, ], - "message": "[ - { - "expected": "string", - "code": "invalid_type", - "path": [], - "message": "Invalid input: expected string, received number" - } -]", }), "message": "output: Invalid input: expected string, received number", }) diff --git a/express-zod-api/tests/__snapshots__/system.spec.ts.snap b/express-zod-api/tests/__snapshots__/system.spec.ts.snap index 6b1188295..19d959f79 100644 --- a/express-zod-api/tests/__snapshots__/system.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/system.spec.ts.snap @@ -131,14 +131,6 @@ exports[`App in production mode > Validation > Problem 787: Should NOT treat Zod "path": [], }, ], - "message": "[ - { - "expected": "number", - "code": "invalid_type", - "path": [], - "message": "Invalid input: expected number, received string" - } -]", }), "message": "Invalid input: expected number, received string", }), @@ -187,18 +179,6 @@ exports[`App in production mode > Validation > Should fail on handler output typ ], }, ], - "message": "[ - { - "origin": "number", - "code": "too_small", - "minimum": 0, - "inclusive": false, - "path": [ - "anything" - ], - "message": "Too small: expected number to be >0" - } -]", }), "message": "output/anything: Too small: expected number to be >0", }), diff --git a/express-zod-api/vitest.setup.ts b/express-zod-api/vitest.setup.ts index 4dd41361e..4dedadb09 100644 --- a/express-zod-api/vitest.setup.ts +++ b/express-zod-api/vitest.setup.ts @@ -13,7 +13,7 @@ const errorSerializer: NewPlugin = { const { issues } = error instanceof z.ZodError ? error : {}; const obj = Object.assign( {}, - message && { message }, + message && !issues && { message }, // @todo undo if merged https://github.com/colinhacks/zod/pull/4436 cause && { cause }, handled && { handled }, issues && { issues }, From fbbe8d383363bf3daa94b05089683181970a80ad Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 20:32:29 +0200 Subject: [PATCH 150/187] changelog: minor, adjusting example. --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2419803..fa6b5351f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,11 +52,10 @@ export default [ ``` ```diff - z.string() + input: z.string() + .example("123") .transform(Number) - .example("123") -+ .example(123) ``` ```diff From 4a642b10c9d2959cdaad9d30dda5562c0c4f5a7b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 20:40:53 +0200 Subject: [PATCH 151/187] Changelog: mentioning discriminated argument for result handler. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6b5351f..05a63fd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,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 argument of `ResultHandler::handler` is now discriminated: either `output` or `error` is null, not both; - The `getExamples()` public helper removed — use `.meta()?.examples` instead; - Consider the automated migration using the built-in ESLint rule. From 9de50b8e7b41cea0b7d89f153f53d742cb7b465d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 May 2025 18:42:49 +0000 Subject: [PATCH 152/187] v24.0.0-beta.4 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index a73e874c6..215cddd00 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.3", + "version": "24.0.0-beta.4", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 253ad29fe5e0b6963ab8f309a40718bcd93daac0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 20:45:53 +0200 Subject: [PATCH 153/187] Changelog: minor, clarity. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a63fd01..bd01c70ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - 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; + - In order to specify an example for an input schema the `.example()` method must be called before `.transform()`; - The transforming proprietary schemas `ez.dateIn()` and `ez.dateOut()` now accept metadata as its argument: - This allows to set examples before transformation (`ez.dateIn()`) and to avoid the examples "branding"; - Generating Documentation is mostly delegated to Zod 4 `z.toJSONSchema()`: From ddf9402e1de2e2eb888632002072d57b8b28802e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 20:49:44 +0200 Subject: [PATCH 154/187] Changelog: shortening. --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd01c70ab..173635a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,7 @@ - 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; - - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - - The `Depicter` type signature changed; + - The `Depicter` type signature changed: became a postprocessing function returning an overridden JSON Schema; - The `optionalPropStyle` option removed from `Integration` class constructor: - Use `.optional()` to add question mark to the object property as well as `undefined` to its type; - Use `.or(z.undefined())` to add `undefined` to the type of the object property; From ddab0a9ff7f07154a2d35fed5c63855134600cdc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 21:10:29 +0200 Subject: [PATCH 155/187] Updating snapshots. --- .../tests/__snapshots__/endpoint.spec.ts.snap | 4 ++-- .../endpoints-factory.spec.ts.snap | 18 +++++++++--------- .../tests/__snapshots__/env.spec.ts.snap | 12 ++++++------ .../tests/__snapshots__/io-schema.spec.ts.snap | 10 +++++----- .../tests/__snapshots__/sse.spec.ts.snap | 10 +++++----- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 675e6c8e1..385571668 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -21,7 +21,7 @@ exports[`Endpoint > .getResponses() > should return the negative responses (read "application/json", ], "schema": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "examples": [ { "error": { @@ -66,7 +66,7 @@ exports[`Endpoint > .getResponses() > should return the positive responses (read "application/json", ], "schema": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "data": { "properties": { diff --git a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap index e34c9bf4d..defe53115 100644 --- a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap @@ -2,7 +2,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "allOf": [ @@ -47,7 +47,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 2`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "b": { "type": "boolean", @@ -62,7 +62,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "properties": { @@ -93,7 +93,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 2`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "o": { "type": "boolean", @@ -108,7 +108,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "properties": { @@ -138,7 +138,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 2`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "b": { "type": "boolean", @@ -153,7 +153,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "anyOf": [ @@ -198,7 +198,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 2`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "b": { "type": "boolean", @@ -213,7 +213,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd exports[`EndpointsFactory > .buildVoid() > Should be a shorthand for empty object output 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "required": [], "type": "object", diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 31dc5dd09..8b25e4efd 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -50,7 +50,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodNumb "def": { "checks": [ { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 9007199254740991, "minimum": -9007199254740991, "type": "integer", @@ -189,7 +189,7 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri "def": { "checks": [ { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "type": "string", @@ -217,7 +217,7 @@ exports[`Environment checks > Zod imperfections > circular object schema has no "configurable": true, "enumerable": true, "value": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "items": { "properties": { "features": { @@ -241,7 +241,7 @@ exports[`Environment checks > Zod imperfections > circular object schema has no "configurable": true, "enumerable": true, "value": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", }, "writable": true, @@ -251,7 +251,7 @@ exports[`Environment checks > Zod imperfections > circular object schema has no exports[`Environment checks > Zod new features > input examples of transformations 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "examples": [ "test", ], @@ -271,7 +271,7 @@ exports[`Environment checks > Zod new features > meta() merge, not just override exports[`Environment checks > Zod new features > output examples of transformations 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "examples": [ 4, ], diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 45022ce85..c39d4d145 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -2,7 +2,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should handle no middlewares 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "four": { "type": "boolean", @@ -17,7 +17,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge input object schemas 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "allOf": [ @@ -77,7 +77,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge intersection object schemas 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "allOf": [ @@ -167,7 +167,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge mixed object schemas 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "allOf": [ @@ -242,7 +242,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should merge union object schemas 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "allOf": [ diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index 18c5d84ff..d518235cf 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -2,7 +2,7 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = ` { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "data": { "type": "string", @@ -34,7 +34,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { "properties": { @@ -98,7 +98,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/plain", ], "schema": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", }, "statusCodes": [ @@ -115,7 +115,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/event-stream", ], "schema": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "data": { "type": "string", @@ -152,7 +152,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "text/plain", ], "schema": { - "$schema": "https://json-schema.org/draft-2020-12/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", }, "statusCodes": [ From ff75f79d198fa0a0d9368603e96b0e1f579cf1a6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 21:16:49 +0200 Subject: [PATCH 156/187] Ref: traverse consistency: using stack.unshift() everywhere. --- express-zod-api/src/deep-checks.ts | 4 ++-- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation-helpers.ts | 4 ++-- express-zod-api/src/json-schema-helpers.ts | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index 16e5a0bb8..2b21d437c 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -51,9 +51,9 @@ export const hasCycle = ( const entry = stack.shift()!; if (R.is(Object, entry)) { if ((entry as JSONSchema.BaseSchema).$ref === "#") return true; - stack.push(...R.values(entry)); + stack.unshift(...R.values(entry)); } - if (R.is(Array, entry)) stack.push(...R.values(entry)); + if (R.is(Array, entry)) stack.unshift(...R.values(entry)); } return false; }; diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index a0580c2c3..d9625617f 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -27,7 +27,7 @@ export class Diagnostics { if (entry.type && entry.type !== "object") this.logger.warn(`Endpoint ${dir} schema is not object-based`, ctx); for (const prop of ["allOf", "oneOf", "anyOf"] as const) - if (entry[prop]) stack.push(...entry[prop]); + if (entry[prop]) stack.unshift(...entry[prop]); } } if (endpoint.requestType === "json") { diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 48c107efd..7162b73ec 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -410,9 +410,9 @@ const fixReferences = ( entry.$ref = ctx.makeRef(depiction, ensureCompliance(depiction)).$ref; continue; } - stack.push(...R.values(entry)); + stack.unshift(...R.values(entry)); } - if (R.is(Array, entry)) stack.push(...R.values(entry)); + if (R.is(Array, entry)) stack.unshift(...R.values(entry)); } return subject; }; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index b06e1777a..ad5d062ec 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -41,7 +41,7 @@ export const flattenIO = ( const [isOptional, entry] = stack.shift()!; if (entry.description) flat.description ??= entry.description; if (entry.allOf) { - stack.push( + stack.unshift( ...entry.allOf.map((one) => { if (mode === "throw" && !(one.type === "object" && canMerge(one))) throw new Error("Can not merge"); @@ -49,8 +49,8 @@ export const flattenIO = ( }), ); } - if (entry.anyOf) stack.push(...R.map(nestOptional, entry.anyOf)); - if (entry.oneOf) stack.push(...R.map(nestOptional, entry.oneOf)); + if (entry.anyOf) stack.unshift(...R.map(nestOptional, entry.anyOf)); + if (entry.oneOf) stack.unshift(...R.map(nestOptional, entry.oneOf)); if (entry.examples?.length) { if (isOptional) { flat.examples = R.concat(flat.examples || [], entry.examples); @@ -63,7 +63,7 @@ export const flattenIO = ( } } if (!isJsonObjectSchema(entry)) continue; - stack.push([isOptional, { examples: pullRequestExamples(entry) }]); + stack.unshift([isOptional, { examples: pullRequestExamples(entry) }]); if (entry.properties) { flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( flat.properties, From 9612080bc4cc097af370f832f67378a6a1678a82 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 23 May 2025 21:22:09 +0200 Subject: [PATCH 157/187] Revert "Ref: traverse consistency: using stack.unshift() everywhere." This reverts commit ff75f79d198fa0a0d9368603e96b0e1f579cf1a6. --- express-zod-api/src/deep-checks.ts | 4 ++-- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation-helpers.ts | 4 ++-- express-zod-api/src/json-schema-helpers.ts | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index 2b21d437c..16e5a0bb8 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -51,9 +51,9 @@ export const hasCycle = ( const entry = stack.shift()!; if (R.is(Object, entry)) { if ((entry as JSONSchema.BaseSchema).$ref === "#") return true; - stack.unshift(...R.values(entry)); + stack.push(...R.values(entry)); } - if (R.is(Array, entry)) stack.unshift(...R.values(entry)); + if (R.is(Array, entry)) stack.push(...R.values(entry)); } return false; }; diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index d9625617f..a0580c2c3 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -27,7 +27,7 @@ export class Diagnostics { if (entry.type && entry.type !== "object") this.logger.warn(`Endpoint ${dir} schema is not object-based`, ctx); for (const prop of ["allOf", "oneOf", "anyOf"] as const) - if (entry[prop]) stack.unshift(...entry[prop]); + if (entry[prop]) stack.push(...entry[prop]); } } if (endpoint.requestType === "json") { diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 7162b73ec..48c107efd 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -410,9 +410,9 @@ const fixReferences = ( entry.$ref = ctx.makeRef(depiction, ensureCompliance(depiction)).$ref; continue; } - stack.unshift(...R.values(entry)); + stack.push(...R.values(entry)); } - if (R.is(Array, entry)) stack.unshift(...R.values(entry)); + if (R.is(Array, entry)) stack.push(...R.values(entry)); } return subject; }; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index ad5d062ec..b06e1777a 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -41,7 +41,7 @@ export const flattenIO = ( const [isOptional, entry] = stack.shift()!; if (entry.description) flat.description ??= entry.description; if (entry.allOf) { - stack.unshift( + stack.push( ...entry.allOf.map((one) => { if (mode === "throw" && !(one.type === "object" && canMerge(one))) throw new Error("Can not merge"); @@ -49,8 +49,8 @@ export const flattenIO = ( }), ); } - if (entry.anyOf) stack.unshift(...R.map(nestOptional, entry.anyOf)); - if (entry.oneOf) stack.unshift(...R.map(nestOptional, entry.oneOf)); + if (entry.anyOf) stack.push(...R.map(nestOptional, entry.anyOf)); + if (entry.oneOf) stack.push(...R.map(nestOptional, entry.oneOf)); if (entry.examples?.length) { if (isOptional) { flat.examples = R.concat(flat.examples || [], entry.examples); @@ -63,7 +63,7 @@ export const flattenIO = ( } } if (!isJsonObjectSchema(entry)) continue; - stack.unshift([isOptional, { examples: pullRequestExamples(entry) }]); + stack.push([isOptional, { examples: pullRequestExamples(entry) }]); if (entry.properties) { flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( flat.properties, From dbd1bb0f4606ff099fac1d61e25c69bed47ffbd8 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 24 May 2025 07:58:37 +0200 Subject: [PATCH 158/187] Updating snapshots and allow flattening with additionalProperties prop. --- example/example.documentation.yaml | 30 +++ express-zod-api/src/json-schema-helpers.ts | 1 + .../__snapshots__/documentation.spec.ts.snap | 199 ++++++++++++++++++ .../tests/__snapshots__/endpoint.spec.ts.snap | 4 + .../endpoints-factory.spec.ts.snap | 15 ++ .../tests/__snapshots__/env.spec.ts.snap | 1 + .../__snapshots__/io-schema.spec.ts.snap | 22 ++ .../tests/__snapshots__/sse.spec.ts.snap | 4 + 8 files changed, 276 insertions(+) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 1aa223c83..210f8f29a 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -48,9 +48,11 @@ paths: - id - name - features + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/user/retrieve Negative response content: @@ -68,9 +70,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -197,12 +201,14 @@ paths: required: - name - createdAt + additionalProperties: false examples: - name: John Doe createdAt: 2021-12-31T00:00:00.000Z required: - status - data + additionalProperties: false examples: example1: value: @@ -227,9 +233,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -274,9 +282,11 @@ paths: maximum: 9007199254740991 required: - id + additionalProperties: false required: - status - data + additionalProperties: false "202": description: POST /v1/user/create Positive response 202 content: @@ -296,9 +306,11 @@ paths: maximum: 9007199254740991 required: - id + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/user/create Negative response 400 content: @@ -314,6 +326,7 @@ paths: required: - status - reason + additionalProperties: false "409": description: POST /v1/user/create Negative response 409 content: @@ -331,6 +344,7 @@ paths: required: - status - id + additionalProperties: false "500": description: POST /v1/user/create Negative response 500 content: @@ -346,6 +360,7 @@ paths: required: - status - reason + additionalProperties: false /v1/user/list: get: operationId: GetV1UserList @@ -365,6 +380,7 @@ paths: type: string required: - name + additionalProperties: false examples: example1: value: @@ -496,9 +512,11 @@ paths: - mime - hash - otherInputs + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/avatar/upload Negative response content: @@ -516,9 +534,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -558,9 +578,11 @@ paths: maximum: 9007199254740991 required: - length + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/avatar/raw Negative response content: @@ -578,9 +600,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -626,6 +650,7 @@ paths: required: - data - event + additionalProperties: false "400": description: GET /v1/events/stream Negative response content: @@ -679,9 +704,11 @@ paths: maximum: 9007199254740991 required: - crc + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/forms/feedback Negative response content: @@ -699,9 +726,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -721,6 +750,7 @@ components: $ref: "#/components/schemas/Schema1" required: - title + additionalProperties: false responses: {} parameters: {} examples: {} diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index b06e1777a..3ed0e0018 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -20,6 +20,7 @@ const canMerge = R.pipe( "required", "examples", "description", + "additionalProperties", ] satisfies Array), R.isEmpty, ); diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 4a910167e..3efe7952e 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -26,9 +26,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSome/thing Negative response content: @@ -46,9 +48,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -97,9 +101,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSome/thing Negative response content: @@ -117,9 +123,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -153,9 +161,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSome/thing Negative response content: @@ -173,9 +183,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -224,9 +236,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSome/thing Negative response content: @@ -244,9 +258,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -273,9 +289,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSome/:thing Negative response content: @@ -293,9 +311,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -361,9 +381,11 @@ paths: type: number required: - num + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -381,9 +403,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -419,9 +443,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/setSomething Negative response content: @@ -439,9 +465,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -475,9 +503,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: PUT /v1/updateSomething Negative response content: @@ -495,9 +525,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -566,9 +598,11 @@ paths: type: number required: - whatever + additionalProperties: false required: - status - data + additionalProperties: false "400": description: DELETE /v1/deleteSomething Negative response content: @@ -586,9 +620,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -669,9 +705,11 @@ paths: required: - literal - transformation + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -689,9 +727,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -774,6 +814,7 @@ paths: required: - status - data + additionalProperties: false - type: object properties: status: @@ -786,14 +827,17 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false discriminator: propertyName: status required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -811,9 +855,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -893,9 +939,11 @@ paths: - six required: - and + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -913,9 +961,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1030,9 +1080,11 @@ paths: - literal - multiliteral - enum + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -1050,9 +1102,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1140,9 +1194,11 @@ paths: maximum: 9007199254740991 required: - or + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1160,9 +1216,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1245,9 +1303,11 @@ paths: required: - "null" - dateOut + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1265,9 +1325,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1323,9 +1385,11 @@ paths: $ref: "#/components/schemas/Schema2" required: - zodExample + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1343,9 +1407,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1378,6 +1444,7 @@ components: required: - name - subcategories + additionalProperties: false responses: {} parameters: {} examples: {} @@ -1416,9 +1483,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - result + additionalProperties: false text/vnd.yaml: *a1 "403": description: GET /v1/getSomething Negative response @@ -1432,6 +1501,7 @@ paths: const: NOT OK required: - status + additionalProperties: false components: schemas: {} responses: {} @@ -1529,9 +1599,11 @@ paths: pattern: ^-?\\d+$ required: - bigint + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1549,9 +1621,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1678,9 +1752,11 @@ paths: minLength: 1 required: - nonempty + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1698,9 +1774,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1768,9 +1846,11 @@ paths: - 2 required: - nativeEnum + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1788,9 +1868,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1901,9 +1983,11 @@ paths: - literal - union - enum + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -1921,9 +2005,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -1990,9 +2076,11 @@ paths: type: number required: - transform + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -2010,9 +2098,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2102,9 +2192,11 @@ paths: not: {} required: - empty + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -2122,9 +2214,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2179,9 +2273,11 @@ paths: any: {} required: - any + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -2199,9 +2295,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2266,9 +2364,11 @@ paths: type: boolean required: - boolean + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -2286,9 +2386,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2350,9 +2452,11 @@ paths: type: string required: - payload + additionalProperties: false required: - status - data + additionalProperties: false "201": description: POST /v1/mtpl Positive response 201 content: @@ -2370,9 +2474,11 @@ paths: type: string required: - payload + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/mtpl Negative response 400 content: @@ -2449,9 +2555,11 @@ paths: summary: My custom schema required: - number + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/:name Negative response content: @@ -2469,9 +2577,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2539,9 +2649,11 @@ paths: type: string required: - user_name + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/test Negative response content: @@ -2559,9 +2671,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2618,9 +2732,11 @@ paths: type: string required: - user_name + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/test Negative response content: @@ -2638,9 +2754,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2700,9 +2818,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/test Negative response content: @@ -2720,9 +2840,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2790,9 +2912,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/test Negative response content: @@ -2810,9 +2934,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2878,9 +3004,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: PUT /v1/test Negative response content: @@ -2898,9 +3026,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -2963,9 +3093,11 @@ paths: type: string required: - arr + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -2983,9 +3115,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3030,9 +3164,11 @@ paths: type: string required: - arr + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -3050,9 +3186,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3131,6 +3269,7 @@ paths: required: - id - field1 + additionalProperties: false - type: object properties: id: @@ -3140,9 +3279,11 @@ paths: required: - id - field2 + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/getSomething Negative response content: @@ -3160,9 +3301,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3219,9 +3362,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -3239,9 +3384,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3315,9 +3462,11 @@ paths: required: - a - b + additionalProperties: false required: - status - data + additionalProperties: false examples: example1: value: @@ -3348,9 +3497,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3425,9 +3576,11 @@ components: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false GetHrisEmployeesNegativeResponse: type: object properties: @@ -3441,9 +3594,11 @@ components: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false responses: {} parameters: {} examples: {} @@ -3507,9 +3662,11 @@ paths: type: number required: - num + additionalProperties: false required: - status - data + additionalProperties: false examples: example1: value: @@ -3533,9 +3690,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3611,11 +3770,13 @@ paths: type: number required: - num + additionalProperties: false examples: - num: 123 required: - status - data + additionalProperties: false examples: example1: value: @@ -3639,9 +3800,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3703,9 +3866,11 @@ paths: type: string required: - numericStr + additionalProperties: false required: - status - data + additionalProperties: false examples: example1: value: @@ -3729,9 +3894,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3799,9 +3966,11 @@ paths: type: string required: - numericStr + additionalProperties: false required: - status - data + additionalProperties: false examples: example1: value: @@ -3825,9 +3994,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3891,11 +4062,13 @@ paths: type: string required: - numericStr + additionalProperties: false examples: - numericStr: "456" required: - status - data + additionalProperties: false examples: example1: value: @@ -3919,9 +4092,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -3991,11 +4166,13 @@ paths: type: string required: - numericStr + additionalProperties: false examples: - numericStr: "456" required: - status - data + additionalProperties: false examples: example1: value: @@ -4019,9 +4196,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -4082,9 +4261,11 @@ paths: maximum: 9007199254740991 required: - result + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/getSomething Negative response content: @@ -4102,9 +4283,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -4174,9 +4357,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: very negative response of PostV1Name content: @@ -4194,9 +4379,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -4279,9 +4466,11 @@ components: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false VeryNegativeResponseOfPostV1Name: type: object properties: @@ -4295,9 +4484,11 @@ components: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false TheBodyOfRequest: type: object properties: @@ -4360,9 +4551,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: GET /v1/:name Negative response content: @@ -4380,9 +4573,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: @@ -4452,9 +4647,11 @@ paths: type: object properties: {} required: [] + additionalProperties: false required: - status - data + additionalProperties: false "400": description: POST /v1/:name Negative response content: @@ -4472,9 +4669,11 @@ paths: type: string required: - message + additionalProperties: false required: - status - error + additionalProperties: false examples: example1: value: diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 385571668..6a76a0c8e 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -22,6 +22,7 @@ exports[`Endpoint > .getResponses() > should return the negative responses (read ], "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "examples": [ { "error": { @@ -32,6 +33,7 @@ exports[`Endpoint > .getResponses() > should return the negative responses (read ], "properties": { "error": { + "additionalProperties": false, "properties": { "message": { "type": "string", @@ -67,8 +69,10 @@ exports[`Endpoint > .getResponses() > should return the positive responses (read ], "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "data": { + "additionalProperties": false, "properties": { "something": { "type": "number", diff --git a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap index defe53115..8d02d5d00 100644 --- a/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoints-factory.spec.ts.snap @@ -7,6 +7,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti { "allOf": [ { + "additionalProperties": false, "properties": { "n1": { "type": "number", @@ -18,6 +19,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti "type": "object", }, { + "additionalProperties": false, "properties": { "n2": { "type": "number", @@ -31,6 +33,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti ], }, { + "additionalProperties": false, "properties": { "s": { "type": "string", @@ -48,6 +51,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with intersecti exports[`EndpointsFactory > .build() > Should create an endpoint with intersection middleware 2`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "b": { "type": "boolean", @@ -65,6 +69,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { + "additionalProperties": false, "properties": { "a": { "type": "number", @@ -77,6 +82,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob "type": "object", }, { + "additionalProperties": false, "properties": { "i": { "type": "string", @@ -94,6 +100,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with refined ob exports[`EndpointsFactory > .build() > Should create an endpoint with refined object middleware 2`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "o": { "type": "boolean", @@ -111,6 +118,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { + "additionalProperties": false, "properties": { "n": { "type": "number", @@ -122,6 +130,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid "type": "object", }, { + "additionalProperties": false, "properties": { "s": { "type": "string", @@ -139,6 +148,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with simple mid exports[`EndpointsFactory > .build() > Should create an endpoint with simple middleware 2`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "b": { "type": "boolean", @@ -158,6 +168,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd { "anyOf": [ { + "additionalProperties": false, "properties": { "n1": { "type": "number", @@ -169,6 +180,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd "type": "object", }, { + "additionalProperties": false, "properties": { "n2": { "type": "number", @@ -182,6 +194,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd ], }, { + "additionalProperties": false, "properties": { "s": { "type": "string", @@ -199,6 +212,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd exports[`EndpointsFactory > .build() > Should create an endpoint with union middleware 2`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "b": { "type": "boolean", @@ -214,6 +228,7 @@ exports[`EndpointsFactory > .build() > Should create an endpoint with union midd exports[`EndpointsFactory > .buildVoid() > Should be a shorthand for empty object output 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": {}, "required": [], "type": "object", diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 8b25e4efd..294573a2e 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -219,6 +219,7 @@ exports[`Environment checks > Zod imperfections > circular object schema has no "value": { "$schema": "https://json-schema.org/draft/2020-12/schema", "items": { + "additionalProperties": false, "properties": { "features": { "$ref": "#", diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index c39d4d145..87a52c5f2 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -3,6 +3,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should handle no middlewares 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "four": { "type": "boolean", @@ -24,6 +25,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "allOf": [ { + "additionalProperties": false, "properties": { "one": { "type": "string", @@ -35,6 +37,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "two": { "type": "number", @@ -48,6 +51,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should ], }, { + "additionalProperties": false, "properties": { "three": { "type": "null", @@ -61,6 +65,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should ], }, { + "additionalProperties": false, "properties": { "four": { "type": "boolean", @@ -84,6 +89,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "allOf": [ { + "additionalProperties": false, "properties": { "one": { "type": "string", @@ -95,6 +101,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "two": { "type": "number", @@ -110,6 +117,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "allOf": [ { + "additionalProperties": false, "properties": { "three": { "type": "null", @@ -121,6 +129,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "four": { "type": "boolean", @@ -138,6 +147,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "allOf": [ { + "additionalProperties": false, "properties": { "five": { "type": "string", @@ -149,6 +159,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "six": { "type": "number", @@ -174,6 +185,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "allOf": [ { + "additionalProperties": false, "properties": { "one": { "type": "string", @@ -185,6 +197,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "two": { "type": "number", @@ -200,6 +213,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "anyOf": [ { + "additionalProperties": false, "properties": { "three": { "type": "null", @@ -211,6 +225,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "four": { "type": "boolean", @@ -226,6 +241,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should ], }, { + "additionalProperties": false, "properties": { "five": { "type": "string", @@ -249,6 +265,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "anyOf": [ { + "additionalProperties": false, "properties": { "one": { "type": "string", @@ -260,6 +277,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "two": { "type": "number", @@ -275,6 +293,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "anyOf": [ { + "additionalProperties": false, "properties": { "three": { "type": "null", @@ -286,6 +305,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "four": { "type": "boolean", @@ -303,6 +323,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should { "anyOf": [ { + "additionalProperties": false, "properties": { "five": { "type": "string", @@ -314,6 +335,7 @@ exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should "type": "object", }, { + "additionalProperties": false, "properties": { "six": { "type": "number", diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index d518235cf..eac7c929c 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -3,6 +3,7 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "data": { "type": "string", @@ -37,6 +38,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { + "additionalProperties": false, "properties": { "data": { "type": "string", @@ -60,6 +62,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss "type": "object", }, { + "additionalProperties": false, "properties": { "data": { "type": "number", @@ -116,6 +119,7 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss ], "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "data": { "type": "string", From 2b71a58dcb0721141b5b46c567e9134b8bd1f821 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 24 May 2025 06:04:22 +0000 Subject: [PATCH 159/187] v24.0.0-beta.5 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 215cddd00..43e892754 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.4", + "version": "24.0.0-beta.5", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From e81470e5010a333f77f42db05b2dffe891a2647c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 25 May 2025 17:18:22 +0200 Subject: [PATCH 160/187] fix(v24): Definitions deduplication (#2664) Extracted from #2663 --- express-zod-api/src/documentation-helpers.ts | 10 +++++++--- express-zod-api/src/documentation.ts | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 48c107efd..fc002052c 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -55,7 +55,7 @@ import wellKnownHeaders from "./well-known-headers.json"; interface ReqResCommons { makeRef: ( - key: object, + key: object | string, subject: SchemaObject | ReferenceObject, name?: string, ) => ReferenceObject; @@ -406,8 +406,12 @@ const fixReferences = ( if (isReferenceObject(entry) && !entry.$ref.startsWith("#/components")) { const actualName = entry.$ref.split("/").pop()!; const depiction = defs[actualName]; - if (depiction) - entry.$ref = ctx.makeRef(depiction, ensureCompliance(depiction)).$ref; + if (depiction) { + entry.$ref = ctx.makeRef( + depiction.id || JSON.stringify(depiction), // id is unique, serialization here is safe + ensureCompliance(depiction), + ).$ref; + } continue; } stack.push(...R.values(entry)); diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index a9691b6c9..822e285c8 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -86,10 +86,10 @@ interface DocumentationParams { export class Documentation extends OpenApiBuilder { readonly #lastSecuritySchemaIds = new Map(); readonly #lastOperationIdSuffixes = new Map(); - readonly #references = new Map(); + readonly #references = new Map(); #makeRef( - key: object, + key: object | string, subject: SchemaObject | ReferenceObject, name = this.#references.get(key), ): ReferenceObject { From 57d08c96330f50667196beaa285227f0ac89de58 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 25 May 2025 19:24:37 +0200 Subject: [PATCH 161/187] FIX(fix): rm serialization because ref changes. --- 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 fc002052c..bd472dd1a 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -408,7 +408,7 @@ const fixReferences = ( const depiction = defs[actualName]; if (depiction) { entry.$ref = ctx.makeRef( - depiction.id || JSON.stringify(depiction), // id is unique, serialization here is safe + depiction.id || depiction, ensureCompliance(depiction), ).$ref; } From 67a580c46a71ad560c53d490c11ca5bf43cb926e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 25 May 2025 19:56:39 +0200 Subject: [PATCH 162/187] Using serialization in depictRequestParams() for makeRef() call. --- express-zod-api/src/documentation-helpers.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index bd472dd1a..9511d00af 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -350,7 +350,11 @@ export const depictRequestParams = ({ const depicted = ensureCompliance(jsonSchema); const result = composition === "components" - ? makeRef(jsonSchema, depicted, makeCleanId(description, name)) + ? makeRef( + jsonSchema.id || JSON.stringify(jsonSchema), + depicted, + makeCleanId(description, name), + ) : depicted; return acc.concat({ name, @@ -408,7 +412,7 @@ const fixReferences = ( const depiction = defs[actualName]; if (depiction) { entry.$ref = ctx.makeRef( - depiction.id || depiction, + depiction.id || depiction, // avoiding serialization, because changing $ref ensureCompliance(depiction), ).$ref; } From 3121496d20b2ec8ccff69c2a31b7d02246c8a709 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 25 May 2025 18:01:35 +0000 Subject: [PATCH 163/187] v24.0.0-beta.6 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 43e892754..64aae4a9c 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.5", + "version": "24.0.0-beta.6", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From dd6608105d13a9f093d738e0214ebb791837808c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 26 May 2025 11:44:00 +0200 Subject: [PATCH 164/187] Adjust error message format in response (#2665) Switching to zod core's `toDotPath()` in `getMessageFromError()`. I'm not using `z.prettifyError()` because its prettiness to me is questionable due to: - icons/symbols - new lines - offsets In my opinion such formatting should be delegated to UI. For that I'm planning #2663 --- express-zod-api/src/common-helpers.ts | 12 ++++------ express-zod-api/src/errors.ts | 8 ++++++- .../__snapshots__/common-helpers.spec.ts.snap | 2 +- .../tests/__snapshots__/endpoint.spec.ts.snap | 9 ++++++++ .../__snapshots__/result-helpers.spec.ts.snap | 2 +- .../tests/__snapshots__/system.spec.ts.snap | 2 +- express-zod-api/tests/endpoint.spec.ts | 5 +---- express-zod-api/tests/errors.spec.ts | 22 +++++++++++++++++-- 8 files changed, 44 insertions(+), 18 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 6756f4c03..7cfa9eadb 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -4,7 +4,6 @@ import { z } from "zod/v4"; import type { $ZodTransform, $ZodType } from "zod/v4/core"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; -import { OutputValidationError } from "./errors"; import { AuxMethod, Method } from "./method"; /** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */ @@ -72,15 +71,12 @@ export const ensureError = (subject: unknown): Error => export const getMessageFromError = (error: Error): string => { if (error instanceof z.ZodError) { return error.issues - .map(({ path, message }) => - (path.length ? [path.join("/")] : []).concat(message).join(": "), - ) + .map(({ path, message }) => { + const prefix = path.length ? `${z.core.toDotPath(path)}: ` : ""; + return `${prefix}${message}`; + }) .join("; "); } - if (error instanceof OutputValidationError) { - const hasFirstField = error.cause.issues[0]?.path.length > 0; - return `output${hasFirstField ? "/" : ": "}${error.message}`; - } return error.message; }; diff --git a/express-zod-api/src/errors.ts b/express-zod-api/src/errors.ts index 26aaaae2d..b795124a3 100644 --- a/express-zod-api/src/errors.ts +++ b/express-zod-api/src/errors.ts @@ -55,7 +55,13 @@ export class OutputValidationError extends IOSchemaError { public override name = "OutputValidationError"; constructor(public override readonly cause: z.ZodError) { - super(getMessageFromError(cause), { cause }); + const prefixedPath = new z.ZodError( + cause.issues.map(({ path, ...rest }) => ({ + ...rest, + path: ["output", ...path], + })), + ); + super(getMessageFromError(prefixedPath), { cause }); } } diff --git a/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap index 559f1d599..30ee82c2c 100644 --- a/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap @@ -26,7 +26,7 @@ exports[`Common Helpers > defaultInputSources > should be declared in a certain } `; -exports[`Common Helpers > getMessageFromError() > should compile a string from ZodError 1`] = `"user/id: expected number, got string; user/name: expected string, got number"`; +exports[`Common Helpers > getMessageFromError() > should compile a string from ZodError 1`] = `"user.id: expected number, got string; user.name: expected string, got number"`; exports[`Common Helpers > getMessageFromError() > should handle empty path in ZodIssue 1`] = `"Top level refinement issue"`; diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 6a76a0c8e..70cb3082e 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -14,6 +14,15 @@ exports[`Endpoint > #handleResult > Should handle errors within ResultHandler 1` ] `; +exports[`Endpoint > #parseOutput > Should throw on output validation failure 1`] = ` +{ + "error": { + "message": "output.email: Invalid email address", + }, + "status": "error", +} +`; + exports[`Endpoint > .getResponses() > should return the negative responses (readonly) 1`] = ` [ { diff --git a/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap index e8b613436..50e718400 100644 --- a/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap @@ -31,7 +31,7 @@ NotFoundError({ }) `; -exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: Invalid input: expected string, received number 1`] = ` +exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: output: Invalid input: expected string, received number 1`] = ` InternalServerError({ "cause": ZodError({ "issues": [ diff --git a/express-zod-api/tests/__snapshots__/system.spec.ts.snap b/express-zod-api/tests/__snapshots__/system.spec.ts.snap index 19d959f79..74ad9c3c2 100644 --- a/express-zod-api/tests/__snapshots__/system.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/system.spec.ts.snap @@ -180,7 +180,7 @@ exports[`App in production mode > Validation > Should fail on handler output typ }, ], }), - "message": "output/anything: Too small: expected number to be >0", + "message": "output.anything: Too small: expected number to be >0", }), "payload": { "key": "123", diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index 068cc1cc0..f0d3ade1e 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -140,10 +140,7 @@ describe("Endpoint", () => { }); const { responseMock } = await testEndpoint({ endpoint }); expect(responseMock._getStatusCode()).toBe(500); - expect(responseMock._getJSONData()).toEqual({ - status: "error", - error: { message: "output/email: Invalid email address" }, - }); + expect(responseMock._getJSONData()).toMatchSnapshot(); }); test("Should throw on output parsing non-Zod error", async () => { diff --git a/express-zod-api/tests/errors.spec.ts b/express-zod-api/tests/errors.spec.ts index 6ab84605d..b16ae377f 100644 --- a/express-zod-api/tests/errors.spec.ts +++ b/express-zod-api/tests/errors.spec.ts @@ -10,6 +10,16 @@ import { } from "../src/errors"; describe("Errors", () => { + const zodError = new z.ZodError([ + { + code: "invalid_type", + path: ["test"], + message: "expected string, received number", + expected: "string", + input: 123, + }, + ]); + describe("RoutingError", () => { const error = new RoutingError("test", "get", "/v1/test"); @@ -83,7 +93,6 @@ describe("Errors", () => { }); describe("OutputValidationError", () => { - const zodError = new z.ZodError([]); const error = new OutputValidationError(zodError); test("should be an instance of IOSchemaError and Error", () => { @@ -95,13 +104,18 @@ describe("Errors", () => { expect(error.name).toBe("OutputValidationError"); }); + test("the message should be formatted and contain prefixed path", () => { + expect(error.message).toBe( + "output.test: expected string, received number", + ); + }); + test("should have .cause property matching the one used for constructing", () => { expect(error.cause).toEqual(zodError); }); }); describe("InputValidationError", () => { - const zodError = new z.ZodError([]); const error = new InputValidationError(zodError); test("should be an instance of IOSchemaError and Error", () => { @@ -113,6 +127,10 @@ describe("Errors", () => { expect(error.name).toBe("InputValidationError"); }); + test("the message should be formatted", () => { + expect(error.message).toBe("test: expected string, received number"); + }); + test("should have .cause property matching the one used for constructing", () => { expect(error.cause).toEqual(zodError); }); From 2993824fd5404b4dcda38aec6dc19e92794fa3e5 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 26 May 2025 18:40:23 +0200 Subject: [PATCH 165/187] Sharing the response examples pulling (#2666) I wanna make it a default behavior regardless of the result handler. --- express-zod-api/src/endpoint.ts | 23 +++++++++++-- express-zod-api/src/result-handler.ts | 47 +++++++++++++++------------ 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index 00872027a..0b7eb2fb9 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import * as R from "ramda"; -import { z } from "zod/v4"; +import { z, globalRegistry } from "zod/v4"; +import type { $ZodObject } from "zod/v4/core"; import { NormalizedResponse, ResponseVariant } from "./api-response"; import { findRequestTypeDefiningSchema } from "./deep-checks"; import { @@ -8,6 +9,7 @@ import { getActualMethod, getInput, ensureError, + isSchema, } from "./common-helpers"; import { CommonConfig } from "./config-type"; import { @@ -25,7 +27,7 @@ import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; import { ezRawBrand } from "./raw-schema"; -import { DiscriminatedResult } from "./result-helpers"; +import { DiscriminatedResult, pullResponseExamples } from "./result-helpers"; import { Routable } from "./routable"; import { AbstractResultHandler } from "./result-handler"; import { Security } from "./security"; @@ -82,6 +84,21 @@ export class Endpoint< > extends AbstractEndpoint { readonly #def: ConstructorParameters>[0]; + /** considered expensive operation, only required for generators */ + #ensureOutputExamples = R.once(() => { + const meta = this.#def.outputSchema.meta(); + if (meta?.examples?.length) return; // has examples on the output schema, or pull up: + if (!isSchema<$ZodObject>(this.#def.outputSchema, "object")) return; + const examples = pullResponseExamples(this.#def.outputSchema as $ZodObject); + if (!examples.length) return; + globalRegistry + .remove(this.#def.outputSchema) // reassign to avoid cloning + .add(this.#def.outputSchema as $ZodObject, { + ...meta, + examples, + }); + }); + constructor(def: { deprecated?: boolean; middlewares?: AbstractMiddleware[]; @@ -137,6 +154,7 @@ export class Endpoint< /** @internal */ public override get outputSchema(): OUT { + this.#ensureOutputExamples(); return this.#def.outputSchema; } @@ -154,6 +172,7 @@ export class Endpoint< /** @internal */ public override getResponses(variant: ResponseVariant) { + if (variant === "positive") this.#ensureOutputExamples(); return Object.freeze( variant === "negative" ? this.#def.resultHandler.getNegativeResponse() diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index a6cefa59a..7d0f5d1aa 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -1,12 +1,11 @@ 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, isSchema } from "./common-helpers"; +import { FlatObject, isObject } from "./common-helpers"; import { contentTypes } from "./content-type"; import { IOSchema } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; @@ -16,7 +15,6 @@ import { getPublicErrorMessage, logServerError, normalize, - pullResponseExamples, ResultSchema, } from "./result-helpers"; @@ -98,19 +96,20 @@ export class ResultHandler< export const defaultResultHandler = new ResultHandler({ positive: (output) => { - const { examples = [] } = globalRegistry.get(output) || {}; - if (!examples.length && isSchema<$ZodObject>(output, "object")) - examples.push(...pullResponseExamples(output as $ZodObject)); - if (examples.length && !globalRegistry.has(output)) - globalRegistry.add(output, { examples }); const responseSchema = z.object({ status: z.literal("success"), data: output, }); - return examples.reduce( - (acc, example) => acc.example({ status: "success", data: example }), - responseSchema, - ); + const { examples = [] } = globalRegistry.get(output) || {}; // pulling down: + if (examples.length) { + globalRegistry.add(responseSchema, { + examples: examples.map((data) => ({ + status: "success" as const, + data, + })), + }); + } + return responseSchema; }, negative: z .object({ @@ -146,20 +145,28 @@ export const defaultResultHandler = new ResultHandler({ * */ export const arrayResultHandler = new ResultHandler({ positive: (output) => { - const { examples = [] } = globalRegistry.get(output) || {}; const responseSchema = output instanceof z.ZodObject && "items" in output.shape && output.shape.items instanceof z.ZodArray ? output.shape.items : z.array(z.any()); - return examples.reduce( - (acc, example) => - isObject(example) && "items" in example && Array.isArray(example.items) - ? acc.example(example.items) - : acc, - responseSchema, - ); + const meta = responseSchema.meta(); + if (meta?.examples?.length) return responseSchema; // has examples on the items, or pull down: + const examples = (globalRegistry.get(output)?.examples || []) + .filter( + (example): example is { items: unknown[] } => + isObject(example) && + "items" in example && + Array.isArray(example.items), + ) + .map((example) => example.items); + if (examples.length) { + globalRegistry + .remove(responseSchema) // reassign to avoid cloning + .add(responseSchema, { ...meta, examples }); + } + return responseSchema; }, negative: z.string().example("Sample error message"), handler: ({ response, output, error, logger, request, input }) => { From d7695dc9b84220faf00eb878307e4f5487096115 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 16:44:47 +0000 Subject: [PATCH 166/187] v24.0.0-beta.7 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 64aae4a9c..d5fc4938a 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.6", + "version": "24.0.0-beta.7", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 83bf134dd5bd5c08506b059cadad92e1607ede83 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 27 May 2025 00:57:53 +0200 Subject: [PATCH 167/187] Remove `ez.file()`, expose `ez.buffer()` (#2668) Instead of #2667 --- CHANGELOG.md | 5 + README.md | 7 +- example/example.documentation.yaml | 10 +- example/factories.ts | 13 +- example/index.spec.ts | 3 + express-zod-api/src/buffer-schema.ts | 10 ++ express-zod-api/src/deep-checks.ts | 2 + express-zod-api/src/documentation-helpers.ts | 18 ++- express-zod-api/src/file-schema.ts | 25 ---- express-zod-api/src/migration.ts | 19 +++ express-zod-api/src/proprietary-schemas.ts | 8 +- express-zod-api/src/raw-schema.ts | 4 +- express-zod-api/src/zts.ts | 16 +-- .../documentation-helpers.spec.ts.snap | 44 ++----- .../__snapshots__/documentation.spec.ts.snap | 11 ++ .../__snapshots__/file-schema.spec.ts.snap | 12 -- .../tests/__snapshots__/index.spec.ts.snap | 2 +- .../tests/__snapshots__/zts.spec.ts.snap | 8 +- express-zod-api/tests/buffer-schema.spec.ts | 39 ++++++ .../tests/documentation-helpers.spec.ts | 16 +-- express-zod-api/tests/documentation.spec.ts | 4 + express-zod-api/tests/file-schema.spec.ts | 117 ------------------ express-zod-api/tests/migration.spec.ts | 45 +++++++ express-zod-api/tests/routing.spec.ts | 4 +- express-zod-api/tests/zts.spec.ts | 15 +-- issue952-test/symbols.ts | 2 +- 26 files changed, 194 insertions(+), 265 deletions(-) create mode 100644 express-zod-api/src/buffer-schema.ts delete mode 100644 express-zod-api/src/file-schema.ts delete mode 100644 express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap create mode 100644 express-zod-api/tests/buffer-schema.spec.ts delete mode 100644 express-zod-api/tests/file-schema.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f03c14b73..689802811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality. - The argument of `ResultHandler::handler` is now discriminated: either `output` or `error` is null, not both; - The `getExamples()` public helper removed — use `.meta()?.examples` instead; +- The `ez.file()` schema removed: use `z.string()`, `z.base64()` or the new `ez.buffer()` instead; - Consider the automated migration using the built-in ESLint rule. ```js @@ -61,6 +62,10 @@ export default [ ```diff - ez.dateIn().example("2021-12-31"); + ez.dateIn({ examples: ["2021-12-31"] }); +- ez.file("base64"); ++ z.base64(); +- ez.file("buffer"); ++ ez.buffer(); ``` ## Version 23 diff --git a/README.md b/README.md index 492b83a47..8a8e2f166 100644 --- a/README.md +++ b/README.md @@ -922,20 +922,19 @@ Thus, you can configure non-object responses too, for example, to send an image You can find two approaches to `EndpointsFactory` and `ResultHandler` implementation [in this example](https://github.com/RobinTail/express-zod-api/blob/master/example/factories.ts). One of them implements file streaming, in this case the endpoint just has to provide the filename. -The response schema generally may be just `z.string()`, but I made more specific `ez.file()` that also supports -`ez.file("binary")` and `ez.file("base64")` variants which are reflected in the +The response schema can be `z.string()`, `z.base64()` or `ez.buffer()` to reflect the data accordingly in the [generated documentation](#creating-a-documentation). ```typescript const fileStreamingEndpointsFactory = new EndpointsFactory( new ResultHandler({ - positive: { schema: ez.file("buffer"), mimeType: "image/*" }, + positive: { schema: ez.buffer(), mimeType: "image/*" }, negative: { schema: z.string(), mimeType: "text/plain" }, handler: ({ response, error, output }) => { if (error) return void response.status(400).send(error.message); if ("filename" in output) fs.createReadStream(output.filename).pipe( - response.type(output.filename), + response.attachment(output.filename), ); else response.status(400).send("Filename is missing"); }, diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 210f8f29a..47983d1c3 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -448,8 +448,9 @@ paths: content: image/*: schema: - type: string - format: binary + externalDocs: + description: raw binary data + url: https://swagger.io/specification/#working-with-binary-data "400": description: GET /v1/avatar/stream Negative response content: @@ -555,8 +556,9 @@ paths: content: application/octet-stream: schema: - type: string - format: binary + externalDocs: + description: raw binary data + url: https://swagger.io/specification/#working-with-binary-data required: true responses: "200": diff --git a/example/factories.ts b/example/factories.ts index 3d807df23..5f7eb2e43 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -32,17 +32,14 @@ export const fileSendingEndpointsFactory = new EndpointsFactory( /** @desc This one streams the file using the "filename" property of the endpoint's output */ export const fileStreamingEndpointsFactory = new EndpointsFactory( new ResultHandler({ - positive: { schema: ez.file("buffer"), mimeType: "image/*" }, + positive: { schema: ez.buffer(), mimeType: "image/*" }, negative: { schema: z.string(), mimeType: "text/plain" }, handler: ({ response, error, output }) => { if (error) return void response.status(400).send(error.message); - if ( - "filename" in output && - typeof output.filename === "string" && - output.filename.includes(".") - ) { - const extension = output.filename.split(".").pop()!; - createReadStream(output.filename).pipe(response.type(extension)); + if ("filename" in output && typeof output.filename === "string") { + createReadStream(output.filename).pipe( + response.attachment(output.filename), + ); } else { response.status(400).send("Filename is missing"); } diff --git a/example/index.spec.ts b/example/index.spec.ts index fe2ec4782..10b0845fc 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -175,6 +175,9 @@ describe("Example", async () => { expect(response.status).toBe(200); expect(response.headers.has("Content-type")).toBeTruthy(); expect(response.headers.get("Content-type")).toBe("image/svg+xml"); + expect(response.headers.get("Content-Disposition")).toBe( + `attachment; filename="logo.svg"`, + ); expect(response.headers.has("Transfer-encoding")).toBeTruthy(); expect(response.headers.get("Transfer-encoding")).toBe("chunked"); expect(response.headers.has("Content-Encoding")).toBeTruthy(); diff --git a/express-zod-api/src/buffer-schema.ts b/express-zod-api/src/buffer-schema.ts new file mode 100644 index 000000000..5903478b3 --- /dev/null +++ b/express-zod-api/src/buffer-schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; + +export const ezBufferBrand = Symbol("Buffer"); + +export const buffer = () => + z + .custom((subject) => Buffer.isBuffer(subject), { + error: "Expected Buffer", + }) + .brand(ezBufferBrand as symbol); diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index 16e5a0bb8..f9b2a872f 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -1,6 +1,7 @@ import type { $ZodType, JSONSchema } from "zod/v4/core"; import * as R from "ramda"; import { z } from "zod/v4"; +import { ezBufferBrand } from "./buffer-schema"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { DeepCheckError } from "./errors"; @@ -91,6 +92,7 @@ export const findJsonIncompatible = ( const brand = getBrand(zodSchema); const { type } = zodSchema._zod.def; if (unsupported.includes(type)) return true; + if (brand === ezBufferBrand) return true; if (io === "input") { if (type === "date") return true; if (brand === ezDateOutBrand) return true; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 9511d00af..b57ebef02 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -24,6 +24,7 @@ import { import * as R from "ramda"; import { z } from "zod/v4"; import { ResponseVariant } from "./api-response"; +import { ezBufferBrand } from "./buffer-schema"; import { FlatObject, getRoutePathParams, @@ -40,7 +41,6 @@ import { contentTypes } from "./content-type"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { DocumentationError } from "./errors"; -import { ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; @@ -104,14 +104,12 @@ export const depictUpload: Depicter = ({}, ctx) => { return { type: "string", format: "binary" }; }; -export const depictFile: Depicter = ({ jsonSchema }) => ({ - type: "string", - format: - jsonSchema.type === "string" - ? jsonSchema.format === "base64" - ? "byte" - : "file" - : "binary", +export const depictBuffer: Depicter = ({ jsonSchema }) => ({ + ...jsonSchema, + externalDocs: { + description: "raw binary data", + url: "https://swagger.io/specification/#working-with-binary-data", + }, }); export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { @@ -390,8 +388,8 @@ const depicters: Partial> = [ezDateInBrand]: depictDateIn, [ezDateOutBrand]: depictDateOut, [ezUploadBrand]: depictUpload, - [ezFileBrand]: depictFile, [ezRawBrand]: depictRaw, + [ezBufferBrand]: depictBuffer, }; /** diff --git a/express-zod-api/src/file-schema.ts b/express-zod-api/src/file-schema.ts deleted file mode 100644 index bfbeab196..000000000 --- a/express-zod-api/src/file-schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from "zod/v4"; - -export const ezFileBrand = Symbol("File"); - -const bufferSchema = z.custom((subject) => Buffer.isBuffer(subject), { - message: "Expected Buffer", -}); - -const variants = { - buffer: () => bufferSchema.brand(ezFileBrand as symbol), - string: () => z.string().brand(ezFileBrand as symbol), - binary: () => bufferSchema.or(z.string()).brand(ezFileBrand as symbol), - base64: () => z.base64().brand(ezFileBrand as symbol), -}; - -type Variants = typeof variants; -type Variant = keyof Variants; - -export function file(): ReturnType; -export function file(variant: K): ReturnType; -export function file(variant?: K) { - return variants[variant || "string"](); -} - -export type FileSchema = ReturnType; diff --git a/express-zod-api/src/migration.ts b/express-zod-api/src/migration.ts index 721aab09e..866efb3bc 100644 --- a/express-zod-api/src/migration.ts +++ b/express-zod-api/src/migration.ts @@ -15,6 +15,7 @@ interface Queries { depicter: TSESTree.ArrowFunctionExpression; nextCall: TSESTree.CallExpression; zod: TSESTree.ImportDeclaration; + ezFile: TSESTree.CallExpression & { arguments: [TSESTree.Literal] }; } type Listener = keyof Queries; @@ -33,6 +34,7 @@ const queries: Record = { `${NT.VariableDeclarator}[id.typeAnnotation.typeAnnotation.typeName.name='Depicter'] > ` + `${NT.ArrowFunctionExpression} ${NT.CallExpression}[callee.name='next']`, zod: `${NT.ImportDeclaration}[source.value='zod']`, + ezFile: `${NT.CallExpression}[arguments.0.type='${NT.Literal}']:has( ${NT.MemberExpression}[object.name='ez'][property.name='file'] )`, }; const listen = < @@ -129,6 +131,23 @@ const v24 = ESLintUtils.RuleCreator.withoutDocs({ data: { subject: "import", from: "zod", to: "zod/v4" }, fix: (fixer) => fixer.replaceText(node.source, `"zod/v4"`), }), + ezFile: (node) => { + const [variant] = node.arguments; + const replacement = + variant.value === "buffer" + ? "ez.buffer()" + : variant.value === "base64" + ? "z.base64()" + : variant.value === "binary" + ? "ez.buffer().or(z.string())" + : "z.string()"; + ctx.report({ + node: node, + messageId: "change", + data: { subject: "schema", from: "ez.file()", to: replacement }, + fix: (fixer) => fixer.replaceText(node, replacement), + }); + }, }), }); diff --git a/express-zod-api/src/proprietary-schemas.ts b/express-zod-api/src/proprietary-schemas.ts index 653fbb0ca..c6c740746 100644 --- a/express-zod-api/src/proprietary-schemas.ts +++ b/express-zod-api/src/proprietary-schemas.ts @@ -1,16 +1,16 @@ +import { buffer, type ezBufferBrand } from "./buffer-schema"; import { dateIn, type ezDateInBrand } from "./date-in-schema"; import { dateOut, type ezDateOutBrand } from "./date-out-schema"; import { form, type ezFormBrand } from "./form-schema"; -import { file, type ezFileBrand } from "./file-schema"; import { raw, type ezRawBrand } from "./raw-schema"; import { upload, type ezUploadBrand } from "./upload-schema"; -export const ez = { dateIn, dateOut, form, file, upload, raw }; +export const ez = { dateIn, dateOut, form, upload, raw, buffer }; export type ProprietaryBrand = | typeof ezFormBrand - | typeof ezFileBrand | typeof ezDateInBrand | typeof ezDateOutBrand | typeof ezUploadBrand - | typeof ezRawBrand; + | typeof ezRawBrand + | typeof ezBufferBrand; diff --git a/express-zod-api/src/raw-schema.ts b/express-zod-api/src/raw-schema.ts index aa3a12cdf..76b43736c 100644 --- a/express-zod-api/src/raw-schema.ts +++ b/express-zod-api/src/raw-schema.ts @@ -1,10 +1,10 @@ import { z } from "zod/v4"; import type { $ZodShape } from "zod/v4/core"; -import { file } from "./file-schema"; +import { buffer } from "./buffer-schema"; export const ezRawBrand = Symbol("Raw"); -const base = z.object({ raw: file("buffer") }); +const base = z.object({ raw: buffer() }); type Base = ReturnType>; const extended = (extra: S) => diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 0fa1508cf..296be79b2 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -13,7 +13,6 @@ import type { $ZodPipe, $ZodReadonly, $ZodRecord, - $ZodString, $ZodTransform, $ZodTuple, $ZodUnion, @@ -21,11 +20,11 @@ import type { import * as R from "ramda"; import ts from "typescript"; import { globalRegistry, z } from "zod/v4"; +import { ezBufferBrand } from "./buffer-schema"; import { getTransformedType, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { hasCycle } from "./deep-checks"; -import { ezFileBrand, FileSchema } from "./file-schema"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand, RawSchema } from "./raw-schema"; import { FirstPartyKind, HandlingRules, walkSchema } from "./schema-walker"; @@ -190,16 +189,7 @@ const onNull: Producer = () => makeLiteralType(null); const onLazy: Producer = ({ _zod: { def } }: $ZodLazy, { makeAlias, next }) => makeAlias(def.getter, () => next(def.getter())); -const onFile: Producer = (schema: FileSchema) => { - const stringType = ensureTypeNode(ts.SyntaxKind.StringKeyword); - const bufferType = ensureTypeNode("Buffer"); - const unionType = f.createUnionTypeNode([stringType, bufferType]); - return isSchema<$ZodString>(schema, "string") - ? stringType - : isSchema<$ZodUnion>(schema, "union") - ? unionType - : bufferType; -}; +const onBuffer: Producer = () => ensureTypeNode("Buffer"); const onRaw: Producer = (schema: RawSchema, { next }) => next(schema._zod.def.shape.raw); @@ -233,7 +223,7 @@ const producers: HandlingRules< pipe: onPipeline, lazy: onLazy, readonly: onWrapped, - [ezFileBrand]: onFile, + [ezBufferBrand]: onBuffer, [ezRawBrand]: onRaw, }; 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 d84c4519a..c4f8a17da 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -8,6 +8,15 @@ exports[`Documentation helpers > depictBigInt() > should set type:string and for } `; +exports[`Documentation helpers > depictBuffer() > should set hint with external docs 1`] = ` +{ + "externalDocs": { + "description": "raw binary data", + "url": "https://swagger.io/specification/#working-with-binary-data", + }, +} +`; + exports[`Documentation helpers > depictDateIn > should set type:string, pattern and format 0 1`] = ` { "description": "YYYY-MM-DDTHH:mm:ss.sssZ", @@ -107,41 +116,6 @@ exports[`Documentation helpers > depictEnum() > should set type 1`] = ` } `; -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 0 1`] = ` -{ - "format": "file", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 1 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 2 1`] = ` -{ - "format": "byte", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 3 1`] = ` -{ - "format": "file", - "type": "string", -} -`; - -exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 4 1`] = ` -{ - "format": "binary", - "type": "string", -} -`; - exports[`Documentation helpers > depictIntersection() > should NOT flatten object schemas having conflicting props 1`] = ` { "allOf": [ diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 3efe7952e..abdfad8e4 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1300,9 +1300,20 @@ paths: format: date-time externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString + buffer: + externalDocs: + description: raw binary data + url: https://swagger.io/specification/#working-with-binary-data + based: + type: string + format: base64 + pattern: ^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$ + contentEncoding: base64 required: - "null" - dateOut + - buffer + - based additionalProperties: false required: - status diff --git a/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap deleted file mode 100644 index b1c8171d6..000000000 --- a/express-zod-api/tests/__snapshots__/file-schema.spec.ts.snap +++ /dev/null @@ -1,12 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ez.file() > parsing > should perform additional check for base64 file 1`] = ` -[ - { - "code": "invalid_format", - "format": "base64", - "message": "Invalid base64-encoded string", - "path": [], - }, -] -`; diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index ed696e900..c03532875 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -58,9 +58,9 @@ exports[`Index Entrypoint > exports > ensureHttpError should have certain value exports[`Index Entrypoint > exports > ez should have certain value 1`] = ` { + "buffer": [Function], "dateIn": [Function], "dateOut": [Function], - "file": [Function], "form": [Function], "raw": [Function], "upload": [Function], diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 076d7c567..100abf288 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -160,13 +160,7 @@ exports[`zod-to-ts > enums > handles 'quoted string' literals 1`] = `""Two Words exports[`zod-to-ts > enums > handles 'string' literals 1`] = `""apple" | "banana" | "cantaloupe""`; -exports[`zod-to-ts > ez.file(base64) > should depend on variant 1`] = `"string"`; - -exports[`zod-to-ts > ez.file(binary) > should depend on variant 1`] = `"string | Buffer"`; - -exports[`zod-to-ts > ez.file(buffer) > should depend on variant 1`] = `"Buffer"`; - -exports[`zod-to-ts > ez.file(string) > should depend on variant 1`] = `"string"`; +exports[`zod-to-ts > ez.buffer() > should be Buffer 1`] = `"Buffer"`; exports[`zod-to-ts > ez.raw() > should depict the raw property 1`] = `"Buffer"`; diff --git a/express-zod-api/tests/buffer-schema.spec.ts b/express-zod-api/tests/buffer-schema.spec.ts new file mode 100644 index 000000000..0828e71e9 --- /dev/null +++ b/express-zod-api/tests/buffer-schema.spec.ts @@ -0,0 +1,39 @@ +import { readFile } from "node:fs/promises"; +import { z } from "zod/v4"; +import { ez } from "../src"; + +describe("ez.buffer()", () => { + describe("creation", () => { + test("should create a Buffer", () => { + const schema = ez.buffer(); + expect(schema).toBeInstanceOf(z.ZodCustom); + expectTypeOf(schema._zod.output).toEqualTypeOf(); + }); + }); + + describe("parsing", () => { + test("should invalidate wrong types", () => { + const result = ez.buffer().safeParse("123"); + expect(result.success).toBeFalsy(); + if (!result.success) { + expect(result.error.issues).toEqual([ + { code: "custom", message: "Expected Buffer", path: [] }, + ]); + } + }); + + test("should accept Buffer", () => { + const schema = ez.buffer(); + const subject = Buffer.from("test", "utf-8"); + const result = schema.safeParse(subject); + expect(result).toEqual({ success: true, data: subject }); + }); + + test("should accept data read into buffer", async () => { + const schema = ez.buffer(); + const data = await readFile("../logo.svg"); + const result = schema.safeParse(data); + expect(result).toEqual({ success: true, data }); + }); + }); +}); diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 7bb91f828..b9ca95689 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -16,7 +16,7 @@ import { depictNullable, depictRaw, depictUpload, - depictFile, + depictBuffer, depictUnion, depictIntersection, depictBigInt, @@ -143,16 +143,10 @@ describe("Documentation helpers", () => { }); }); - describe("depictFile()", () => { - test.each([ - { type: "string" }, - { anyOf: [{}, { type: "string" }] }, - { type: "string", format: "base64" }, - { anyOf: [], type: "string" }, - {}, - ])("should set type:string and format accordingly %#", (jsonSchema) => { - expect( - depictFile({ zodSchema: z.never(), jsonSchema }, responseCtx), + describe("depictBuffer()", () => { + test("should set hint with external docs", () => { + expect( + depictBuffer({ zodSchema: z.never(), jsonSchema: {} }, responseCtx), ).toMatchSnapshot(); }); }); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 1ce9bd764..5c6ef89af 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -248,10 +248,14 @@ describe("Documentation", () => { output: z.object({ null: z.null(), dateOut: ez.dateOut(), + buffer: ez.buffer(), + based: z.base64(), }), handler: async () => ({ null: null, dateOut: new Date("2021-12-31"), + buffer: Buffer.from("test"), + based: btoa("test"), }), }), }, diff --git a/express-zod-api/tests/file-schema.spec.ts b/express-zod-api/tests/file-schema.spec.ts deleted file mode 100644 index d5ee30ba9..000000000 --- a/express-zod-api/tests/file-schema.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { z } from "zod/v4"; -import { ezFileBrand } from "../src/file-schema"; -import { ez } from "../src"; -import { readFile } from "node:fs/promises"; -import { getBrand } from "../src/metadata"; - -describe("ez.file()", () => { - describe("creation", () => { - test("should create an instance being string by default", () => { - const schema = ez.file(); - expect(schema).toBeInstanceOf(z.ZodString); - expect(getBrand(schema)).toBe(ezFileBrand); - }); - - test("should create a string file", () => { - const schema = ez.file("string"); - expect(schema).toBeInstanceOf(z.ZodString); - expectTypeOf(schema._zod.output).toBeString(); - }); - - test("should create a buffer file", () => { - const schema = ez.file("buffer"); - expect(schema).toBeInstanceOf(z.ZodCustom); - expectTypeOf(schema._zod.output).toExtend(); - }); - - test("should create a binary file", () => { - const schema = ez.file("binary"); - expect(schema).toBeInstanceOf(z.ZodUnion); - expectTypeOf(schema._zod.output).toExtend(); - }); - - test("should create a base64 file", () => { - const schema = ez.file("base64"); - expect(schema).toBeInstanceOf(z.ZodBase64); - expectTypeOf(schema._zod.output).toBeString(); - }); - }); - - describe("parsing", () => { - test.each([ - { - schema: ez.file(), - subject: 123, - code: "invalid_type", - expected: "string", - message: "Invalid input: expected string, received number", - }, - { - schema: ez.file("buffer"), - subject: "123", - code: "custom", - message: "Expected Buffer", - }, - ])( - "should invalidate wrong types", - ({ schema, subject, ...expectedError }) => { - const result = schema.safeParse(subject); - expect(result.success).toBeFalsy(); - if (!result.success) { - expect(result.error.issues).toEqual([ - { - ...expectedError, - path: [], - }, - ]); - } - }, - ); - - test("should perform additional check for base64 file", () => { - const schema = ez.file("base64"); - const result = schema.safeParse("~~~~"); - expect(result.success).toBeFalsy(); - if (!result.success) expect(result.error.issues).toMatchSnapshot(); - }); - - test("should accept string", () => { - const schema = ez.file(); - const result = schema.safeParse("some string"); - expect(result).toEqual({ - success: true, - data: "some string", - }); - }); - - test("should accept Buffer", () => { - const schema = ez.file("buffer"); - const subject = Buffer.from("test", "utf-8"); - const result = schema.safeParse(subject); - expect(result).toEqual({ - success: true, - data: subject, - }); - }); - - test("should accept binary read string", async () => { - const schema = ez.file("binary"); - const data = await readFile("../logo.svg", "binary"); - const result = schema.safeParse(data); - expect(result).toEqual({ - success: true, - data, - }); - }); - - test("should accept base64 read string", async () => { - const schema = ez.file("base64"); - const data = await readFile("../logo.svg", "base64"); - const result = schema.safeParse(data); - expect(result).toEqual({ - success: true, - data, - }); - }); - }); -}); diff --git a/express-zod-api/tests/migration.spec.ts b/express-zod-api/tests/migration.spec.ts index 17b259e46..dabf52000 100644 --- a/express-zod-api/tests/migration.spec.ts +++ b/express-zod-api/tests/migration.spec.ts @@ -23,6 +23,7 @@ describe("Migration", () => { `new Integration({});`, `const rule: Depicter = () => {};`, `import {} from "zod/v4";`, + `ez.buffer();`, ], invalid: [ { @@ -77,6 +78,50 @@ describe("Migration", () => { }, ], }, + { + code: `ez.file("string");`, + output: `z.string();`, + errors: [ + { + messageId: "change", + data: { subject: "schema", from: "ez.file()", to: "z.string()" }, + }, + ], + }, + { + code: `ez.file("buffer");`, + output: `ez.buffer();`, + errors: [ + { + messageId: "change", + data: { subject: "schema", from: "ez.file()", to: "ez.buffer()" }, + }, + ], + }, + { + code: `ez.file("base64");`, + output: `z.base64();`, + errors: [ + { + messageId: "change", + data: { subject: "schema", from: "ez.file()", to: "z.base64()" }, + }, + ], + }, + { + code: `ez.file("binary");`, + output: `ez.buffer().or(z.string());`, + errors: [ + { + messageId: "change", + data: { + subject: "schema", + from: "ez.file()", + to: "ez.buffer().or(z.string())", + }, + }, + ], + }, ], }); }); diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 3a8b50ff0..2456ec552 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -521,8 +521,8 @@ describe("Routing", () => { [ez.dateOut(), ez.dateIn()], [z.lazy(() => z.void()), ez.raw()], [z.promise(z.any()), ez.upload()], - [z.never(), z.tuple([ez.file()]).rest(z.nan())], - [z.nan().pipe(z.any()), circular], + [z.never(), z.tuple([ez.buffer()]).rest(z.nan())], + [ez.buffer().pipe(z.any()), circular], ])("should warn about JSON incompatible schemas %#", (input, output) => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ input: z.object({ input }), diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 9895acc80..08f0ca765 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -23,15 +23,12 @@ describe("zod-to-ts", () => { }); }); - describe.each(["string", "base64", "binary", "buffer"] as const)( - "ez.file(%s)", - (variant) => { - test("should depend on variant", () => { - const node = zodToTs(ez.file(variant), { ctx }); - expect(printNodeTest(node)).toMatchSnapshot(); - }); - }, - ); + describe("ez.buffer()", () => { + test("should be Buffer", () => { + const node = zodToTs(ez.buffer(), { ctx }); + expect(printNodeTest(node)).toMatchSnapshot(); + }); + }); describe("ez.raw()", () => { test("should depict the raw property", () => { diff --git a/issue952-test/symbols.ts b/issue952-test/symbols.ts index 9f4637692..a3a9f2574 100644 --- a/issue952-test/symbols.ts +++ b/issue952-test/symbols.ts @@ -2,7 +2,7 @@ import { ez } from "express-zod-api"; export const schemas = { raw: ez.raw(), - file: ez.file(), + file: ez.buffer(), dateIn: ez.dateIn(), dateOut: ez.dateOut(), upload: ez.upload(), From 5930fcac17edd83672f7c20ba52f4a79c5ad787a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 27 May 2025 08:11:55 +0200 Subject: [PATCH 168/187] Readme: moving and renaming the section: Non-JSON response. --- README.md | 60 +++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8a8e2f166..b6dee590c 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ Start your API server with I/O schema validation and custom middlewares in minut 2. [Headers as input source](#headers-as-input-source) 3. [Response customization](#response-customization) 4. [Empty response](#empty-response) - 5. [Error handling](#error-handling) - 6. [Production mode](#production-mode) - 7. [Non-object response](#non-object-response) including file downloads + 5. [Non-JSON response](#non-json-response) including file downloads + 6. [Error handling](#error-handling) + 7. [Production mode](#production-mode) 8. [HTML Forms (URL encoded)](#html-forms-url-encoded) 9. [File uploads](#file-uploads) 10. [Connect to your own express app](#connect-to-your-own-express-app) @@ -872,6 +872,33 @@ const resultHandler = new ResultHandler({ }); ``` +## Non-JSON response + +To configure a non-JSON responses (for example, to send an image file) you should specify its MIME type. + +You can find two approaches to `EndpointsFactory` and `ResultHandler` implementation +[in this example](https://github.com/RobinTail/express-zod-api/blob/master/example/factories.ts). +One of them implements file streaming, in this case the endpoint just has to provide the filename. +The response schema can be `z.string()`, `z.base64()` or `ez.buffer()` to reflect the data accordingly in the +[generated documentation](#creating-a-documentation). + +```typescript +const fileStreamingEndpointsFactory = new EndpointsFactory( + new ResultHandler({ + positive: { schema: ez.buffer(), mimeType: "image/*" }, + negative: { schema: z.string(), mimeType: "text/plain" }, + handler: ({ response, error, output }) => { + if (error) return void response.status(400).send(error.message); + if ("filename" in output) + fs.createReadStream(output.filename).pipe( + response.attachment(output.filename), + ); + else response.status(400).send("Filename is missing"); + }, + }), +); +``` + ## Error handling All runtime errors are handled by a `ResultHandler`. The default is `defaultResultHandler`. Using `ensureHttpError()` @@ -915,33 +942,6 @@ createHttpError(500, "Something is broken"); // —> "Internal Server Error" createHttpError(501, "We didn't make it yet", { expose: true }); // —> "We didn't make it yet" ``` -## Non-object response - -Thus, you can configure non-object responses too, for example, to send an image file. - -You can find two approaches to `EndpointsFactory` and `ResultHandler` implementation -[in this example](https://github.com/RobinTail/express-zod-api/blob/master/example/factories.ts). -One of them implements file streaming, in this case the endpoint just has to provide the filename. -The response schema can be `z.string()`, `z.base64()` or `ez.buffer()` to reflect the data accordingly in the -[generated documentation](#creating-a-documentation). - -```typescript -const fileStreamingEndpointsFactory = new EndpointsFactory( - new ResultHandler({ - positive: { schema: ez.buffer(), mimeType: "image/*" }, - negative: { schema: z.string(), mimeType: "text/plain" }, - handler: ({ response, error, output }) => { - if (error) return void response.status(400).send(error.message); - if ("filename" in output) - fs.createReadStream(output.filename).pipe( - response.attachment(output.filename), - ); - else response.status(400).send("Filename is missing"); - }, - }), -); -``` - ## HTML Forms (URL encoded) Use the proprietary schema `ez.form()` with an object shape or a custom `z.object()` with form fields in order to From 5fa05c18464a1103191f5bd54d060ceec22c958c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 06:29:40 +0000 Subject: [PATCH 169/187] v24.0.0-beta.8 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index d5fc4938a..ef9f0f5eb 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.7", + "version": "24.0.0-beta.8", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 88cf6c601266a97a8fabf960588985dfdeb2e70d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 27 May 2025 09:00:36 +0200 Subject: [PATCH 170/187] minor: naming. --- express-zod-api/src/zts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 296be79b2..b34cd6987 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -73,7 +73,7 @@ const onObject: Producer = ( obj: $ZodObject, { isResponse, next, makeAlias }, ) => { - const fn = () => { + const produce = () => { const members = Object.entries(obj._zod.def.shape).map( ([key, value]) => { const { description: comment, deprecated: isDeprecated } = @@ -89,8 +89,8 @@ const onObject: Producer = ( return f.createTypeLiteralNode(members); }; return hasCycle(obj, { io: isResponse ? "output" : "input" }) - ? makeAlias(obj, fn) - : fn(); + ? makeAlias(obj, produce) + : produce(); }; const onArray: Producer = ({ _zod: { def } }: $ZodArray, { next }) => From 808d01bd0709e40da096b69b016b516be6250d2c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 27 May 2025 21:57:26 +0200 Subject: [PATCH 171/187] Ref: better constraints for branded examples as per suggestion from @coderabbitai. --- express-zod-api/src/date-out-schema.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/date-out-schema.ts b/express-zod-api/src/date-out-schema.ts index 88fe3349f..c0d62092a 100644 --- a/express-zod-api/src/date-out-schema.ts +++ b/express-zod-api/src/date-out-schema.ts @@ -12,5 +12,7 @@ export const dateOut = ({ .brand(ezDateOutBrand as symbol) .meta({ ...rest, - examples: examples as Array | undefined, + examples: examples as + | Array[number] & z.$brand> + | undefined, }); From 40ba21aeaf3b2151ddd0bd4975968bc1d8a2cbcc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 27 May 2025 22:17:13 +0200 Subject: [PATCH 172/187] AI: nullish assignment instead of deleting bag.brand prop. --- express-zod-api/tests/integration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index dcdc061e7..629b022f7 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -108,7 +108,7 @@ describe("Integration", () => { schema: ReturnType, { next }, ) => { - delete schema._zod.bag.brand; + schema._zod.bag.brand = undefined; return next(schema); }; const client = new Integration({ From a6e352e1585c940417910aeff7c2050c564eebfa Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 07:49:42 +0200 Subject: [PATCH 173/187] minor: comment for clarity. --- express-zod-api/src/common-helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 7cfa9eadb..8266442f4 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -64,8 +64,8 @@ export const getInput = ( export const ensureError = (subject: unknown): Error => subject instanceof Error ? subject - : subject instanceof z.ZodError - ? new z.ZodRealError(subject.issues) // ZodError is not an instance of Error, unlike ZodRealError that is + : subject instanceof z.ZodError // ZodError does not extend Error, unlike ZodRealError that does + ? new z.ZodRealError(subject.issues) : new Error(String(subject)); export const getMessageFromError = (error: Error): string => { From 1815c5af0618fd2fd1a5c7361bdf43b756e3f826 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 08:11:05 +0200 Subject: [PATCH 174/187] AI: simpler prop definition in Zod Plugin. --- express-zod-api/src/zod-plugin.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 44552860c..3b3b0c979 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -141,14 +141,12 @@ if (!(metaSymbol in globalThis)) { if (typeof Cls !== "function") continue; Object.defineProperties(Cls.prototype, { ["example" satisfies keyof z.ZodType]: { - get(): z.ZodType["example"] { - return exampleSetter.bind(this); - }, + value: exampleSetter, + writable: false, }, ["deprecated" satisfies keyof z.ZodType]: { - get(): z.ZodType["deprecated"] { - return deprecationSetter.bind(this); - }, + value: deprecationSetter, + writable: false, }, ["brand" satisfies keyof z.ZodType]: { set() {}, // this is required to override the existing method @@ -162,19 +160,11 @@ if (!(metaSymbol in globalThis)) { Object.defineProperty( z.ZodDefault.prototype, "label" satisfies keyof z.ZodDefault, - { - get(): z.ZodDefault["label"] { - return labelSetter.bind(this); - }, - }, + { value: labelSetter, writable: false }, ); Object.defineProperty( z.ZodObject.prototype, "remap" satisfies keyof z.ZodObject, - { - get() { - return objectMapper.bind(this); - }, - }, + { value: objectMapper, writable: false }, ); } From 01418fd5f4dec07389c243e43ee3ed5eaac90135 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 08:21:54 +0200 Subject: [PATCH 175/187] AI: fix inconsistent import. --- example/endpoints/list-users.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/endpoints/list-users.ts b/example/endpoints/list-users.ts index 4a6313519..6aedac2f2 100644 --- a/example/endpoints/list-users.ts +++ b/example/endpoints/list-users.ts @@ -1,4 +1,4 @@ -import z from "zod/v4"; +import { z } from "zod/v4"; import { arrayRespondingFactory } from "../factories"; /** From 510c7ed5bbb976f4b035f9535a00e3949f1e587d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 08:25:47 +0200 Subject: [PATCH 176/187] AI: rm snapshot for clarity. --- express-zod-api/tests/__snapshots__/zts.spec.ts.snap | 2 -- express-zod-api/tests/zts.spec.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 100abf288..624eadc13 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -262,8 +262,6 @@ exports[`zod-to-ts > z.object() > supports zod.describe() 1`] = ` }" `; -exports[`zod-to-ts > z.optional() > Zod 4: does not add undefined to it, unwrap as is 1`] = `"string"`; - exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to optional props 1`] = ` "{ optional?: string | undefined; diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 08f0ca765..d34979d09 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -201,7 +201,7 @@ describe("zod-to-ts", () => { test("Zod 4: does not add undefined to it, unwrap as is", () => { const node = zodToTs(optionalStringSchema, { ctx }); - expect(printNodeTest(node)).toMatchSnapshot(); + expect(printNodeTest(node)).toEqual("string"); }); test("Zod 4: should add question mark only to optional props", () => { From 07817304afd979f8bedf44d2b94cbd5232bd295b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 08:27:41 +0200 Subject: [PATCH 177/187] AI: improving context on throwing from JSON schema helper propsMerger(). --- express-zod-api/src/json-schema-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 3ed0e0018..81657e4fa 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -9,7 +9,7 @@ const isJsonObjectSchema = ( const propsMerger = R.mergeDeepWith((a: unknown, b: unknown) => { if (Array.isArray(a) && Array.isArray(b)) return R.concat(a, b); if (a === b) return b; - throw new Error("Can not flatten properties"); + throw new Error("Can not flatten properties", { cause: { a, b } }); }); const canMerge = R.pipe( From cba3933a0e5b8ba72211e5993b4e794bee8a0bf2 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 28 May 2025 09:40:46 +0200 Subject: [PATCH 178/187] Minimizing `any` in client types (#2670) AI led me to realization that I have to much `any` in the generated client types. I'd like to fix it to prevent possible issues with such a broad type. ## Summary by CodeRabbit - **New Features** - Added support for Zod types `never`, `void`, and `unknown`, which now map to their respective TypeScript types. - **Bug Fixes** - Improved fallback typing behavior to better distinguish between response and non-response contexts. - **Tests** - Enhanced test coverage for primitive schema handling and error scenarios in schema processing. --- express-zod-api/src/zts.ts | 12 ++++- .../tests/__snapshots__/zts.spec.ts.snap | 47 ++++++++++++------ express-zod-api/tests/zts.spec.ts | 48 +++++++++++-------- 3 files changed, 71 insertions(+), 36 deletions(-) diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index b34cd6987..67c763d31 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -160,6 +160,11 @@ const onWrapped: Producer = ( { next }, ) => next(def.innerType); +const getFallback = (isResponse: boolean) => + ensureTypeNode( + isResponse ? ts.SyntaxKind.UnknownKeyword : ts.SyntaxKind.AnyKeyword, + ); + const onPipeline: Producer = ( { _zod: { def } }: $ZodPipe, { next, isResponse }, @@ -180,7 +185,7 @@ const onPipeline: Producer = ( object: ts.SyntaxKind.ObjectKeyword, }; return ensureTypeNode( - (targetType && resolutions[targetType]) || ts.SyntaxKind.AnyKeyword, + (targetType && resolutions[targetType]) || getFallback(isResponse), ); }; @@ -207,6 +212,9 @@ const producers: HandlingRules< undefined: onPrimitive(ts.SyntaxKind.UndefinedKeyword), [ezDateInBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), [ezDateOutBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), + never: onPrimitive(ts.SyntaxKind.NeverKeyword), + void: onPrimitive(ts.SyntaxKind.UndefinedKeyword), + unknown: onPrimitive(ts.SyntaxKind.UnknownKeyword), null: onNull, array: onArray, tuple: onTuple, @@ -239,6 +247,6 @@ export const zodToTs = ( ) => walkSchema(schema, { rules: { ...brandHandling, ...producers }, - onMissing: () => ensureTypeNode(ts.SyntaxKind.AnyKeyword), + onMissing: ({}, { isResponse }) => getFallback(isResponse), ctx, }); diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 624eadc13..0f5553b6c 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -18,10 +18,10 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` date: any; undefined: undefined; null: null; - void: any; + void: undefined; any: any; - unknown: any; - never: any; + unknown: unknown; + never: never; optionalString?: string | undefined; nullablePartialObject: { string?: string | undefined; @@ -139,7 +139,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > }" `; -exports[`zod-to-ts > PrimitiveSchema > outputs correct typescript 1`] = ` +exports[`zod-to-ts > PrimitiveSchema (isResponse=false) > outputs correct typescript 1`] = ` "{ string: string; number: number; @@ -147,10 +147,25 @@ exports[`zod-to-ts > PrimitiveSchema > outputs correct typescript 1`] = ` date: any; undefined: undefined; null: null; - void: any; + void: undefined; any: any; - unknown: any; - never: any; + unknown: unknown; + never: never; +}" +`; + +exports[`zod-to-ts > PrimitiveSchema (isResponse=true) > outputs correct typescript 1`] = ` +"{ + string: string; + number: number; + boolean: boolean; + date: unknown; + undefined: undefined; + null: null; + void: undefined; + any: any; + unknown: unknown; + never: never; }" `; @@ -185,14 +200,6 @@ exports[`zod-to-ts > z.discriminatedUnion() > outputs correct typescript 1`] = ` }" `; -exports[`zod-to-ts > z.effect() > transformations > should handle an error within the transformation 1`] = `"any"`; - -exports[`zod-to-ts > z.effect() > transformations > should handle unsupported transformation in response 1`] = `"any"`; - -exports[`zod-to-ts > z.effect() > transformations > should produce the schema type 'intact' 1`] = `"number"`; - -exports[`zod-to-ts > z.effect() > transformations > should produce the schema type 'transformed' 1`] = `"string"`; - exports[`zod-to-ts > z.literal() > Should produce the correct typescript 0 1`] = `""test""`; exports[`zod-to-ts > z.literal() > Should produce the correct typescript 1 1`] = `"true"`; @@ -278,3 +285,13 @@ exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to opti ] | undefined; }" `; + +exports[`zod-to-ts > z.pipe() > transformations > should handle an error within the transformation 1`] = `"unknown"`; + +exports[`zod-to-ts > z.pipe() > transformations > should handle preprocess error in request 1`] = `"any"`; + +exports[`zod-to-ts > z.pipe() > transformations > should handle unsupported transformation in response 1`] = `"unknown"`; + +exports[`zod-to-ts > z.pipe() > transformations > should produce the schema type 'intact' 1`] = `"number"`; + +exports[`zod-to-ts > z.pipe() > transformations > should produce the schema type 'transformed' 1`] = `"string"`; diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index d34979d09..2f94f4cd1 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -328,25 +328,28 @@ describe("zod-to-ts", () => { ); }); - describe("PrimitiveSchema", () => { - const primitiveSchema = z.object({ - string: z.string(), - number: z.number(), - boolean: z.boolean(), - date: z.date(), - undefined: z.undefined(), - null: z.null(), - void: z.void(), - any: z.any(), - unknown: z.unknown(), - never: z.never(), - }); - const node = zodToTs(primitiveSchema, { ctx }); + describe.each([true, false])( + "PrimitiveSchema (isResponse=%s)", + (isResponse) => { + const primitiveSchema = z.object({ + string: z.string(), + number: z.number(), + boolean: z.boolean(), + date: z.date(), + undefined: z.undefined(), + null: z.null(), + void: z.void(), + any: z.any(), + unknown: z.unknown(), + never: z.never(), + }); + const node = zodToTs(primitiveSchema, { ctx: { ...ctx, isResponse } }); - test("outputs correct typescript", () => { - expect(printNodeTest(node)).toMatchSnapshot(); - }); - }); + test("outputs correct typescript", () => { + expect(printNodeTest(node)).toMatchSnapshot(); + }); + }, + ); describe("z.discriminatedUnion()", () => { const shapeSchema = z.discriminatedUnion("kind", [ @@ -373,7 +376,7 @@ describe("zod-to-ts", () => { }); }); - describe("z.effect()", () => { + describe("z.pipe()", () => { describe("transformations", () => { test.each([ { isResponse: false, expected: "intact" }, @@ -392,6 +395,13 @@ describe("zod-to-ts", () => { ).toMatchSnapshot(); }); + test("should handle preprocess error in request", () => { + const schema = z.preprocess(() => { + throw new Error("intentional"); + }, z.number()); + expect(printNodeTest(zodToTs(schema, { ctx }))).toMatchSnapshot(); + }); + test("should handle an error within the transformation", () => { const schema = z .number() From a947e57e504cf1180dd786dd94577dfaabb1c674 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 10:34:15 +0200 Subject: [PATCH 179/187] AI: casting fallback types via unknown for clarity. --- express-zod-api/src/endpoints-factory.ts | 2 +- express-zod-api/src/middleware.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index 0cd7654e9..a82854adf 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -118,7 +118,7 @@ export class EndpointsFactory< } public build({ - input = z.object({}) as IOSchema as BIN, // @todo revisit + input = z.object({}) as unknown as BIN, output: outputSchema, operationId, scope, diff --git a/express-zod-api/src/middleware.ts b/express-zod-api/src/middleware.ts index ec0614b45..24b840d7f 100644 --- a/express-zod-api/src/middleware.ts +++ b/express-zod-api/src/middleware.ts @@ -50,7 +50,7 @@ export class Middleware< readonly #handler: Handler, OPT, OUT>; constructor({ - input = z.object({}) as IOSchema as IN, // @todo revisit + input = z.object({}) as unknown as IN, security, handler, }: { From c2d9d03ac825b99c90e64c368d2bc6741349c491 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 10:59:35 +0200 Subject: [PATCH 180/187] Minor: explaining why EmptySchema is ZodRecord, but default assignment is ZodObject. --- express-zod-api/src/common-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 8266442f4..99e4acca9 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -8,6 +8,7 @@ import { AuxMethod, Method } from "./method"; /** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */ export type EmptyObject = z.output; +/** Avoiding z.ZodObject, $strip>, because its z.output<> is generic "object" (external issue) */ export type EmptySchema = z.ZodRecord; export type FlatObject = Record; From 1e59081a7478405705534d975976e39aaca2ea3e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 12:13:41 +0200 Subject: [PATCH 181/187] ESLint: ensure consistent imports. --- eslint.config.js | 8 ++++++++ tools/headers.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 4f2374809..eb31f11fb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,14 @@ const importConcerns = [ "ImportDeclaration[source.value='ramda'] > ImportDefaultSpecifier", message: "use import * as R from 'ramda'", }, + { + selector: "ImportDeclaration[source.value=/^zod/] > ImportDefaultSpecifier", + message: "do import { z } instead", + }, + { + selector: "ImportDeclaration[source.value='zod'] > ImportSpecifier", + message: "should import from zod/v4", // @todo remove when zod version changed to 4.0.0 + }, ...builtinModules.map((mod) => ({ selector: `ImportDeclaration[source.value='${mod}']`, message: `use node:${mod} for the built-in module`, diff --git a/tools/headers.ts b/tools/headers.ts index d6a7616ea..94ff77202 100644 --- a/tools/headers.ts +++ b/tools/headers.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import { writeFile } from "node:fs/promises"; import * as R from "ramda"; -import { z } from "zod"; +import { z } from "zod/v4"; /** * @link https://chatgpt.com/c/6795dae3-8a10-800e-96af-fd0d01579f39 From 959db2e9107ed3e1ea59e0aa4ceb55609f538864 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 12:24:54 +0200 Subject: [PATCH 182/187] Changelog: reflecting changes to Integration. --- CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 689802811..a9272e21b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,19 +21,23 @@ - In order to specify an example for an input schema the `.example()` method must be called before `.transform()`; - The transforming proprietary schemas `ez.dateIn()` and `ez.dateOut()` now accept metadata as its argument: - This allows to set examples before transformation (`ez.dateIn()`) and to avoid the examples "branding"; -- Generating Documentation is mostly delegated to Zod 4 `z.toJSONSchema()`: - - The basic depiction of each schema is now natively performed by Zod 4; +- Changes to `Documentation`: + - Generating Documentation is mostly delegated to Zod 4 `z.toJSONSchema()`; - 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; - The `Depicter` type signature changed: became a postprocessing function returning an overridden JSON Schema; -- The `optionalPropStyle` option removed from `Integration` class constructor: +- Changes to `Integration`: + - The `optionalPropStyle` option removed from `Integration` class constructor: - Use `.optional()` to add question mark to the object property as well as `undefined` to its type; - 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. + - `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality; + - Added types generation for `z.never()`, `z.void()` and `z.unknown()` schemas; + - The fallback type for unsupported schemas and unclear transformations in response changed from `any` to `unknown`; - The argument of `ResultHandler::handler` is now discriminated: either `output` or `error` is null, not both; - The `getExamples()` public helper removed — use `.meta()?.examples` instead; -- The `ez.file()` schema removed: use `z.string()`, `z.base64()` or the new `ez.buffer()` instead; +- Added the new proprietary schema `ez.buffer()`; +- The `ez.file()` schema removed: use `z.string()`, `z.base64()`, `ez.buffer()` or their union; - Consider the automated migration using the built-in ESLint rule. ```js From 9ad966398480d48a136655e8ca6b8b99e40da6d6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 12:26:32 +0200 Subject: [PATCH 183/187] Changelog: minor, formatting. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9272e21b..97975c448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ - `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality; - Added types generation for `z.never()`, `z.void()` and `z.unknown()` schemas; - The fallback type for unsupported schemas and unclear transformations in response changed from `any` to `unknown`; -- The argument of `ResultHandler::handler` is now discriminated: either `output` or `error` is null, not both; +- The argument of `ResultHandler::handler` is now discriminated: either `output` or `error` is `null`, not both; - The `getExamples()` public helper removed — use `.meta()?.examples` instead; - Added the new proprietary schema `ez.buffer()`; - The `ez.file()` schema removed: use `z.string()`, `z.base64()`, `ez.buffer()` or their union; From a867eefafade34edc0bdff73e2660d6134233334 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 12:32:20 +0200 Subject: [PATCH 184/187] AI: Changelog: wrapping bare links. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97975c448..6ffbc5de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Switched to Zod 4: - Minimum supported version of `zod` is 3.25.1, BUT imports MUST be from `zod/v4`; - - Explanation of the versioning strategy: https://github.com/colinhacks/zod/issues/4371; + - Read the [Explanation of the versioning strategy](https://github.com/colinhacks/zod/issues/4371); - Express Zod API, however, is not aiming to support both Zod 3 and Zod 4 simultaneously due to: - incompatibility of data structures; - operating composite schemas (need to avoid mixing schemas of different versions); @@ -30,8 +30,8 @@ - The `optionalPropStyle` option removed from `Integration` class constructor: - Use `.optional()` to add question mark to the object property as well as `undefined` to its type; - 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; + - See the [reasoning](https://x.com/colinhacks/status/1919292504861491252); + - `z.any()` and `z.unknown()` are required: [details](https://v4.zod.dev/v4/changelog#changes-zunknown-optionality); - Added types generation for `z.never()`, `z.void()` and `z.unknown()` schemas; - The fallback type for unsupported schemas and unclear transformations in response changed from `any` to `unknown`; - The argument of `ResultHandler::handler` is now discriminated: either `output` or `error` is `null`, not both; From c1d875b465203fb8685c70aae7f47f59873d523b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 10:33:20 +0000 Subject: [PATCH 185/187] v24.0.0-beta.9 --- express-zod-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index ef9f0f5eb..2c778fd9a 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "24.0.0-beta.8", + "version": "24.0.0-beta.9", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From e976eb574543c169cc0d9eb2c2bd3f863f3b1956 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 28 May 2025 14:22:49 +0200 Subject: [PATCH 186/187] Updating snapshots. --- example/example.documentation.yaml | 4 ---- .../tests/__snapshots__/documentation.spec.ts.snap | 9 ++++----- express-zod-api/tests/__snapshots__/env.spec.ts.snap | 8 ++++++-- express-zod-api/tests/documentation.spec.ts | 2 +- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 47983d1c3..f98fb7537 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -18,7 +18,6 @@ paths: schema: type: string description: a numeric string containing the id of the user - format: regex pattern: \d+ responses: "200": @@ -94,7 +93,6 @@ paths: schema: type: string description: numeric string - format: regex pattern: \d+ responses: "204": @@ -411,7 +409,6 @@ paths: description: GET /v1/avatar/send Parameter schema: type: string - format: regex pattern: \d+ responses: "200": @@ -440,7 +437,6 @@ paths: description: GET /v1/avatar/stream Parameter schema: type: string - format: regex pattern: \d+ responses: "200": diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index abdfad8e4..077586750 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1307,8 +1307,8 @@ paths: based: type: string format: base64 - pattern: ^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$ contentEncoding: base64 + pattern: ^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$ required: - "null" - dateOut @@ -1720,14 +1720,14 @@ paths: format: uri numeric: type: string - format: regex pattern: \\d+ combined: type: string minLength: 1 maxLength: 90 - format: regex - pattern: .*@example\\.com + allOf: + - pattern: ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$ + - pattern: .*@example\\.com required: - regular - min @@ -1949,7 +1949,6 @@ paths: type: object propertyNames: type: string - format: regex pattern: "[A-Z]+" additionalProperties: type: boolean diff --git a/express-zod-api/tests/__snapshots__/env.spec.ts.snap b/express-zod-api/tests/__snapshots__/env.spec.ts.snap index 294573a2e..bbee5151d 100644 --- a/express-zod-api/tests/__snapshots__/env.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/env.spec.ts.snap @@ -4,7 +4,9 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodEmai { "bag": { "format": "email", - "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + "patterns": Set { + /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + }, }, "check": [Function], "constr": [Function], @@ -183,7 +185,9 @@ exports[`Environment checks > Zod checks/refinements > Snapshot control 'ZodStri { "bag": { "format": "email", - "pattern": /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + "patterns": Set { + /\\^\\(\\?!\\\\\\.\\)\\(\\?!\\.\\*\\\\\\.\\\\\\.\\)\\(\\[A-Za-z0-9_'\\+\\\\-\\\\\\.\\]\\*\\)\\[A-Za-z0-9_\\+-\\]@\\(\\[A-Za-z0-9\\]\\[A-Za-z0-9\\\\-\\]\\*\\\\\\.\\)\\+\\[A-Za-z\\]\\{2,\\}\\$/, + }, }, "constr": [Function], "def": { diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 5c6ef89af..c40b06bfa 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1,5 +1,4 @@ import camelize from "camelize-ts"; -import type { Method } from "../src/method"; import snakify from "snakify-ts"; import { Documentation, @@ -11,6 +10,7 @@ import { ez, ResultHandler, Depicter, + Method, } from "../src"; import { contentTypes } from "../src/content-type"; import { z } from "zod/v4"; From 0f771ebc9e151d929478bf47aed6dd2236400e1f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 28 May 2025 14:56:38 +0200 Subject: [PATCH 187/187] CI: rm temporary trigger, no more PRs to this branch --- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/node.js.yml | 4 ++-- .github/workflows/validations.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c580fbdf2..6fda500b1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v20, v21, v22, v23, make-v24 ] + branches: [ master, v20, v21, v22, v23 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v20, v21, v22, v23, make-v24 ] + branches: [ master, v20, v21, v22, v23 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2ea011a1e..4e98de4d5 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,9 +5,9 @@ name: Node.js CI on: push: - branches: [ master, v20, v21, v22, v23, make-v24 ] + branches: [ master, v20, v21, v22, v23 ] pull_request: - branches: [ master, v20, v21, v22, v23, make-v24 ] + branches: [ master, v20, v21, v22, v23 ] jobs: build: diff --git a/.github/workflows/validations.yml b/.github/workflows/validations.yml index 1a181c84b..ca5b5591f 100644 --- a/.github/workflows/validations.yml +++ b/.github/workflows/validations.yml @@ -2,9 +2,9 @@ name: Validations on: push: - branches: [ master, v20, v21, v22, v23, make-v24 ] + branches: [ master, v20, v21, v22, v23 ] pull_request: - branches: [ master, v20, v21, v22, v23, make-v24 ] + branches: [ master, v20, v21, v22, v23 ] jobs: