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"]); }); });