diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe1cea82..0574bd19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Changed the `ServerConfig` option `server.upload.beforeUpload`: - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; - Restricting the upload can be achieved now by throwing an error from within. + - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`. - Features: - Selective parsers equipped with a child logger: - There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others; @@ -28,6 +29,8 @@ - Avoid mutating the readonly arrays; - If you're using ~~`withMeta()`~~: - Remove it and unwrap your schemas — you can use `.example()` method directly. + - If you're using `ez.raw().extend()` for additional properties: + - Supply them directly as an argument to `ez.raw()` — see the example below. - If you're using `beforeUpload` in your config: - Adjust the implementation according to the example below. @@ -63,6 +66,19 @@ const after = createConfig({ }); ``` +```ts +import { z } from "zod"; +import { ez } from "express-zod-api"; + +const before = ez.raw().extend({ + pathParameter: z.string(), +}); + +const after = ez.raw({ + pathParameter: z.string(), +}); +``` + ## Version 18 ### v18.5.2 diff --git a/README.md b/README.md index 1caa05d2e..fd11de7e6 100644 --- a/README.md +++ b/README.md @@ -1001,7 +1001,7 @@ Some APIs may require an endpoint to be able to accept and process raw data, suc file as an entire body of request. In order to enable this feature you need to set the `rawParser` config feature to `express.raw()`. See also its options [in Express.js documentation](https://expressjs.com/en/4x/api.html#express.raw). The raw data is placed into `request.body.raw` property, having type `Buffer`. Then use the proprietary `ez.raw()` -schema (which is an alias for `z.object({ raw: ez.file("buffer") })`) as the input schema of your endpoint. +schema as the input schema of your endpoint. ```typescript import express from "express"; @@ -1015,9 +1015,9 @@ const config = createConfig({ const rawAcceptingEndpoint = defaultEndpointsFactory.build({ method: "post", - input: ez - .raw() // accepts the featured { raw: Buffer } - .extend({}), // for additional inputs, like route params, if needed + input: ez.raw({ + /* the place for additional inputs, like route params, if needed */ + }), output: z.object({ length: z.number().int().nonnegative() }), handler: async ({ input: { raw } }) => ({ length: raw.length, // raw is Buffer diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index a6eb3faa7..b97dc4b32 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -5,9 +5,10 @@ import { taggedEndpointsFactory } from "../factories"; export const rawAcceptingEndpoint = taggedEndpointsFactory.build({ method: "post", tag: "files", - input: ez - .raw() // requires to enable rawParser option in server config - .extend({}), // additional inputs, route params for example, if needed + // requires to enable rawParser option in server config: + input: ez.raw({ + /* the place for additional inputs, like route params, if needed */ + }), output: z.object({ length: z.number().int().nonnegative() }), handler: async ({ input: { raw } }) => ({ length: raw.length, // input.raw is populated automatically when rawParser is set in config diff --git a/package.json b/package.json index d278c0d6a..c0a07f9e8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "install_hooks": "husky" }, "type": "module", + "sideEffects": true, "main": "dist/index.cjs", "types": "dist/index.d.ts", "module": "dist/index.js", diff --git a/src/common-helpers.ts b/src/common-helpers.ts index 005512b7b..51d2e3b53 100644 --- a/src/common-helpers.ts +++ b/src/common-helpers.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { InputValidationError, OutputValidationError } from "./errors"; import { AbstractLogger } from "./logger"; -import { getMeta } from "./metadata"; +import { metaSymbol } from "./metadata"; import { AuxMethod, Method } from "./method"; import { contentTypes } from "./content-type"; @@ -130,7 +130,7 @@ export const getExamples = < * */ validate?: boolean; }): ReadonlyArray : z.input> => { - const examples = getMeta(schema, "examples") || []; + const examples = schema._def[metaSymbol]?.examples || []; if (!validate && variant === "original") { return examples; } diff --git a/src/config-type.ts b/src/config-type.ts index ac95987b0..90e00aaf1 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -146,6 +146,7 @@ export interface ServerConfig * @desc When enabled, use ez.raw() as input schema to get input.raw in Endpoint's handler * @default undefined * @example express.raw() + * @todo this can be now automatic * @link https://expressjs.com/en/4x/api.html#express.raw * */ rawParser?: RequestHandler; diff --git a/src/date-in-schema.ts b/src/date-in-schema.ts index a2737e4bf..68e3c34e4 100644 --- a/src/date-in-schema.ts +++ b/src/date-in-schema.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; import { isValidDate } from "./schema-helpers"; -export const ezDateInKind = "DateIn"; +export const ezDateInBrand = Symbol("DateIn"); export const dateIn = () => { const schema = z.union([ @@ -11,8 +10,10 @@ export const dateIn = () => { z.string().datetime({ local: true }), ]); - return proprietary( - ezDateInKind, - schema.transform((str) => new Date(str)).pipe(z.date().refine(isValidDate)), - ); + return schema + .transform((str) => new Date(str)) + .pipe(z.date().refine(isValidDate)) + .brand(ezDateInBrand); }; + +export type DateInSchema = ReturnType; diff --git a/src/date-out-schema.ts b/src/date-out-schema.ts index 6b832aab4..466e88a77 100644 --- a/src/date-out-schema.ts +++ b/src/date-out-schema.ts @@ -1,14 +1,13 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; import { isValidDate } from "./schema-helpers"; -export const ezDateOutKind = "DateOut"; +export const ezDateOutBrand = Symbol("DateOut"); export const dateOut = () => - proprietary( - ezDateOutKind, - z - .date() - .refine(isValidDate) - .transform((date) => date.toISOString()), - ); + z + .date() + .refine(isValidDate) + .transform((date) => date.toISOString()) + .brand(ezDateOutBrand); + +export type DateOutSchema = ReturnType; diff --git a/src/deep-checks.ts b/src/deep-checks.ts index 4f97f572a..9438313ab 100644 --- a/src/deep-checks.ts +++ b/src/deep-checks.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { IOSchema } from "./io-schema"; -import { isProprietary } from "./metadata"; -import { ezRawKind } from "./raw-schema"; +import { metaSymbol } from "./metadata"; +import { ezRawBrand } from "./raw-schema"; import { HandlingRules, SchemaHandler } from "./schema-walker"; -import { ezUploadKind } from "./upload-schema"; +import { ezUploadBrand } from "./upload-schema"; /** @desc Check is a schema handling rule returning boolean */ type Check = SchemaHandler; @@ -95,12 +95,12 @@ export const hasTransformationOnTop = (subject: IOSchema): boolean => export const hasUpload = (subject: IOSchema) => hasNestedSchema({ subject, - condition: (schema) => isProprietary(schema, ezUploadKind), + condition: (schema) => schema._def[metaSymbol]?.brand === ezUploadBrand, }); export const hasRaw = (subject: IOSchema) => hasNestedSchema({ subject, - condition: (schema) => isProprietary(schema, ezRawKind), + condition: (schema) => schema._def[metaSymbol]?.brand === ezRawBrand, maxDepth: 3, }); diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 4c7a2a73d..d939948e0 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -52,19 +52,19 @@ import { ucFirst, } from "./common-helpers"; import { InputSource, TagsConfig } from "./config-type"; -import { ezDateInKind } from "./date-in-schema"; -import { ezDateOutKind } from "./date-out-schema"; +import { DateInSchema, ezDateInBrand } from "./date-in-schema"; +import { DateOutSchema, ezDateOutBrand } from "./date-out-schema"; import { DocumentationError } from "./errors"; -import { ezFileKind } from "./file-schema"; +import { FileSchema, ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; import { LogicalContainer, andToOr, mapLogicalContainer, } from "./logical-container"; -import { getMeta } from "./metadata"; +import { metaSymbol } from "./metadata"; import { Method } from "./method"; -import { RawSchema, ezRawKind } from "./raw-schema"; +import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, HandlingVariant, @@ -72,7 +72,7 @@ import { walkSchema, } from "./schema-walker"; import { Security } from "./security"; -import { ezUploadKind } from "./upload-schema"; +import { UploadSchema, ezUploadBrand } from "./upload-schema"; /* eslint-disable @typescript-eslint/no-use-before-define */ @@ -132,7 +132,7 @@ export const depictDefault: Depicter> = ({ next, }) => ({ ...next(schema._def.innerType), - default: getMeta(schema, "defaultLabel") || schema._def.defaultValue(), + default: schema._def[metaSymbol]?.defaultLabel || schema._def.defaultValue(), }); export const depictCatch: Depicter> = ({ @@ -146,7 +146,7 @@ export const depictAny: Depicter = () => ({ format: "any", }); -export const depictUpload: Depicter = (ctx) => { +export const depictUpload: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -160,15 +160,18 @@ export const depictUpload: Depicter = (ctx) => { }; }; -export const depictFile: Depicter = ({ schema }) => ({ - type: "string", - format: - schema instanceof z.ZodString - ? schema._def.checks.find((check) => check.kind === "base64") - ? "byte" - : "file" - : "binary", -}); +export const depictFile: Depicter = ({ schema }) => { + const subject = schema.unwrap(); + return { + type: "string", + format: + subject instanceof z.ZodString + ? subject._def.checks.find((check) => check.kind === "base64") + ? "byte" + : "file" + : "binary", + }; +}; export const depictUnion: Depicter> = ({ schema: { options }, @@ -317,7 +320,7 @@ export const depictObject: Depicter> = ({ * */ export const depictNull: Depicter = () => ({ type: "null" }); -export const depictDateIn: Depicter = (ctx) => { +export const depictDateIn: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -336,7 +339,7 @@ export const depictDateIn: Depicter = (ctx) => { }; }; -export const depictDateOut: Depicter = (ctx) => { +export const depictDateOut: Depicter = (ctx) => { assert( ctx.isResponse, new DocumentationError({ @@ -628,7 +631,7 @@ export const depictLazy: Depicter> = ({ }; export const depictRaw: Depicter = ({ next, schema }) => - next(schema.shape.raw); + next(schema.unwrap().shape.raw); const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => examples.length @@ -669,6 +672,9 @@ export const extractObjectSchema = ( if (subject instanceof z.ZodObject) { return subject; } + if (subject instanceof z.ZodBranded) { + return extractObjectSchema(subject.unwrap(), tfError); + } if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion @@ -779,11 +785,11 @@ export const depicters: HandlingRules< ZodPipeline: depictPipeline, ZodLazy: depictLazy, ZodReadonly: depictReadonly, - [ezFileKind]: depictFile, - [ezUploadKind]: depictUpload, - [ezDateOutKind]: depictDateOut, - [ezDateInKind]: depictDateIn, - [ezRawKind]: depictRaw, + [ezFileBrand]: depictFile, + [ezUploadBrand]: depictUpload, + [ezDateOutBrand]: depictDateOut, + [ezDateInBrand]: depictDateIn, + [ezRawBrand]: depictRaw, }; export const onEach: Depicter = ({ diff --git a/src/file-schema.ts b/src/file-schema.ts index d79d88dfe..d37515654 100644 --- a/src/file-schema.ts +++ b/src/file-schema.ts @@ -1,17 +1,16 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; -export const ezFileKind = "File"; +export const ezFileBrand = Symbol("File"); const bufferSchema = z.custom((subject) => Buffer.isBuffer(subject), { message: "Expected Buffer", }); const variants = { - buffer: () => proprietary(ezFileKind, bufferSchema), - string: () => proprietary(ezFileKind, z.string()), - binary: () => proprietary(ezFileKind, bufferSchema.or(z.string())), - base64: () => proprietary(ezFileKind, z.string().base64()), + buffer: () => bufferSchema.brand(ezFileBrand), + string: () => z.string().brand(ezFileBrand), + binary: () => bufferSchema.or(z.string()).brand(ezFileBrand), + base64: () => z.string().base64().brand(ezFileBrand), }; type Variants = typeof variants; @@ -22,3 +21,5 @@ export function file(variant: K): ReturnType; export function file(variant?: K) { return variants[variant || "string"](); } + +export type FileSchema = ReturnType; diff --git a/src/index.ts b/src/index.ts index 950a1b923..d2722aefc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import "./zod-plugin"; + export { createConfig } from "./config-type"; export { AbstractEndpoint } from "./endpoint"; export { diff --git a/src/io-schema.ts b/src/io-schema.ts index cf993fc5c..6199700ea 100644 --- a/src/io-schema.ts +++ b/src/io-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { copyMeta } from "./metadata"; import { AnyMiddlewareDef } from "./middleware"; +import { RawSchema } from "./raw-schema"; type Refined = T extends z.ZodType ? z.ZodEffects, O, O> : never; @@ -14,7 +15,8 @@ export type IOSchema = | z.ZodUnion<[IOSchema, ...IOSchema[]]> | z.ZodIntersection, IOSchema> | z.ZodDiscriminatedUnion[]> - | Refined>; + | Refined> + | RawSchema; export type ProbableIntersection< A extends IOSchema<"strip"> | null, diff --git a/src/metadata.ts b/src/metadata.ts index e83000022..b971e53c2 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,95 +1,29 @@ -import { combinations, isObject } from "./common-helpers"; +import { combinations } from "./common-helpers"; import { z } from "zod"; import { clone, mergeDeepRight } from "ramda"; -import { ProprietaryKind } from "./proprietary-schemas"; export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { - kind?: ProprietaryKind; examples: z.input[]; /** @override ZodDefault::_def.defaultValue() in depictDefault */ defaultLabel?: string; -} - -declare module "zod" { - interface ZodTypeDef { - [metaSymbol]?: Metadata; - } - interface ZodType { - /** @desc Add an example value (before any transformations, can be called multiple times) */ - example(example: this["_input"]): this; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface ZodDefault { - /** @desc Change the default value in the generated Documentation to a label */ - label(label: string): this; - } + brand?: string | number | symbol; } /** @link https://github.com/colinhacks/zod/blob/3e4f71e857e75da722bd7e735b6d657a70682df2/src/types.ts#L485 */ -const cloneSchema = (schema: T) => { +export const cloneSchema = (schema: T) => { const copy = schema.describe(schema.description as string); copy._def[metaSymbol] = // clone for deep copy, issue #827 clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata); return copy; }; -const exampleSetter = function ( - this: z.ZodType, - value: (typeof this)["_input"], -) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.examples.push(value); - return copy; -}; - -const defaultLabeler = function ( - this: z.ZodDefault, - label: string, -) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.defaultLabel = label; - return copy; -}; - -/** @see https://github.com/colinhacks/zod/blob/90efe7fa6135119224412c7081bd12ef0bccef26/plugin/effect/src/index.ts#L21-L31 */ -if (!(metaSymbol in globalThis)) { - (globalThis as Record)[metaSymbol] = true; - Object.defineProperty( - z.ZodType.prototype, - "example" satisfies keyof z.ZodType, - { - get(): z.ZodType["example"] { - return exampleSetter.bind(this); - }, - }, - ); - Object.defineProperty( - z.ZodDefault.prototype, - "label" satisfies keyof z.ZodDefault, - { - get(): z.ZodDefault["label"] { - return defaultLabeler.bind(this); - }, - }, - ); -} - -export const hasMeta = (schema: T) => - metaSymbol in schema._def && isObject(schema._def[metaSymbol]); - -export const getMeta = >( - schema: T, - meta: K, -): Readonly[K]> | undefined => - hasMeta(schema) ? schema._def[metaSymbol][meta] : undefined; - export const copyMeta = ( src: A, dest: B, ): B => { - if (!hasMeta(src)) { + if (!(metaSymbol in src._def)) { return dest; } const result = cloneSchema(dest); @@ -103,15 +37,3 @@ export const copyMeta = ( ); return result; }; - -export const proprietary = ( - kind: ProprietaryKind, - subject: T, -) => { - const schema = cloneSchema(subject); - schema._def[metaSymbol].kind = kind; - return schema; -}; - -export const isProprietary = (schema: z.ZodTypeAny, kind: ProprietaryKind) => - getMeta(schema, "kind") === kind; diff --git a/src/proprietary-schemas.ts b/src/proprietary-schemas.ts index 6ea9f2256..5d151eb8e 100644 --- a/src/proprietary-schemas.ts +++ b/src/proprietary-schemas.ts @@ -1,14 +1,14 @@ -import { dateIn, ezDateInKind } from "./date-in-schema"; -import { dateOut, ezDateOutKind } from "./date-out-schema"; -import { ezFileKind, file } from "./file-schema"; -import { ezRawKind, raw } from "./raw-schema"; -import { ezUploadKind, upload } from "./upload-schema"; +import { dateIn, ezDateInBrand } from "./date-in-schema"; +import { dateOut, ezDateOutBrand } from "./date-out-schema"; +import { ezFileBrand, file } from "./file-schema"; +import { ezRawBrand, raw } from "./raw-schema"; +import { ezUploadBrand, upload } from "./upload-schema"; export const ez = { dateIn, dateOut, file, upload, raw }; -export type ProprietaryKind = - | typeof ezFileKind - | typeof ezDateInKind - | typeof ezDateOutKind - | typeof ezUploadKind - | typeof ezRawKind; +export type ProprietaryBrand = + | typeof ezFileBrand + | typeof ezDateInBrand + | typeof ezDateOutBrand + | typeof ezUploadBrand + | typeof ezRawBrand; diff --git a/src/raw-schema.ts b/src/raw-schema.ts index 4ad7e5c63..9575e086a 100644 --- a/src/raw-schema.ts +++ b/src/raw-schema.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { file } from "./file-schema"; -import { proprietary } from "./metadata"; -export const ezRawKind = "Raw"; +export const ezRawBrand = Symbol("Raw"); /** Shorthand for z.object({ raw: ez.file("buffer") }) */ -export const raw = () => - proprietary(ezRawKind, z.object({ raw: file("buffer") })); +export const raw = (extra: S = {} as S) => + z + .object({ raw: file("buffer") }) + .extend(extra) + .brand(ezRawBrand); export type RawSchema = ReturnType; diff --git a/src/schema-walker.ts b/src/schema-walker.ts index 3fc1c9481..3156eb7e7 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { FlatObject } from "./common-helpers"; -import { getMeta } from "./metadata"; -import { ProprietaryKind } from "./proprietary-schemas"; +import { metaSymbol } from "./metadata"; +import { ProprietaryBrand } from "./proprietary-schemas"; interface VariantDependingProps { regular: { next: (schema: z.ZodTypeAny) => U }; @@ -30,7 +30,7 @@ export type SchemaHandler< export type HandlingRules = Partial< Record< - z.ZodFirstPartyTypeKind | ProprietaryKind, + z.ZodFirstPartyTypeKind | ProprietaryBrand, SchemaHandler // keeping "any" here in order to avoid excessive complexity > >; @@ -47,8 +47,9 @@ export const walkSchema = ({ rules: HandlingRules; onMissing: SchemaHandler; }): U => { - const kind = getMeta(schema, "kind") || schema._def.typeName; - const handler = kind ? rules[kind as keyof typeof rules] : undefined; + const handler = + rules[schema._def[metaSymbol]?.brand as keyof typeof rules] || + rules[schema._def.typeName as keyof typeof rules]; const ctx = rest as unknown as Context; const next = (subject: z.ZodTypeAny) => walkSchema({ schema: subject, ...ctx, onEach, rules, onMissing }); diff --git a/src/upload-schema.ts b/src/upload-schema.ts index c88456c03..8a58fbf52 100644 --- a/src/upload-schema.ts +++ b/src/upload-schema.ts @@ -1,13 +1,11 @@ import type { UploadedFile } from "express-fileupload"; import { z } from "zod"; -import { proprietary } from "./metadata"; -export const ezUploadKind = "Upload"; +export const ezUploadBrand = Symbol("Upload"); export const upload = () => - proprietary( - ezUploadKind, - z.custom( + z + .custom( (subject) => typeof subject === "object" && subject !== null && @@ -32,5 +30,7 @@ export const upload = () => (input) => ({ message: `Expected file upload, received ${typeof input}`, }), - ), - ); + ) + .brand(ezUploadBrand); + +export type UploadSchema = ReturnType; diff --git a/src/zod-plugin.ts b/src/zod-plugin.ts new file mode 100644 index 000000000..371838df7 --- /dev/null +++ b/src/zod-plugin.ts @@ -0,0 +1,81 @@ +/** + * @fileoverview Zod Runtime Plugin + * @see https://github.com/colinhacks/zod/blob/90efe7fa6135119224412c7081bd12ef0bccef26/plugin/effect/src/index.ts#L21-L31 + * @desc This code modifies and extends zod's functionality immediately when importing express-zod-api + * @desc Enables .examples() on all schemas (ZodType) + * @desc Enables .label() on ZodDefault + * @desc Stores the argument supplied to .brand() on all schema (runtime distinguishable branded types) + * */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { clone } from "ramda"; +import { z } from "zod"; +import { Metadata, cloneSchema, metaSymbol } from "./metadata"; + +declare module "zod" { + interface ZodTypeDef { + [metaSymbol]?: Metadata; + } + interface ZodType { + /** @desc Add an example value (before any transformations, can be called multiple times) */ + example(example: this["_input"]): this; + } + interface ZodDefault { + /** @desc Change the default value in the generated Documentation to a label */ + label(label: string): this; + } +} + +const exampleSetter = function ( + this: z.ZodType, + value: (typeof this)["_input"], +) { + const copy = cloneSchema(this); + copy._def[metaSymbol]!.examples.push(value); + return copy; +}; + +const labelSetter = function (this: z.ZodDefault, label: string) { + const copy = cloneSchema(this); + copy._def[metaSymbol]!.defaultLabel = label; + return copy; +}; + +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: [], ...clone(this._def[metaSymbol]), brand }, + }); +}; + +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); + }, + }, + ["brand" satisfies keyof z.ZodType]: { + set() {}, // this is required to override the existing method + get() { + return brandSetter.bind(this) as z.ZodType["brand"]; + }, + }, + }); + Object.defineProperty( + z.ZodDefault.prototype, + "label" satisfies keyof z.ZodDefault, + { + get(): z.ZodDefault["label"] { + return labelSetter.bind(this); + }, + }, + ); +} diff --git a/src/zts.ts b/src/zts.ts index c1e8261d6..1d687c083 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -1,10 +1,10 @@ import ts from "typescript"; import { z } from "zod"; import { hasCoercion, tryToTransform } from "./common-helpers"; -import { ezDateInKind } from "./date-in-schema"; -import { ezDateOutKind } from "./date-out-schema"; -import { ezFileKind } from "./file-schema"; -import { RawSchema, ezRawKind } from "./raw-schema"; +import { ezDateInBrand } from "./date-in-schema"; +import { ezDateOutBrand } from "./date-out-schema"; +import { FileSchema, ezFileBrand } from "./file-schema"; +import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, walkSchema } from "./schema-walker"; import { LiteralType, @@ -216,18 +216,20 @@ const onLazy: Producer> = ({ ); }; -const onFile: Producer = ({ schema }) => { +const onFile: Producer = ({ schema }) => { + const subject = schema.unwrap(); const stringType = f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); const bufferType = f.createTypeReferenceNode("Buffer"); const unionType = f.createUnionTypeNode([stringType, bufferType]); - return schema instanceof z.ZodString + return subject instanceof z.ZodString ? stringType - : schema instanceof z.ZodUnion + : subject instanceof z.ZodUnion ? unionType : bufferType; }; -const onRaw: Producer = ({ next, schema }) => next(schema.shape.raw); +const onRaw: Producer = ({ next, schema }) => + next(schema.unwrap().shape.raw); const producers: HandlingRules = { ZodString: onPrimitive(ts.SyntaxKind.StringKeyword), @@ -235,8 +237,8 @@ const producers: HandlingRules = { ZodBigInt: onPrimitive(ts.SyntaxKind.BigIntKeyword), ZodBoolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), ZodAny: onPrimitive(ts.SyntaxKind.AnyKeyword), - [ezDateInKind]: onPrimitive(ts.SyntaxKind.StringKeyword), - [ezDateOutKind]: onPrimitive(ts.SyntaxKind.StringKeyword), + [ezDateInBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), + [ezDateOutBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), ZodNull: onNull, ZodArray: onArray, ZodTuple: onTuple, @@ -257,8 +259,8 @@ const producers: HandlingRules = { ZodPipeline: onPipeline, ZodLazy: onLazy, ZodReadonly: onReadonly, - [ezFileKind]: onFile, - [ezRawKind]: onRaw, + [ezFileBrand]: onFile, + [ezRawBrand]: onRaw, }; export const zodToTs = ({ diff --git a/tests/helpers.ts b/tests/helpers.ts index c985fedc2..56e331544 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,5 +1,6 @@ import { map } from "ramda"; import { z } from "zod"; +import { ezFileBrand } from "../src/file-schema"; import { SchemaHandler, walkSchema } from "../src/schema-walker"; let lastGivenPort = 8010; @@ -82,6 +83,7 @@ export const serializeSchemaForTest = ( from: next(schema._def.in), to: next(schema._def.out), }), + [ezFileBrand]: () => ({ brand: ezFileBrand }), }, onEach: ({ schema }) => ({ _type: schema._def.typeName }), onMissing: ({ schema }) => { diff --git a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap index 07d2ba2f9..1531efb9c 100644 --- a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap @@ -1441,3 +1441,15 @@ exports[`Documentation helpers > extractObjectSchema() > should return object sc }, } `; + +exports[`Documentation helpers > extractObjectSchema() > should support ez.raw() 1`] = ` +{ + "_type": "ZodObject", + "shape": { + "raw": { + "_type": "ZodBranded", + "brand": Symbol(File), + }, + }, +} +`; diff --git a/tests/unit/checks.spec.ts b/tests/unit/checks.spec.ts index 7271aa2fe..b91963c1b 100644 --- a/tests/unit/checks.spec.ts +++ b/tests/unit/checks.spec.ts @@ -3,13 +3,13 @@ import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { ez } from "../../src"; import { hasNestedSchema, hasTransformationOnTop } from "../../src/deep-checks"; -import { isProprietary } from "../../src/metadata"; -import { ezUploadKind } from "../../src/upload-schema"; +import { metaSymbol } from "../../src/metadata"; +import { ezUploadBrand } from "../../src/upload-schema"; describe("Checks", () => { describe("hasNestedSchema()", () => { const condition = (subject: z.ZodTypeAny) => - isProprietary(subject, ezUploadKind); + subject._def[metaSymbol]?.brand === ezUploadBrand; test("should return true for given argument satisfying condition", () => { expect(hasNestedSchema({ subject: ez.upload(), condition })).toBeTruthy(); diff --git a/tests/unit/date-in-schema.spec.ts b/tests/unit/date-in-schema.spec.ts index 578f81e5b..4db7ca4b5 100644 --- a/tests/unit/date-in-schema.spec.ts +++ b/tests/unit/date-in-schema.spec.ts @@ -1,14 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateInBrand } from "../../src/date-in-schema"; import { ez } from "../../src"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.dateIn()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateIn(); - expect(schema).toBeInstanceOf(z.ZodPipeline); - expect(getMeta(schema, "kind")).toEqual("DateIn"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toEqual(ezDateInBrand); }); }); diff --git a/tests/unit/date-out-schema.spec.ts b/tests/unit/date-out-schema.spec.ts index 64052b70b..4a679c874 100644 --- a/tests/unit/date-out-schema.spec.ts +++ b/tests/unit/date-out-schema.spec.ts @@ -1,14 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateOutBrand } from "../../src/date-out-schema"; import { ez } from "../../src"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.dateOut()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateOut(); - expect(schema).toBeInstanceOf(z.ZodEffects); - expect(getMeta(schema, "kind")).toEqual("DateOut"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toEqual(ezDateOutBrand); }); }); diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index 6c9dd53de..e3b6c287c 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -152,6 +152,12 @@ describe("Documentation helpers", () => { expect(serializeSchemaForTest(subject)).toMatchSnapshot(); }); + test("should support ez.raw()", () => { + const subject = extractObjectSchema(ez.raw(), tfError); + expect(subject).toBeInstanceOf(z.ZodObject); + expect(serializeSchemaForTest(subject)).toMatchSnapshot(); + }); + describe("Feature #600: Top level refinements", () => { test("should handle refined object schema", () => { const subject = extractObjectSchema( diff --git a/tests/unit/file-schema.spec.ts b/tests/unit/file-schema.spec.ts index 72c4ba036..1ed592503 100644 --- a/tests/unit/file-schema.spec.ts +++ b/tests/unit/file-schema.spec.ts @@ -1,39 +1,40 @@ import { expectType } from "tsd"; import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezFileBrand } from "../../src/file-schema"; import { ez } from "../../src"; import { readFile } from "node:fs/promises"; import { describe, expect, test } from "vitest"; +import { metaSymbol } 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(getMeta(schema, "kind")).toBe("File"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toBe(ezFileBrand); }); test("should create a string file", () => { const schema = ez.file("string"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a buffer file", () => { const schema = ez.file("buffer"); - expect(schema).toBeInstanceOf(z.ZodEffects); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a binary file", () => { const schema = ez.file("binary"); - expect(schema).toBeInstanceOf(z.ZodUnion); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a base64 file", () => { const schema = ez.file("base64"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); }); diff --git a/tests/unit/io-schema.spec.ts b/tests/unit/io-schema.spec.ts index e8d725086..984dab8eb 100644 --- a/tests/unit/io-schema.spec.ts +++ b/tests/unit/io-schema.spec.ts @@ -1,8 +1,8 @@ import { expectNotType, expectType } from "tsd"; import { z } from "zod"; -import { IOSchema, createMiddleware } from "../../src"; +import { IOSchema, createMiddleware, ez } from "../../src"; import { getFinalEndpointInputSchema } from "../../src/io-schema"; -import { getMeta } from "../../src/metadata"; +import { metaSymbol } from "../../src/metadata"; import { AnyMiddlewareDef } from "../../src/middleware"; import { serializeSchemaForTest } from "../helpers"; import { describe, expect, test, vi } from "vitest"; @@ -16,6 +16,10 @@ describe("I/O Schema and related helpers", () => { expectType>(z.object({}).passthrough()); expectType>(z.object({}).strip()); }); + test("accepts ez.raw()", () => { + expectType(ez.raw()); + expectType(ez.raw({ something: z.any() })); + }); test("respects the UnknownKeys type argument", () => { expectNotType>(z.object({})); }); @@ -224,7 +228,7 @@ describe("I/O Schema and related helpers", () => { .object({ five: z.string() }) .example({ five: "some" }); const result = getFinalEndpointInputSchema(middlewares, endpointInput); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]?.examples).toEqual([ { one: "test", two: 123, diff --git a/tests/unit/metadata.spec.ts b/tests/unit/metadata.spec.ts index 8d372bcfd..96af2590f 100644 --- a/tests/unit/metadata.spec.ts +++ b/tests/unit/metadata.spec.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { copyMeta, getMeta, hasMeta, metaSymbol } from "../../src/metadata"; +import { copyMeta, metaSymbol } from "../../src/metadata"; import { describe, expect, test } from "vitest"; describe("Metadata", () => { @@ -62,45 +62,9 @@ describe("Metadata", () => { }); }); - describe("hasMeta()", () => { - test("should return false if the schema definition has no meta prop", () => { - expect(hasMeta(z.string())).toBeFalsy(); - }); - test("should return false if the meta prop has invalid type", () => { - const schema1 = z.string(); - const schema2 = z.string(); - Object.defineProperty(schema1._def, metaSymbol, { value: null }); - expect(hasMeta(schema1)).toBeFalsy(); - Object.defineProperty(schema2._def, metaSymbol, { value: 123 }); - expect(hasMeta(schema2)).toBeFalsy(); - }); - test("should return true if proprietary method has been used", () => { - expect(hasMeta(z.string().example(""))).toBeTruthy(); - }); - }); - - describe("getMeta()", () => { - test("should return undefined on a regular Zod schema or the malformed one", () => { - expect(getMeta(z.string(), "examples")).toBeUndefined(); - }); - test("should return undefined on malformed schema", () => { - const schema1 = z.string(); - const schema2 = z.string(); - Object.defineProperty(schema1._def, metaSymbol, { value: null }); - expect(getMeta(schema1, "examples")).toBeUndefined(); - Object.defineProperty(schema2._def, metaSymbol, { value: 123 }); - expect(getMeta(schema2, "examples")).toBeUndefined(); - }); - test("should return undefined if the value not set", () => { - expect(getMeta(z.string(), "examples")).toBeUndefined(); - }); - test("should return the value that has been set", () => { - expect(getMeta(z.string().example("test"), "examples")).toEqual(["test"]); - }); - test("should return an array of examples", () => { - expect( - getMeta(z.string().example("test1").example("test2"), "examples"), - ).toEqual(["test1", "test2"]); + describe(".brand()", () => { + test("should set the brand", () => { + expect(z.string().brand("test")._def[metaSymbol]?.brand).toEqual("test"); }); }); @@ -110,15 +74,17 @@ describe("Metadata", () => { const dest = z.number(); const result = copyMeta(src, dest); expect(result).toEqual(dest); - expect(hasMeta(result)).toBeFalsy(); - expect(hasMeta(dest)).toBeFalsy(); + expect(result._def[metaSymbol]).toBeFalsy(); + expect(dest._def[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(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual(getMeta(src, "examples")); + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual( + src._def[metaSymbol]?.examples, + ); }); test("should merge the meta from src to dest", () => { @@ -132,8 +98,8 @@ describe("Metadata", () => { .example({ b: 456 }) .example({ b: 789 }); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual([ { a: "some", b: 123 }, { a: "another", b: 123 }, { a: "some", b: 456 }, @@ -154,8 +120,8 @@ describe("Metadata", () => { .example({ a: { c: 456 } }) .example({ a: { c: 789 } }); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual([ { a: { b: "some", c: 123 } }, { a: { b: "another", c: 123 } }, { a: { b: "some", c: 456 } }, diff --git a/tests/unit/upload-schema.spec.ts b/tests/unit/upload-schema.spec.ts index 034ea45f8..8e11a6d2a 100644 --- a/tests/unit/upload-schema.spec.ts +++ b/tests/unit/upload-schema.spec.ts @@ -1,14 +1,15 @@ -import { getMeta } from "../../src/metadata"; import { z } from "zod"; import { ez } from "../../src"; import { describe, expect, test, vi } from "vitest"; +import { metaSymbol } from "../../src/metadata"; +import { ezUploadBrand } from "../../src/upload-schema"; describe("ez.upload()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.upload(); - expect(schema).toBeInstanceOf(z.ZodEffects); - expect(getMeta(schema, "kind")).toBe("Upload"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toBe(ezUploadBrand); }); }); diff --git a/tsconfig.json b/tsconfig.json index d9fdaa0cb..379fd6c5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,5 @@ "moduleResolution": "Bundler", "resolveJsonModule": true }, - "include": ["src", "example", "tests/unit", "tests/system", "tests/bench", "tests/*.ts", "tools", "*.config.ts"] + "include": ["src", "example", "tests/unit", "tests/system", "tests/bench", "tests/*.ts", "tools", "*.config.ts", "*.setup.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index a410d6041..874c2b553 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from "vitest/config"; +const isIntegrationTest = process.argv.includes("-r"); + export default defineConfig({ test: { env: { @@ -12,5 +14,6 @@ export default defineConfig({ reporter: [["text", { maxCols: 120 }], "json-summary", "html", "lcov"], include: ["src/**"], }, + setupFiles: isIntegrationTest ? [] : ["./vitest.setup.ts"], }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..54cc0964b --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,2 @@ +// required to apply the plugin before running the tests, because some tests do not import from entrypoint +import "./src/zod-plugin";