diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d186dc1f..eedaa3584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### v28.0.0 - Supported Node.js versions: `^22.19.0 || ^24.0.0`; +- The Zod plugin is no longer installed automatically — it's an optional peer dependency now: + - To keep using `.example()`, `.label()`, `.remap()`, `.deprecated()` and `.brand()` methods on schemas + install the `@express-zod-api/zod-plugin` manually and import it (ideally at the top of a file declaring `Routing`); - Breaking changes to the `createConfig()` argument (object): - property `wrongMethodBehavior` (number) changed to `hintAllowedMethods` (boolean); - property `methodLikeRouteBehavior` (string literal) changed to `recognizeMethodDependentRoutes` (boolean); diff --git a/README.md b/README.md index dc36ae05f..4f725bddb 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ 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 4.x](https://github.com/colinhacks/zod) including [Zod Plugin](#zod-plugin): +- Schema validation — [Zod 4.x](https://github.com/colinhacks/zod) - 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. @@ -1100,8 +1100,21 @@ expect(output).toEqual({ collectedContext: ["prev"], testLength: 9 }); ## Zod Plugin -Express Zod API augments Zod using [Zod Plugin](https://www.npmjs.com/package/@express-zod-api/zod-plugin), -adding the runtime helpers the framework relies on. +The [@express-zod-api/zod-plugin](https://www.npmjs.com/package/@express-zod-api/zod-plugin) is an optional package +that extends Zod with convenience methods: + +- `.brand(name)` — enhanced with a shorthand for `.meta({ "x-brand": name })`; +- `.example(value)` — shorthand for `.meta({ examples: [value] })`; +- `.deprecated()` — shorthand for `.meta({ deprecated: true })`; +- `.label(text)` — shorthand for `.meta({ default: text })` on `ZodDefault`; +- `.remap(mapping)` — for renaming `ZodObject` shape properties; + +To benefit from these methods, install `@express-zod-api/zod-plugin` and import it once, preferably at the top of a +file declaring your `Routing`. + +```ts +import "@express-zod-api/zod-plugin"; // in your routing.ts file +``` ## End-to-End Type Safety @@ -1166,8 +1179,8 @@ const exampleEndpoint = defaultEndpointsFactory.build({ description: "The detailed explanaition on what this endpoint does.", input: z.object({ id: z - .string() - .example("123") // input examples should be set before transformations + .string() // input examples should be set before transformations + .example("123") // requires Zod Plugin, or .meta({ examples: ["123"] }) .transform(Number) .describe("the ID of the user"), }), @@ -1175,7 +1188,8 @@ const exampleEndpoint = defaultEndpointsFactory.build({ }); ``` -You can also use `schema.meta({ id: "UniqueName" })` for custom schema naming. +Setting examples via `.example()` requires [Zod Plugin](#zod-plugin). You can also use `.meta({ examples: [] })` and +`.meta({ id: "UniqueName" })` for custom schema naming. _See the complete example of the generated documentation [here](https://github.com/RobinTail/express-zod-api/blob/master/example/example.documentation.yaml)_ @@ -1213,9 +1227,9 @@ new Documentation({ ## Deprecated schemas and routes -As your API evolves, you may need to mark some parameters or routes as deprecated before deleting them. For this -purpose, the `.deprecated()` method is available on each schema and `Endpoint`, it's immutable. -You can also deprecate all routes the `Endpoint` assigned to by setting `EndpointsFactory::build({ deprecated: true })`. +As your API evolves, you may need to mark some parameters or routes as deprecated before deleting them. This can be +achieved using the corresponding method or metadata. The `.deprecated()` method on Zod schema requires to install the +[Zod Plugin](#zod-plugin). Consider the following example: ```ts import type { Routing } from "express-zod-api"; @@ -1224,7 +1238,7 @@ import { z } from "zod"; const someEndpoint = factory.build({ deprecated: true, // deprecates all routes the endpoint assigned to input: z.object({ - prop: z.string().deprecated(), // deprecates the property or a path parameter + prop: z.string().deprecated(), // requires Zod Plugin, or .meta({ deprecated: true }) }), }); @@ -1236,8 +1250,9 @@ const routing: Routing = { ## Customizable brands handling -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. +You can customize handling rules for your schemas in Documentation and Integration. The framework treats your schema +specially based on its `x-brand` metadata. When the [Zod Plugin](#zod-plugin) is installed you can conveniently use +the `.brand()` enhanced method of the Zod schema, preferably with a symbol argument for its branding. After that use 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`. @@ -1252,7 +1267,7 @@ import { } from "express-zod-api"; const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose -const myBrandedSchema = z.string().brand(myBrand); +const myBrandedSchema = z.string().brand(myBrand); // requires Zod Plugin, or .meta({ "x-brand": myBrand }) const ruleForDocs: Depicter = ( { zodSchema, jsonSchema }, // jsonSchema is the default depiction diff --git a/cjs-test/zod-plugin.spec.ts b/cjs-test/zod-plugin.spec.ts index c817518dc..493c30e8e 100644 --- a/cjs-test/zod-plugin.spec.ts +++ b/cjs-test/zod-plugin.spec.ts @@ -1,7 +1,7 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -require("express-zod-api"); // side effect here via Zod Plugin +require("@express-zod-api/zod-plugin"); // side effect here const z = require("zod"); // ensure CJS version of Zod is used describe("Zod plugin in CJS environment", () => { diff --git a/compat-test/dts.spec.ts b/compat-test/dts.spec.ts index 6ef40e180..758ed7682 100644 --- a/compat-test/dts.spec.ts +++ b/compat-test/dts.spec.ts @@ -2,14 +2,6 @@ import { describe, test, expect } from "vitest"; import { readFile } from "node:fs/promises"; describe("DTS", () => { - test("Framework must import Zod plugin", async () => { - const fwDts = await readFile( - "./node_modules/express-zod-api/dist/index.d.ts", - "utf-8", - ); - expect(fwDts).toMatch(`import "@express-zod-api/zod-plugin";`); - }); - test("Zod plugin must import augmentation", async () => { const pluginDts = await readFile( "./node_modules/express-zod-api/node_modules/@express-zod-api/zod-plugin/dist/index.d.ts", diff --git a/example/package.json b/example/package.json index 591b8fc7a..3037a30bc 100644 --- a/example/package.json +++ b/example/package.json @@ -12,6 +12,7 @@ "test": "vitest run index.spec.ts" }, "devDependencies": { + "@express-zod-api/zod-plugin": "workspace:*", "@types/http-errors": "catalog:dev", "@types/swagger-ui-express": "^4.1.8", "express-zod-api": "workspace:*", diff --git a/example/routing.ts b/example/routing.ts index 0b9f2088c..20139e457 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -1,3 +1,4 @@ +import "@express-zod-api/zod-plugin"; // adds .example() method import { type Routing, ServeStatic } from "express-zod-api"; import { rawAcceptingEndpoint } from "./endpoints/accept-raw.ts"; import { createUserEndpoint } from "./endpoints/create-user.ts"; diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 08287be80..46d194ff7 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -24,7 +24,6 @@ "prepack": "cp ../*.md ../LICENSE ./" }, "type": "module", - "sideEffects": true, "main": "dist/index.js", "types": "dist/index.d.ts", "module": "dist/index.js", @@ -42,13 +41,13 @@ "node": "^22.19.0 || ^24.0.0" }, "dependencies": { - "@express-zod-api/zod-plugin": "workspace:^", "ansis": "^4.2.0", "node-mocks-http": "^1.17.2", "openapi3-ts": "^4.5.0", "ramda": "catalog:prod" }, "peerDependencies": { + "@express-zod-api/zod-plugin": "workspace:^", "@types/compression": "^1.7.5", "@types/express": "^5.0.0", "@types/express-fileupload": "^1.5.0", @@ -61,6 +60,9 @@ "zod": "catalog:peer" }, "peerDependenciesMeta": { + "@express-zod-api/zod-plugin": { + "optional": true + }, "@types/compression": { "optional": true }, @@ -84,6 +86,7 @@ } }, "devDependencies": { + "@express-zod-api/zod-plugin": "workspace:^", "@types/compression": "catalog:dev", "@types/cors": "^2.8.19", "@types/depd": "^1.1.37", diff --git a/express-zod-api/src/buffer-schema.ts b/express-zod-api/src/buffer-schema.ts index 6fc7d8c99..18f6d5237 100644 --- a/express-zod-api/src/buffer-schema.ts +++ b/express-zod-api/src/buffer-schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { brandProperty } from "./metadata"; export const ezBufferBrand = Symbol("Buffer"); @@ -7,4 +8,4 @@ export const buffer = () => .custom((subject) => Buffer.isBuffer(subject), { error: "Expected Buffer", }) - .brand(ezBufferBrand as symbol); + .meta({ [brandProperty]: ezBufferBrand }); diff --git a/express-zod-api/src/date-in-schema.ts b/express-zod-api/src/date-in-schema.ts index cc7d4fd33..7ee70b963 100644 --- a/express-zod-api/src/date-in-schema.ts +++ b/express-zod-api/src/date-in-schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { brandProperty } from "./metadata"; export const ezDateInBrand = Symbol("DateIn"); @@ -20,6 +21,5 @@ export const dateIn = ({ examples, ...rest }: DateInParams = {}) => { .meta({ examples }) .transform((str) => new Date(str)) .pipe(z.date()) - .brand(ezDateInBrand as symbol) - .meta(rest); + .meta({ ...rest, [brandProperty]: ezDateInBrand }); }; diff --git a/express-zod-api/src/date-out-schema.ts b/express-zod-api/src/date-out-schema.ts index 2c5c7ca8a..2731ab28f 100644 --- a/express-zod-api/src/date-out-schema.ts +++ b/express-zod-api/src/date-out-schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { brandProperty } from "./metadata"; export const ezDateOutBrand = Symbol("DateOut"); @@ -13,5 +14,4 @@ export const dateOut = (meta: DateOutParams = {}) => z .date() .transform((date) => date.toISOString()) - .brand(ezDateOutBrand as symbol) - .meta(meta); + .meta({ ...meta, [brandProperty]: ezDateOutBrand }); diff --git a/express-zod-api/src/deep-checks.ts b/express-zod-api/src/deep-checks.ts index c2e7abfd2..5248f5048 100644 --- a/express-zod-api/src/deep-checks.ts +++ b/express-zod-api/src/deep-checks.ts @@ -6,7 +6,7 @@ import { ezDateOutBrand } from "./date-out-schema"; import { DeepCheckError } from "./errors"; import { ezFormBrand } from "./form-schema"; import type { IOSchema } from "./io-schema"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "./metadata"; import type { FirstPartyKind } from "./schema-walker"; import { ezUploadBrand } from "./upload-schema"; import { ezRawBrand } from "./raw-schema"; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index b255a5ba7..6526dc2f4 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -38,7 +38,7 @@ import { DocumentationError } from "./errors"; import type { IOSchema } from "./io-schema"; import { flattenIO } from "./json-schema-helpers"; import type { Alternatives } from "./logical-container"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "./metadata"; import type { ClientMethod } from "./method"; import type { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand } from "./raw-schema"; diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index c7313b6c0..a1b916eec 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -21,7 +21,7 @@ import type { IOSchema } from "./io-schema"; import { lastResortHandler } from "./last-resort"; import type { ActualLogger } from "./logger-helpers"; import type { LogicalContainer } from "./logical-container"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand, getExamples } from "./metadata"; import type { ClientMethod, CORSMethod, Method, SomeMethod } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import type { ContentType } from "./content-type"; @@ -92,9 +92,9 @@ export class Endpoint< > extends AbstractEndpoint { readonly #def: ConstructorParameters>[0]; - /** considered expensive operation, only required for generators */ + /** considered an expensive operation, only required for generators */ #ensureOutputExamples = R.once(() => { - if (globalRegistry.get(this.#def.outputSchema)?.examples?.length) return; // examples on output schema, or pull up: + if (getExamples(this.#def.outputSchema).length) return; // examples on output schema, or pull up: if (!isSchema(this.#def.outputSchema, "object")) return; const examples = pullResponseExamples(this.#def.outputSchema); if (!examples.length) return; diff --git a/express-zod-api/src/form-schema.ts b/express-zod-api/src/form-schema.ts index eda1d287b..466c5725d 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 { brandProperty } from "./metadata"; export const ezFormBrand = Symbol("Form"); /** @desc Accepts an object shape or a custom object schema */ export const form = (base: S | z.ZodObject) => - (base instanceof z.ZodObject ? base : z.object(base)).brand( - ezFormBrand as symbol, - ); + (base instanceof z.ZodObject ? base : z.object(base)).meta({ + [brandProperty]: ezFormBrand, + }); diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index 19ff1715a..986cecf0c 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -1,4 +1,3 @@ -import "@express-zod-api/zod-plugin"; // side effects here export { createConfig } from "./config-type"; export { EndpointsFactory, diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts new file mode 100644 index 000000000..6b6d4584e --- /dev/null +++ b/express-zod-api/src/metadata.ts @@ -0,0 +1,22 @@ +import type { brandProperty as brandProp } from "../../zod-plugin/src/brand.ts"; +import { globalRegistry, type z } from "zod"; + +export const brandProperty = "x-brand" satisfies typeof brandProp; + +export const getBrand = (subject: z.core.$ZodType) => { + const { [brandProperty]: brand } = globalRegistry.get(subject) || {}; + if ( + typeof brand === "symbol" || + typeof brand === "string" || + typeof brand === "number" + ) + return brand; + return undefined; +}; + +/** @desc Returns examples from the schema metadata always as an array */ +export const getExamples = (subject: z.core.$ZodType): unknown[] => { + const { examples } = globalRegistry.get(subject) || {}; + if (Array.isArray(examples)) return examples; + return []; +}; diff --git a/express-zod-api/src/raw-schema.ts b/express-zod-api/src/raw-schema.ts index d779b85b8..56f02a518 100644 --- a/express-zod-api/src/raw-schema.ts +++ b/express-zod-api/src/raw-schema.ts @@ -1,20 +1,21 @@ import { z } from "zod"; import { buffer } from "./buffer-schema"; +import { brandProperty } from "./metadata"; export const ezRawBrand = Symbol("Raw"); const base = z.object({ raw: buffer() }); -type Base = ReturnType>; +type Base = typeof base; const extended = (extra: S) => - base.extend(extra).brand(ezRawBrand as symbol); + base.extend(extra).meta({ [brandProperty]: ezRawBrand }); export function raw(): Base; export function raw( extra: S, ): ReturnType>; export function raw(extra?: z.core.$ZodShape) { - return extra ? extended(extra) : base.brand(ezRawBrand as symbol); + return extra ? extended(extra) : base.meta({ [brandProperty]: ezRawBrand }); } export type RawSchema = Base; diff --git a/express-zod-api/src/result-handler.ts b/express-zod-api/src/result-handler.ts index d753b07d2..3b60dc5ff 100644 --- a/express-zod-api/src/result-handler.ts +++ b/express-zod-api/src/result-handler.ts @@ -17,6 +17,7 @@ import { logServerError, normalize, } from "./result-helpers"; +import { getExamples } from "./metadata"; type Handler = ( params: DiscriminatedResult & { @@ -110,8 +111,8 @@ export const defaultResultHandler = new ResultHandler({ status: z.literal("success"), data: output, }); - const { examples } = globalRegistry.get(output) || {}; // pulling down: - if (examples?.length) { + const examples = getExamples(output); // pulling down: + if (examples.length) { globalRegistry.add(responseSchema, { examples: examples.map((data) => ({ status: "success" as const, @@ -160,11 +161,9 @@ export const arrayResultHandler = new ResultHandler({ output.shape.items instanceof z.ZodArray ? output.shape.items : z.array(z.any()); - if (globalRegistry.get(responseSchema)?.examples?.length) - return responseSchema; // has examples on the items, or pull down: - const examples = globalRegistry - .get(output) - ?.examples?.filter( + if (getExamples(responseSchema).length) return responseSchema; // has examples on the items, or pull down: + const examples = getExamples(output) + .filter( (example): example is { items: unknown[] } => isObject(example) && "items" in example && diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index 6b50c4017..fb6ae0c6e 100644 --- a/express-zod-api/src/result-helpers.ts +++ b/express-zod-api/src/result-helpers.ts @@ -1,7 +1,7 @@ import type { Request } from "express"; import createHttpError, { HttpError, isHttpError } from "http-errors"; import * as R from "ramda"; -import { globalRegistry, z } from "zod"; +import { z } from "zod"; import type { NormalizedResponse, ResponseVariant } from "./api-response"; import { combinations, @@ -12,6 +12,7 @@ import { import { InputValidationError, ResultHandlerError } from "./errors"; import type { ActualLogger } from "./logger-helpers"; import type { LazyResult, Result } from "./result-handler"; +import { getExamples } from "./metadata"; export type ResultSchema = R extends Result ? S : never; @@ -90,12 +91,11 @@ export const getPublicErrorMessage = (error: HttpError): string => /** @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, - })); - }, + (acc, [key, schema]) => + combinations( + acc, + getExamples(schema).map(R.objOf(key)), + ([left, right]) => ({ ...left, ...right }), + ), [], ); diff --git a/express-zod-api/src/schema-walker.ts b/express-zod-api/src/schema-walker.ts index ef107d54a..3273ea4d5 100644 --- a/express-zod-api/src/schema-walker.ts +++ b/express-zod-api/src/schema-walker.ts @@ -1,5 +1,5 @@ import type { EmptyObject, FlatObject } from "./common-helpers"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "./metadata"; import type { z } from "zod"; export type FirstPartyKind = z.core.$ZodTypeDef["type"]; diff --git a/express-zod-api/src/upload-schema.ts b/express-zod-api/src/upload-schema.ts index e6aa2c906..590f98b54 100644 --- a/express-zod-api/src/upload-schema.ts +++ b/express-zod-api/src/upload-schema.ts @@ -1,5 +1,6 @@ import type { UploadedFile } from "express-fileupload"; import { z } from "zod"; +import { brandProperty } from "./metadata"; export const ezUploadBrand = Symbol("Upload"); @@ -33,4 +34,4 @@ export const upload = () => }), }, ) - .brand(ezUploadBrand as symbol); + .meta({ [brandProperty]: ezUploadBrand }); diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 57a0f1d23..acd97ba81 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -252,14 +252,7 @@ exports[`zod-to-ts > z.object() > specially handles coercive schema in response }" `; -exports[`zod-to-ts > z.object() > supports string literal properties 1`] = ` -"{ - "5": number; - "string-literal": string; -}" -`; - -exports[`zod-to-ts > z.object() > supports zod.deprecated() 1`] = ` +exports[`zod-to-ts > z.object() > supports deprecated metadata 1`] = ` "{ /** @deprecated */ one: string; @@ -268,6 +261,13 @@ exports[`zod-to-ts > z.object() > supports zod.deprecated() 1`] = ` }" `; +exports[`zod-to-ts > z.object() > supports string literal properties 1`] = ` +"{ + "5": number; + "string-literal": string; +}" +`; + exports[`zod-to-ts > z.object() > supports zod.describe() 1`] = ` "{ /** The name of the item */ diff --git a/express-zod-api/tests/buffer-schema.spec.ts b/express-zod-api/tests/buffer-schema.spec.ts index cdb8bcaf9..02de1f18b 100644 --- a/express-zod-api/tests/buffer-schema.spec.ts +++ b/express-zod-api/tests/buffer-schema.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { z, $brand } from "zod"; +import { z } from "zod"; import { ez } from "../src"; describe("ez.buffer()", () => { @@ -7,7 +7,7 @@ describe("ez.buffer()", () => { test("should create a Buffer", () => { const schema = ez.buffer(); expect(schema).toBeInstanceOf(z.ZodCustom); - expectTypeOf(schema._zod.output).toEqualTypeOf>(); + expectTypeOf(schema._zod.output).toEqualTypeOf(); }); }); diff --git a/express-zod-api/tests/date-in-schema.spec.ts b/express-zod-api/tests/date-in-schema.spec.ts index 57d0d8dac..aa337cfcc 100644 --- a/express-zod-api/tests/date-in-schema.spec.ts +++ b/express-zod-api/tests/date-in-schema.spec.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { ezDateInBrand } from "../src/date-in-schema"; import { ez } from "../src"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "../src/metadata"; describe("ez.dateIn()", () => { describe("creation", () => { diff --git a/express-zod-api/tests/date-out-schema.spec.ts b/express-zod-api/tests/date-out-schema.spec.ts index fab72dc78..0aa44a776 100644 --- a/express-zod-api/tests/date-out-schema.spec.ts +++ b/express-zod-api/tests/date-out-schema.spec.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { ezDateOutBrand } from "../src/date-out-schema"; import { ez } from "../src"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "../src/metadata"; describe("ez.dateOut()", () => { describe("creation", () => { diff --git a/express-zod-api/tests/deep-checks.spec.ts b/express-zod-api/tests/deep-checks.spec.ts index 9f5c66f91..26d44f1d9 100644 --- a/express-zod-api/tests/deep-checks.spec.ts +++ b/express-zod-api/tests/deep-checks.spec.ts @@ -2,7 +2,7 @@ import type { UploadedFile } from "express-fileupload"; import { z } from "zod"; import { ez } from "../src"; import { findNestedSchema, hasCycle } from "../src/deep-checks"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; describe("Checks", () => { @@ -22,7 +22,7 @@ describe("Checks", () => { z.object({ test: z.boolean() }).and(z.object({ test2: ez.upload() })), z.optional(ez.upload()), ez.upload().nullable(), - ez.upload().default({} as UploadedFile & z.core.$brand), + ez.upload().default({} as UploadedFile), z.record(z.string(), ez.upload()), ez.upload().refine(() => true), z.array(ez.upload()), diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 2abde50c7..2972721ad 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -16,6 +16,7 @@ import { contentTypes } from "../src/content-type"; import { z } from "zod"; import { givePort } from "../../tools/ports"; import * as R from "ramda"; +import { brandProperty } from "../src/metadata"; describe("Documentation", () => { const sampleConfig = createConfig({ @@ -93,7 +94,7 @@ describe("Documentation", () => { labeledDate: z.iso .datetime() .default(() => new Date().toISOString()) - .label("Today"), // Feature #1706 + .meta({ default: "Today" }), // Feature #1706 }), output: z.object({ nullable: z.string().nullable(), @@ -991,7 +992,7 @@ describe("Documentation", () => { test("Feature #2390: should support deprecations", () => { const endpoint = defaultEndpointsFactory.build({ input: z.object({ - str: z.string().deprecated(), + str: z.string().meta({ deprecated: true }), }), output: z.object({}), handler: vi.fn(), @@ -1046,14 +1047,14 @@ describe("Documentation", () => { input: z.object({ strNum: z .string() - .example("123") // example for the input side of the transformation + .meta({ examples: ["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 + .meta({ examples: ["456"] }), // example for the output side of the transformation }), handler: async () => ({ numericStr: 123 }), }), @@ -1078,15 +1079,13 @@ describe("Documentation", () => { method, input: z .object({ strNum: z.string() }) - .example({ strNum: "123" }) // example is for input side of the transformation + .meta({ examples: [{ 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 - }), + .meta({ examples: [{ numericStr: "123" }] }), // example is for output side of the transformation handler: async () => ({ numericStr: 123 }), }), }, @@ -1108,13 +1107,17 @@ describe("Documentation", () => { .addMiddleware({ input: z .object({ key: z.string() }) - .example({ key: "1234-56789-01" }), + .meta({ examples: [{ key: "1234-56789-01" }] }), handler: vi.fn(), }) .build({ method: "post", - input: z.object({ str: z.string() }).example({ str: "test" }), - output: z.object({ num: z.number() }).example({ num: 123 }), + input: z + .object({ str: z.string() }) + .meta({ examples: [{ str: "test" }] }), + output: z + .object({ num: z.number() }) + .meta({ examples: [{ num: 123 }] }), handler: async () => ({ num: 123 }), }), }, @@ -1133,13 +1136,17 @@ describe("Documentation", () => { v1: { getSomething: defaultEndpointsFactory .addMiddleware({ - input: z.object({ key: z.string().example("1234-56789-01") }), + input: z.object({ + key: z.string().meta({ examples: ["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) }), + input: z.object({ + str: z.string().meta({ examples: ["test"] }), + }), + output: z.object({ num: z.number().meta({ examples: [123] }) }), handler: async () => ({ num: 123 }), }), }, @@ -1159,11 +1166,13 @@ describe("Documentation", () => { v1: { addSomething: defaultEndpointsFactory.build({ method: "post", - input: zodSchema.example({ a: "first" }), - output: zodSchema - .extend({ b: z.string() }) - .example({ a: "first", b: "prefix_first" }) - .example({ a: "second", b: "prefix_second" }), + input: zodSchema.meta({ examples: [{ a: "first" }] }), + output: zodSchema.extend({ b: z.string() }).meta({ + examples: [ + { a: "first", b: "prefix_first" }, + { a: "second", b: "prefix_second" }, + ], + }), handler: async ({ input: { a } }) => ({ a, b: `prefix_${a}` }), }), }, @@ -1186,12 +1195,12 @@ describe("Documentation", () => { v1: { ":name": defaultEndpointsFactory.build({ input: z.object({ - name: z.string().brand("CUSTOM"), - other: z.boolean().brand("CUSTOM"), - regular: z.boolean().brand(deep), + name: z.string().meta({ [brandProperty]: "CUSTOM" }), + other: z.boolean().meta({ [brandProperty]: "CUSTOM" }), + regular: z.boolean().meta({ [brandProperty]: deep }), }), output: z.object({ - number: z.number().brand("CUSTOM"), + number: z.number().meta({ [brandProperty]: "CUSTOM" }), }), handler: vi.fn(), }), @@ -1223,7 +1232,8 @@ describe("Documentation", () => { .transform((inputs) => camelize(inputs, true)), output: z .object({ userName: z.string() }) - .remap((outputs) => snakify(outputs, true)), + .transform((outputs) => snakify(outputs, true)) + .pipe(z.object({ user_name: z.string() })), // zod plugin's remap emulation handler: async ({ input: { userId } }) => ({ userName: `User ${userId}`, }), @@ -1245,10 +1255,15 @@ describe("Documentation", () => { test: defaultEndpointsFactory.build({ input: z .object({ user_id: z.string(), at: ez.dateIn() }) - .remap({ user_id: "userId" }), // partial mapping + .transform(({ user_id: userId, ...rest }) => ({ + ...rest, + userId, // partial mapping + })) + .pipe(z.object({ userId: z.string(), at: z.date() })), output: z .object({ userName: z.string() }) - .remap({ userName: "user_name" }), + .transform(({ userName: user_name }) => ({ user_name })) + .pipe(z.object({ user_name: z.string() })), handler: async ({ input: { userId, at } }) => ({ userName: `User ${userId} ${at}`, }), diff --git a/express-zod-api/tests/form-schema.spec.ts b/express-zod-api/tests/form-schema.spec.ts index fe4ac34fb..53d31b03e 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"; import { ez } from "../src"; import { ezFormBrand } from "../src/form-schema"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "../src/metadata"; describe("ez.form()", () => { describe("creation", () => { diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 4ca6a2221..014081ae9 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -7,6 +7,7 @@ import { ResultHandler, type Producer, } from "../src"; +import { brandProperty } from "../src/metadata"; describe("Integration", () => { const recursive1: z.ZodType = z.lazy(() => @@ -149,11 +150,11 @@ describe("Integration", () => { custom: defaultEndpointsFactory.build({ method: "post", input: z.object({ - string: z.string().brand("CUSTOM"), - regular: z.string().brand("DEEP"), + string: z.string().meta({ [brandProperty]: "CUSTOM" }), + regular: z.string().meta({ [brandProperty]: "DEEP" }), }), output: z.object({ - number: z.number().brand("CUSTOM"), + number: z.number().meta({ [brandProperty]: "CUSTOM" }), }), handler: vi.fn(), }), diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 7d17ea95f..11ab90bc4 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -106,9 +106,6 @@ describe("I/O Schema and related helpers", () => { .transform(() => ({ n: 123 })) .pipe(z.object({ n: z.number() })), ).toExtend(); - expectTypeOf( - z.object({ user_id: z.string() }).remap({ user_id: "userId" }), - ).toExtend(); }); test("does not accept transformation to another type", () => { expectTypeOf( diff --git a/express-zod-api/tests/metadata.spec.ts b/express-zod-api/tests/metadata.spec.ts new file mode 100644 index 000000000..70db2dca8 --- /dev/null +++ b/express-zod-api/tests/metadata.spec.ts @@ -0,0 +1,28 @@ +import { globalRegistry, z } from "zod"; +import { brandProperty, getBrand, getExamples } from "../src/metadata"; + +describe("Metadata helpers", () => { + describe("getBrand()", () => { + test.each([{ [brandProperty]: "test" }, {}, undefined])( + "should take it from metadata in globalRegistry %#", + (metadata) => { + const subject = z.string(); + if (metadata) globalRegistry.add(subject, metadata); + expect(getBrand(subject)).toBe(metadata?.[brandProperty]); + }, + ); + }); + + describe("getExamples()", () => { + test.each([ + { examples: [123, 456] }, + { examples: [] }, + { examples: undefined }, + {}, + ])("always returns an array %#", (metadata) => { + const subject = z.number(); + globalRegistry.add(subject, metadata); + expect(getExamples(subject)).toEqual(metadata.examples ?? []); + }); + }); +}); diff --git a/express-zod-api/tests/raw-schema.spec.ts b/express-zod-api/tests/raw-schema.spec.ts index f0104ffad..7f648deab 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"; import { ez } from "../src"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "../src/metadata"; import { ezRawBrand } from "../src/raw-schema"; describe("ez.raw()", () => { diff --git a/express-zod-api/tests/result-handler.spec.ts b/express-zod-api/tests/result-handler.spec.ts index f1c3e998f..039e425f3 100644 --- a/express-zod-api/tests/result-handler.spec.ts +++ b/express-zod-api/tests/result-handler.spec.ts @@ -185,9 +185,9 @@ describe("ResultHandler", () => { test("should forward output schema examples", () => { const apiResponse = subject.getPositiveResponse( - z - .object({ str: z.string(), items: z.array(z.string()) }) - .example({ str: "test", items: ["One", "Two", "Three"] }), + z.object({ str: z.string(), items: z.array(z.string()) }).meta({ + examples: [{ str: "test", items: ["One", "Two", "Three"] }], + }), ); expect(apiResponse).toHaveLength(1); expect(apiResponse[0].schema.meta()).toMatchSnapshot(); @@ -202,7 +202,11 @@ describe("ResultHandler", () => { test("arrayResultHandler should attempt to take examples from the items prop", () => { const apiResponse = arrayResultHandler.getPositiveResponse( - z.object({ items: z.array(z.string()).example(["One", "Two", "Three"]) }), + z.object({ + items: z + .array(z.string()) + .meta({ examples: [["One", "Two", "Three"]] }), + }), ); expect(apiResponse).toHaveLength(1); expect(apiResponse[0].schema.meta()).toMatchSnapshot(); @@ -213,7 +217,9 @@ describe("ResultHandler", () => { const loggerMock = makeLoggerMock(); const positiveSchema = arrayResultHandler .getPositiveResponse( - z.object({ anything: z.number() }).example({ anything: 118 }), + z + .object({ anything: z.number() }) + .meta({ examples: [{ anything: 118 }] }), ) .pop()?.schema; expect(positiveSchema).toHaveProperty(["_zod", "def", "type"], "array"); diff --git a/express-zod-api/tests/result-helpers.spec.ts b/express-zod-api/tests/result-helpers.spec.ts index 50526c067..099c9cd9c 100644 --- a/express-zod-api/tests/result-helpers.spec.ts +++ b/express-zod-api/tests/result-helpers.spec.ts @@ -97,9 +97,9 @@ 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), + a: z.string().meta({ examples: ["one", "two", "three"] }), + b: z.number().meta({ examples: [1, 2] }), + c: z.boolean().meta({ examples: [false] }), }); expect(pullResponseExamples(schema)).toEqual([ { a: "one", b: 1, c: false }, diff --git a/express-zod-api/tests/upload-schema.spec.ts b/express-zod-api/tests/upload-schema.spec.ts index d6c4f671a..d517f783d 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"; import { ez } from "../src"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import { getBrand } from "../src/metadata"; import { ezUploadBrand } from "../src/upload-schema"; describe("ez.upload()", () => { diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 556e7217f..78c873fd1 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -277,10 +277,10 @@ describe("zod-to-ts", () => { expect(printNodeTest(node)).toMatchSnapshot(); }); - test("supports zod.deprecated()", () => { + test("supports deprecated metadata", () => { const schema = z.object({ - one: z.string().deprecated(), - two: z.string().deprecated().describe("with description"), + one: z.string().meta({ deprecated: true }), + two: z.string().meta({ deprecated: true }).describe("with description"), }); const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); diff --git a/express-zod-api/tsdown.config.ts b/express-zod-api/tsdown.config.ts index 689f5377c..03b52d568 100644 --- a/express-zod-api/tsdown.config.ts +++ b/express-zod-api/tsdown.config.ts @@ -10,10 +10,6 @@ export default defineConfig({ deps: { neverBundle: ["express-serve-static-core", "qs"], }, - banner: { - /** @since tsdown 0.21 it shakes the unused import */ - dts: `import "@express-zod-api/zod-plugin";`, - }, plugins: [fixDtsPlugin()], define: { "process.env.TSDOWN_SELF": `"${manifest.name}"`, // used by localsID diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5b7ded1d..6538f82b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: example: devDependencies: + '@express-zod-api/zod-plugin': + specifier: workspace:* + version: link:../zod-plugin '@types/http-errors': specifier: catalog:dev version: 2.0.5 @@ -185,9 +188,6 @@ importers: express-zod-api: dependencies: - '@express-zod-api/zod-plugin': - specifier: workspace:^ - version: link:../zod-plugin ansis: specifier: ^4.2.0 version: 4.2.0 @@ -201,6 +201,9 @@ importers: specifier: catalog:prod version: 0.32.0 devDependencies: + '@express-zod-api/zod-plugin': + specifier: workspace:^ + version: link:../zod-plugin '@types/compression': specifier: catalog:dev version: 1.8.1 diff --git a/zod-plugin/CHANGELOG.md b/zod-plugin/CHANGELOG.md index 359442e15..96935fcce 100644 --- a/zod-plugin/CHANGELOG.md +++ b/zod-plugin/CHANGELOG.md @@ -5,6 +5,8 @@ ### v5.0.0 - Supported Node.js versions: `^22.19.0 || ^24.0.0`; +- `getBrand()` removed: + - use `schema.meta()?.["x-brand"]` instead. ## Version 4 diff --git a/zod-plugin/README.md b/zod-plugin/README.md index 71410198c..86bfea593 100644 --- a/zod-plugin/README.md +++ b/zod-plugin/README.md @@ -1,4 +1,4 @@ -# Zod Plugin from Express Zod API +# Zod Plugin for Express Zod API ## Overview @@ -14,7 +14,7 @@ This module extends Zod functionality when it's imported: - Supports a mapping object or an object transforming function as an argument; - Relies on `R.renameKeys()` from the `ramda` library; - Alters the `.brand()` method on all Zod schemas: - - shorthand for `.meta({ "x-brand": ... })` making the brand available in runtime via `getBrand()` helper; + - shorthand for `.meta({ "x-brand": ... })` making the brand available in runtime; ## Requirements @@ -24,14 +24,9 @@ This module extends Zod functionality when it's imported: ```ts import { z } from "zod"; -import { getBrand } from "@express-zod-api/zod-plugin"; +import "@express-zod-api/zod-plugin"; const schema = z.string().example("test").example("another").brand("custom"); -getBrand(schema); // "custom" schema.meta(); // { examples: ["test", "another"], "x-brand": "custom" } ``` - -## Helpers - -- `getBrand()` — retrieves the brand from the schema that was set by its `.brand()` method. diff --git a/zod-plugin/src/brand.ts b/zod-plugin/src/brand.ts index c0a7ba078..474d7707c 100644 --- a/zod-plugin/src/brand.ts +++ b/zod-plugin/src/brand.ts @@ -1,19 +1,2 @@ -import { globalRegistry, z } from "zod"; - /** The property we store the brand in */ export const brandProperty = "x-brand" as const; - -/** - * @public - * @desc Retrieves the brand from the schema set by its .brand() method. - * */ -export const getBrand = (subject: z.core.$ZodType) => { - const { [brandProperty]: brand } = globalRegistry.get(subject) || {}; - if ( - typeof brand === "symbol" || - typeof brand === "string" || - typeof brand === "number" - ) - return brand; - return undefined; -}; diff --git a/zod-plugin/src/index.ts b/zod-plugin/src/index.ts index fd7b1155f..b8d89756a 100644 --- a/zod-plugin/src/index.ts +++ b/zod-plugin/src/index.ts @@ -1,2 +1 @@ import "./runtime"; // side effects here -export { getBrand } from "./brand"; diff --git a/zod-plugin/tests/brand.spec.ts b/zod-plugin/tests/brand.spec.ts index 1def208cd..cd7df3d52 100644 --- a/zod-plugin/tests/brand.spec.ts +++ b/zod-plugin/tests/brand.spec.ts @@ -1,5 +1,4 @@ -import { z, globalRegistry } from "zod"; -import { brandProperty, getBrand } from "../src/brand"; +import { brandProperty } from "../src/brand"; describe("Brand", () => { describe("brandProperty", () => { @@ -7,15 +6,4 @@ describe("Brand", () => { expect(brandProperty).toBe("x-brand"); }); }); - - describe("getBrand", () => { - test.each([{ [brandProperty]: "test" }, {}, undefined])( - "should take it from metadata in globalRegistry %#", - (metadata) => { - const subject = z.string(); - if (metadata) globalRegistry.add(subject, metadata); - expect(getBrand(subject)).toBe(metadata?.[brandProperty]); - }, - ); - }); }); diff --git a/zod-plugin/tests/index.spec.ts b/zod-plugin/tests/index.spec.ts index 88116b5df..aa296ec7f 100644 --- a/zod-plugin/tests/index.spec.ts +++ b/zod-plugin/tests/index.spec.ts @@ -27,9 +27,7 @@ describe("Entrypoint", () => { .toEqualTypeOf(); }); - test("Exports", () => { - expect(entrypoint).toMatchObject({ - getBrand: expect.any(Function), - }); + test("has no exports", () => { + expect(Object.keys(entrypoint)).toHaveLength(0); }); });