Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions express-zod-api/src/io-schema.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -14,7 +14,7 @@ export type IOSchema = z.ZodType<Base>;
* @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,
Expand All @@ -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<MIN, IN>;
};
Expand Down
34 changes: 17 additions & 17 deletions express-zod-api/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A extends z.ZodType, B extends z.ZodType>(
export const mixExamples = <A extends z.ZodType, B extends z.ZodType>(
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 },
});
};
24 changes: 14 additions & 10 deletions express-zod-api/src/zod-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,40 +56,43 @@ declare module "zod" {
}

const exampleSetter = function (this: z.ZodType, value: z.input<typeof this>) {
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],
});
};

const labelSetter = function (
this: z.ZodDefault<z.ZodTypeAny>,
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 },
});
};

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 },
});
};

Expand Down
20 changes: 11 additions & 9 deletions express-zod-api/tests/metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 },
Expand All @@ -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 } },
Expand All @@ -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"]);
});
});
Expand Down