Skip to content
16 changes: 10 additions & 6 deletions express-zod-api/src/endpoints-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { Request, Response } from "express";
import { z } from "zod/v4";
import { EmptyObject, EmptySchema, FlatObject, Tag } from "./common-helpers";
import { Endpoint, Handler } from "./endpoint";
import { IOSchema, getFinalEndpointInputSchema } from "./io-schema";
import {
IOSchema,
getFinalEndpointInputSchema,
AltIntersection,
} from "./io-schema";
import { Method } from "./method";
import {
AbstractMiddleware,
Expand All @@ -18,7 +22,7 @@ import {
interface BuildProps<
IN extends IOSchema,
OUT extends IOSchema | z.ZodVoid,
MIN extends IOSchema,
MIN extends IOSchema | undefined,
OPT extends FlatObject,
SCO extends string,
> {
Expand All @@ -31,7 +35,7 @@ interface BuildProps<
/** @desc The schema by which the returns of the Endpoint handler is validated */
output: OUT;
/** @desc The Endpoint handler receiving the validated inputs, returns of added Middlewares (options) and a logger */
handler: Handler<z.output<z.ZodIntersection<MIN, IN>>, z.input<OUT>, OPT>;
handler: Handler<z.output<AltIntersection<MIN, IN>>, z.input<OUT>, OPT>;
/** @desc The operation description for the generated Documentation */
description?: string;
/** @desc The operation summary for the generated Documentation (50 symbols max) */
Expand Down Expand Up @@ -59,15 +63,15 @@ interface BuildProps<
}

export class EndpointsFactory<
IN extends IOSchema = EmptySchema,
IN extends IOSchema | undefined = undefined,
OUT extends FlatObject = EmptyObject,
SCO extends string = string,
> {
protected middlewares: AbstractMiddleware[] = [];
constructor(protected resultHandler: AbstractResultHandler) {}

static #create<
CIN extends IOSchema,
CIN extends IOSchema | undefined,
COUT extends FlatObject,
CSCO extends string,
>(middlewares: AbstractMiddleware[], resultHandler: AbstractResultHandler) {
Expand All @@ -86,7 +90,7 @@ export class EndpointsFactory<
| ConstructorParameters<typeof Middleware<OUT, AOUT, ASCO, AIN>>[0],
) {
return EndpointsFactory.#create<
z.ZodIntersection<IN, AIN>,
AltIntersection<IN, AIN>,
OUT & AOUT,
SCO & ASCO
>(
Expand Down
10 changes: 8 additions & 2 deletions express-zod-api/src/io-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ type Base = object & { [Symbol.iterator]?: never };
/** @desc The type allowed on the top level of Middlewares and Endpoints */
export type IOSchema = z.ZodType<Base>;

// @todo naming?
export type AltIntersection<
Current extends IOSchema | undefined,
Inc extends IOSchema,
> = z.ZodIntersection<Current extends IOSchema ? Current : Inc, Inc>;

/**
* @description intersects input schemas of middlewares and the endpoint
* @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas()
Expand All @@ -15,12 +21,12 @@ export type IOSchema = z.ZodType<Base>;
* @since 22.05.2025 does not mix examples in after switching to Zod 4
*/
export const getFinalEndpointInputSchema = <
MIN extends IOSchema,
MIN extends IOSchema | undefined,
IN extends IOSchema,
>(
middlewares: AbstractMiddleware[],
input: IN,
) =>
R.pluck("schema", middlewares)
.concat(input)
.reduce((acc, schema) => acc.and(schema)) as z.ZodIntersection<MIN, IN>;
.reduce((acc, schema) => acc.and(schema)) as AltIntersection<MIN, IN>;
50 changes: 34 additions & 16 deletions express-zod-api/tests/endpoints-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RequestHandler } from "express";
import createHttpError from "http-errors";
import { expectTypeOf } from "vitest";
import {
EndpointsFactory,
Middleware,
Expand Down Expand Up @@ -81,6 +82,28 @@ describe("EndpointsFactory", () => {
>
>();
});

test("Issue #2760: should strip excessive props by default", () => {
defaultEndpointsFactory.build({
input: z.object({ foo: z.string() }),
output: z.object({ foo: z.string() }),
handler: async ({ input }) => {
expectTypeOf(input).not.toHaveProperty("bar");
return input;
},
});
});

test("Issue #2760: should allow excessive props when using loose object schema", () => {
defaultEndpointsFactory.build({
input: z.looseObject({ foo: z.string() }),
output: z.object({ foo: z.string() }),
handler: async ({ input }) => {
expectTypeOf(input).toHaveProperty("bar").toEqualTypeOf<unknown>();
return input;
},
});
});
});

describe(".addOptions()", () => {
Expand All @@ -92,7 +115,7 @@ describe("EndpointsFactory", () => {
}));
expectTypeOf(newFactory).toEqualTypeOf<
EndpointsFactory<
EmptySchema,
undefined,
EmptyObject & { option1: string; option2: string }
>
>();
Expand Down Expand Up @@ -249,10 +272,9 @@ describe("EndpointsFactory", () => {
expect(endpoint.methods).toBeUndefined();
expect(endpoint.inputSchema).toMatchSnapshot();
expect(endpoint.outputSchema).toMatchSnapshot();
expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{
n: number;
s: string;
}>();
expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf<
{ n: number } & { s: string }
>();
});

test("Should create an endpoint with refined object middleware", () => {
Expand All @@ -277,11 +299,9 @@ describe("EndpointsFactory", () => {
});
expect(endpoint.inputSchema).toMatchSnapshot();
expect(endpoint.outputSchema).toMatchSnapshot();
expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{
a?: number;
b?: string;
i: string;
}>();
expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf<
{ a?: number; b?: string } & { i: string }
>();
});

test("Should create an endpoint with intersection middleware", () => {
Expand All @@ -302,11 +322,9 @@ describe("EndpointsFactory", () => {
expect(endpoint.methods).toBeUndefined();
expect(endpoint.inputSchema).toMatchSnapshot();
expect(endpoint.outputSchema).toMatchSnapshot();
expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{
n1: number;
n2: number;
s: string;
}>();
expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf<
{ n1: number } & { n2: number } & { s: string }
>();
});

test("Should create an endpoint with union middleware", () => {
Expand All @@ -330,7 +348,7 @@ describe("EndpointsFactory", () => {
expect(endpoint.methods).toBeUndefined();
expect(endpoint.inputSchema).toMatchSnapshot();
expect(endpoint.outputSchema).toMatchSnapshot();
expectTypeOf(endpoint.inputSchema._zod.output).toExtend<
expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf<
{ s: string } & ({ n1: number } | { n2: number })
>();
});
Expand Down