From 45e86c61a797312af00fb2c02756ef531d0d54b2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 12:31:16 +0200 Subject: [PATCH 01/15] feat(deps): Add express-rate-limit optional peer and dev dependency. --- express-zod-api/package.json | 5 +++++ pnpm-lock.yaml | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 1c8ff1803..281ee2805 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -57,6 +57,7 @@ "cookie-parser": "^1.4.7", "express": "^5.1.0", "express-fileupload": "^1.5.0", + "express-rate-limit": "^7.5.1", "http-errors": "^2.0.1", "typescript": "^5.1.3 || ^6.0.2", "zod": "catalog:peer" @@ -89,6 +90,9 @@ "express-fileupload": { "optional": true }, + "express-rate-limit": { + "optional": true + }, "typescript": { "optional": true } @@ -111,6 +115,7 @@ "depd": "^2.0.0", "express": "catalog:dev", "express-fileupload": "catalog:dev", + "express-rate-limit": "^7.5.1", "http-errors": "catalog:dev", "node-forge": "^1.3.3", "snakify-ts": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 342623124..1dc1f9e2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: express-fileupload: specifier: catalog:dev version: 1.5.2 + express-rate-limit: + specifier: ^7.5.1 + version: 7.5.1(express@5.2.1) http-errors: specifier: catalog:dev version: 2.0.1 @@ -1130,6 +1133,12 @@ packages: resolution: {integrity: sha512-wxUJn2vTHvj/kZCVmc5/bJO15C7aSMyHeuXYY3geKpeKibaAoQGcEv5+sM6nHS2T7VF+QHS4hTWPiY2mKofEdg==} engines: {node: '>=12.0.0'} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -2815,6 +2824,10 @@ snapshots: dependencies: busboy: 1.6.0 + express-rate-limit@7.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + express@5.1.0: dependencies: accepts: 2.0.0 From f67ef800060f3bfce698c1835240bff514b7a1e2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 12:44:27 +0200 Subject: [PATCH 02/15] feat(mw): Draft implementation. --- express-zod-api/src/index.ts | 1 + express-zod-api/src/rate-limit-middleware.ts | 31 +++++++++++++++++++ .../tests/__snapshots__/index.spec.ts.snap | 3 ++ 3 files changed, 35 insertions(+) create mode 100644 express-zod-api/src/rate-limit-middleware.ts diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index 3f65c0dac..37158bc9a 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -10,6 +10,7 @@ export { BuiltinLogger } from "./builtin-logger"; export { Middleware } from "./middleware"; export { createCookieMiddleware } from "./cookie-middleware"; export { createCacheMiddleware } from "./cache-middleware"; +export { createRateLimitMiddleware } from "./rate-limit-middleware"; export { ResultHandler, defaultResultHandler, diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts new file mode 100644 index 000000000..8ef98e768 --- /dev/null +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -0,0 +1,31 @@ +import createHttpError from "http-errors"; +import type { Request, Response } from "express"; +import type { + Options, + RateLimitInfo, + RateLimitRequestHandler, + AugmentedRequest, +} from "express-rate-limit"; +import { ExpressMiddleware } from "./middleware"; +import { loadPeer } from "./peer-helpers"; + +export const createRateLimitMiddleware = async ( + config?: Partial, +): Promise< + ExpressMiddleware +> => { + const rateLimit = + await loadPeer< + (passedOptions?: Partial) => RateLimitRequestHandler + >("express-rate-limit"); + const handler = rateLimit({ + ...config, + handler: (_req, _res, next, optionsUsed) => { + next(createHttpError(429, optionsUsed.message)); + }, + }); + return new ExpressMiddleware(handler, { + provider: (req: AugmentedRequest) => + ({ rateLimit: req.rateLimit }) as { rateLimit: RateLimitInfo }, + }); +}; diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index 8f76b8156..adf5f0abd 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -45,6 +45,8 @@ exports[`Index Entrypoint > exports > createConfig should have certain value 1`] exports[`Index Entrypoint > exports > createCookieMiddleware should have certain value 1`] = `[Function]`; +exports[`Index Entrypoint > exports > createRateLimitMiddleware should have certain value 1`] = `[Function]`; + exports[`Index Entrypoint > exports > createServer should have certain value 1`] = `[Function]`; exports[`Index Entrypoint > exports > defaultEndpointsFactory should have certain value 1`] = ` @@ -86,6 +88,7 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "Middleware", "createCookieMiddleware", "createCacheMiddleware", + "createRateLimitMiddleware", "ResultHandler", "defaultResultHandler", "arrayResultHandler", From 9e2474b210098fa1b7fc39d3ca21d451e8225207 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 20:40:13 +0200 Subject: [PATCH 03/15] fix(mw): making it sync and simpler typed. --- express-zod-api/src/rate-limit-middleware.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts index 8ef98e768..2899adac9 100644 --- a/express-zod-api/src/rate-limit-middleware.ts +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -3,21 +3,16 @@ import type { Request, Response } from "express"; import type { Options, RateLimitInfo, - RateLimitRequestHandler, AugmentedRequest, + rateLimit as RateLimitFn, } from "express-rate-limit"; import { ExpressMiddleware } from "./middleware"; import { loadPeer } from "./peer-helpers"; -export const createRateLimitMiddleware = async ( +export const createRateLimitMiddleware = ( config?: Partial, -): Promise< - ExpressMiddleware -> => { - const rateLimit = - await loadPeer< - (passedOptions?: Partial) => RateLimitRequestHandler - >("express-rate-limit"); +): ExpressMiddleware => { + const rateLimit = loadPeer("express-rate-limit"); const handler = rateLimit({ ...config, handler: (_req, _res, next, optionsUsed) => { From 76a4ae7be4f7889c3b30bc985435ba4ffe24afb2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 20:41:03 +0200 Subject: [PATCH 04/15] feat(api): Add EndpointsFactory::useRateLimit() shorthand. --- express-zod-api/src/endpoints-factory.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index 3a9e83a42..7aea2b197 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -28,6 +28,7 @@ import { } from "./result-handler"; import { createCacheMiddleware } from "./cache-middleware"; import { createCookieMiddleware } from "./cookie-middleware"; +import { createRateLimitMiddleware } from "./rate-limit-middleware"; interface BuildProps< IN extends IOSchema, @@ -135,6 +136,11 @@ export class EndpointsFactory< return this.#extend(createCacheMiddleware(...args)); } + /** @desc Shorthand for .addMiddleware(createRateLimitMiddleware()) */ + public useRateLimit(...args: Parameters) { + return this.#extend(createRateLimitMiddleware(...args)); + } + /** * @desc Shorthand for addExpressMiddleware(). Use it for wrapping native Express middlewares. * @see addExpressMiddleware From 8992be191ed1e91daef07caaf064d33c338818cc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 20:48:46 +0200 Subject: [PATCH 05/15] feat(test): Testing the new function. --- express-zod-api/tests/peers-mock.ts | 5 +- .../tests/rate-limit-middleware.spec.ts | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 express-zod-api/tests/rate-limit-middleware.spec.ts diff --git a/express-zod-api/tests/peers-mock.ts b/express-zod-api/tests/peers-mock.ts index ad9587ce2..727bfab25 100644 --- a/express-zod-api/tests/peers-mock.ts +++ b/express-zod-api/tests/peers-mock.ts @@ -1,6 +1,7 @@ const compressionMock = vi.fn(); const fileUploadMock = vi.fn(); const cookieParserMock = vi.fn(); +const rateLimitMock = vi.fn(); vi.mock("../src/peer-helpers", () => ({ loadPeer: (moduleName: string) => { @@ -11,10 +12,12 @@ vi.mock("../src/peer-helpers", () => ({ return cookieParserMock; case "express-fileupload": return fileUploadMock; + case "express-rate-limit": + return rateLimitMock; default: throw new Error(`Unhandled peer dependency mock: ${moduleName}`); } }, })); -export { compressionMock, fileUploadMock, cookieParserMock }; +export { compressionMock, fileUploadMock, cookieParserMock, rateLimitMock }; diff --git a/express-zod-api/tests/rate-limit-middleware.spec.ts b/express-zod-api/tests/rate-limit-middleware.spec.ts new file mode 100644 index 000000000..5841a8cf3 --- /dev/null +++ b/express-zod-api/tests/rate-limit-middleware.spec.ts @@ -0,0 +1,69 @@ +import { rateLimitMock } from "./peers-mock.ts"; +import { createRateLimitMiddleware, testMiddleware } from "../src"; + +describe("Rate limit middleware", () => { + describe("createRateLimitMiddleware", () => { + beforeEach(() => { + rateLimitMock.mockReset(); + }); + + test("should create a Middleware instance", () => { + const middleware = createRateLimitMiddleware(); + const { constructor } = Object.getPrototypeOf(middleware); + expect(constructor.name).toBe("ExpressMiddleware"); + }); + + test("should forward config to rateLimit function", () => { + createRateLimitMiddleware({ windowMs: 60000, max: 10 }); + expect(rateLimitMock).toHaveBeenCalledWith( + expect.objectContaining({ windowMs: 60000, max: 10 }), + ); + expect(rateLimitMock).toHaveBeenCalledTimes(1); + }); + + test("should include custom handler in options", () => { + createRateLimitMiddleware({ message: "custom message" }); + const options = rateLimitMock.mock.calls[0]![0]!; + expect(typeof options.handler).toBe("function"); + }); + + test("should call next when within limit", async () => { + rateLimitMock.mockReturnValue((_req: any, _res: any, next: any) => { + next(); + }); + const { output } = await testMiddleware({ + middleware: createRateLimitMiddleware(), + }); + expect(output).toEqual({}); + }); + + test("should reject with 429 when limit exceeded", async () => { + rateLimitMock.mockImplementation((options: any) => { + return (req: any, res: any, next: any) => { + options.handler(req, res, next, options); + }; + }); + const { responseMock } = await testMiddleware({ + middleware: createRateLimitMiddleware({ message: "too fast" }), + }); + expect(responseMock._getStatusCode()).toBe(429); + }); + + test("should populate ctx.rateLimit from request", async () => { + const rateLimitInfo = { + limit: 100, + used: 1, + remaining: 99, + resetTime: new Date("2026-01-01T00:00:00Z"), + }; + rateLimitMock.mockReturnValue((req: any, _res: any, next: any) => { + req.rateLimit = rateLimitInfo; + next(); + }); + const { output } = await testMiddleware({ + middleware: createRateLimitMiddleware(), + }); + expect(output.rateLimit).toEqual(rateLimitInfo); + }); + }); +}); From 24114187b8482d39d01be83b0b41f609176d7c4b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 20:50:23 +0200 Subject: [PATCH 06/15] feat(test): Testing shorthand. --- .../tests/endpoints-factory.spec.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 9696ccec8..2771fefbc 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from "express"; import createHttpError from "http-errors"; -import { expectTypeOf } from "vitest"; +import { expectTypeOf, vi } from "vitest"; import { EndpointsFactory, Middleware, @@ -10,10 +10,19 @@ import { } from "../src"; import * as cookieMw from "../src/cookie-middleware"; import * as cacheMw from "../src/cache-middleware"; +import * as rateLimitMw from "../src/rate-limit-middleware"; import type { EmptyObject } from "../src/common-helpers"; import { Endpoint } from "../src/endpoint"; import { z } from "zod"; +const rateLimitMock = vi.fn(); +vi.mock("../src/peer-helpers", () => ({ + loadPeer: (moduleName: string) => { + if (moduleName === "express-rate-limit") return rateLimitMock; + throw new Error(`Unhandled peer dependency mock: ${moduleName}`); + }, +})); + describe("EndpointsFactory", () => { const resultHandlerMock = new ResultHandler({ positive: z.string(), @@ -150,6 +159,16 @@ describe("EndpointsFactory", () => { }); }); + describe(".useRateLimit", () => { + test("should add created rate limit middleware", () => { + rateLimitMock.mockReturnValue(vi.fn()); + const spy = vi.spyOn(rateLimitMw, "createRateLimitMiddleware"); + const factory = defaultEndpointsFactory.useRateLimit({ max: 20 }); + expect(spy).toHaveBeenCalledWith({ max: 20 }); + expect(factory["middlewares"]).toHaveLength(1); + }); + }); + describe.each(["addExpressMiddleware", "use"] as const)(".%s()", (method) => { test("Should create a new factory with a native express middleware wrapper", async () => { const factory = new EndpointsFactory(resultHandlerMock); From 8a5d26cbf118a2a67253fd8761cbf3d089368908 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 21:28:22 +0200 Subject: [PATCH 07/15] fix(test): rm local mock. --- express-zod-api/tests/endpoints-factory.spec.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 2771fefbc..c96a54772 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -1,6 +1,7 @@ +import "./peers-mock.ts"; import type { RequestHandler } from "express"; import createHttpError from "http-errors"; -import { expectTypeOf, vi } from "vitest"; +import { expectTypeOf } from "vitest"; import { EndpointsFactory, Middleware, @@ -15,14 +16,6 @@ import type { EmptyObject } from "../src/common-helpers"; import { Endpoint } from "../src/endpoint"; import { z } from "zod"; -const rateLimitMock = vi.fn(); -vi.mock("../src/peer-helpers", () => ({ - loadPeer: (moduleName: string) => { - if (moduleName === "express-rate-limit") return rateLimitMock; - throw new Error(`Unhandled peer dependency mock: ${moduleName}`); - }, -})); - describe("EndpointsFactory", () => { const resultHandlerMock = new ResultHandler({ positive: z.string(), @@ -161,7 +154,6 @@ describe("EndpointsFactory", () => { describe(".useRateLimit", () => { test("should add created rate limit middleware", () => { - rateLimitMock.mockReturnValue(vi.fn()); const spy = vi.spyOn(rateLimitMw, "createRateLimitMiddleware"); const factory = defaultEndpointsFactory.useRateLimit({ max: 20 }); expect(spy).toHaveBeenCalledWith({ max: 20 }); From 6639b350e02ab24c69cd0241f121113f3cc857bd Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 21:31:16 +0200 Subject: [PATCH 08/15] fix: add jsdoc. --- express-zod-api/src/rate-limit-middleware.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts index 2899adac9..2ef1df799 100644 --- a/express-zod-api/src/rate-limit-middleware.ts +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -9,12 +9,18 @@ import type { import { ExpressMiddleware } from "./middleware"; import { loadPeer } from "./peer-helpers"; +/** + * @desc Creates an ExpressMiddleware that enforces rate limits using express-rate-limit. + * @requires express-rate-limit + * @param options — Partial options passed to the express-rate-limit constructor. + * @example createRateLimitMiddleware({ windowMs: 60000, max: 100 }) + */ export const createRateLimitMiddleware = ( - config?: Partial, + options?: Partial, ): ExpressMiddleware => { const rateLimit = loadPeer("express-rate-limit"); const handler = rateLimit({ - ...config, + ...options, handler: (_req, _res, next, optionsUsed) => { next(createHttpError(429, optionsUsed.message)); }, From 5c3949283bcfa8b39c668be5256b4ca83bf163b5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 22:35:05 +0200 Subject: [PATCH 09/15] fix(type): simpler typing. --- express-zod-api/src/rate-limit-middleware.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts index 2ef1df799..9e6344794 100644 --- a/express-zod-api/src/rate-limit-middleware.ts +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -1,8 +1,6 @@ import createHttpError from "http-errors"; -import type { Request, Response } from "express"; import type { Options, - RateLimitInfo, AugmentedRequest, rateLimit as RateLimitFn, } from "express-rate-limit"; @@ -15,9 +13,7 @@ import { loadPeer } from "./peer-helpers"; * @param options — Partial options passed to the express-rate-limit constructor. * @example createRateLimitMiddleware({ windowMs: 60000, max: 100 }) */ -export const createRateLimitMiddleware = ( - options?: Partial, -): ExpressMiddleware => { +export const createRateLimitMiddleware = (options?: Partial) => { const rateLimit = loadPeer("express-rate-limit"); const handler = rateLimit({ ...options, @@ -26,7 +22,8 @@ export const createRateLimitMiddleware = ( }, }); return new ExpressMiddleware(handler, { - provider: (req: AugmentedRequest) => - ({ rateLimit: req.rateLimit }) as { rateLimit: RateLimitInfo }, + provider: (req: AugmentedRequest) => ({ + rateLimit: req.rateLimit!, + }), }); }; From b3d7944168c3c54d789ac7bafcfa5cbfe7568380 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 15 Jun 2026 22:40:51 +0200 Subject: [PATCH 10/15] fix(mw): Respecting requestPropertyName option. --- express-zod-api/src/rate-limit-middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts index 9e6344794..b7a39467f 100644 --- a/express-zod-api/src/rate-limit-middleware.ts +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -23,7 +23,7 @@ export const createRateLimitMiddleware = (options?: Partial) => { }); return new ExpressMiddleware(handler, { provider: (req: AugmentedRequest) => ({ - rateLimit: req.rateLimit!, + rateLimit: req[options?.requestPropertyName ?? "rateLimit"], }), }); }; From 6dcfd5425a59adc4b0478aa4448e5c5280454e8c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 16 Jun 2026 08:19:44 +0200 Subject: [PATCH 11/15] ref: naming. --- express-zod-api/src/rate-limit-middleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts index b7a39467f..fd915dab0 100644 --- a/express-zod-api/src/rate-limit-middleware.ts +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -15,13 +15,13 @@ import { loadPeer } from "./peer-helpers"; */ export const createRateLimitMiddleware = (options?: Partial) => { const rateLimit = loadPeer("express-rate-limit"); - const handler = rateLimit({ + const limiter = rateLimit({ ...options, handler: (_req, _res, next, optionsUsed) => { next(createHttpError(429, optionsUsed.message)); }, }); - return new ExpressMiddleware(handler, { + return new ExpressMiddleware(limiter, { provider: (req: AugmentedRequest) => ({ rateLimit: req[options?.requestPropertyName ?? "rateLimit"], }), From ff2d145fcefd2d83c3db5286b5fe3b17a5cb7b83 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 16 Jun 2026 10:10:10 +0200 Subject: [PATCH 12/15] Add limiter API to the provided context. --- express-zod-api/src/rate-limit-middleware.ts | 12 ++++++++- express-zod-api/tests/peers-mock.ts | 16 +++++++++-- .../tests/rate-limit-middleware.spec.ts | 27 +++++++++---------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/express-zod-api/src/rate-limit-middleware.ts b/express-zod-api/src/rate-limit-middleware.ts index fd915dab0..f63019eb7 100644 --- a/express-zod-api/src/rate-limit-middleware.ts +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -3,6 +3,8 @@ import type { Options, AugmentedRequest, rateLimit as RateLimitFn, + RateLimitInfo, + RateLimitRequestHandler, } from "express-rate-limit"; import { ExpressMiddleware } from "./middleware"; import { loadPeer } from "./peer-helpers"; @@ -21,9 +23,17 @@ export const createRateLimitMiddleware = (options?: Partial) => { next(createHttpError(429, optionsUsed.message)); }, }); + const { getKey, resetKey } = limiter; + const limiterApi: Pick = { + getKey, + resetKey, + }; return new ExpressMiddleware(limiter, { provider: (req: AugmentedRequest) => ({ - rateLimit: req[options?.requestPropertyName ?? "rateLimit"], + rateLimit: { + ...limiterApi, + ...req[options?.requestPropertyName ?? "rateLimit"], + } as RateLimitInfo & typeof limiterApi, }), }); }; diff --git a/express-zod-api/tests/peers-mock.ts b/express-zod-api/tests/peers-mock.ts index 727bfab25..4a25fb0b7 100644 --- a/express-zod-api/tests/peers-mock.ts +++ b/express-zod-api/tests/peers-mock.ts @@ -1,7 +1,13 @@ const compressionMock = vi.fn(); const fileUploadMock = vi.fn(); const cookieParserMock = vi.fn(); -const rateLimitMock = vi.fn(); + +const limiterApiMock = { getKey: vi.fn(), resetKey: vi.fn() }; +const rateLimitMock = vi.fn(({}: any) => + Object.assign((_req: any, _res: any, next: any) => { + next(); + }, limiterApiMock), +); vi.mock("../src/peer-helpers", () => ({ loadPeer: (moduleName: string) => { @@ -20,4 +26,10 @@ vi.mock("../src/peer-helpers", () => ({ }, })); -export { compressionMock, fileUploadMock, cookieParserMock, rateLimitMock }; +export { + compressionMock, + fileUploadMock, + cookieParserMock, + rateLimitMock, + limiterApiMock, +}; diff --git a/express-zod-api/tests/rate-limit-middleware.spec.ts b/express-zod-api/tests/rate-limit-middleware.spec.ts index 5841a8cf3..f6fcccc14 100644 --- a/express-zod-api/tests/rate-limit-middleware.spec.ts +++ b/express-zod-api/tests/rate-limit-middleware.spec.ts @@ -1,4 +1,4 @@ -import { rateLimitMock } from "./peers-mock.ts"; +import { limiterApiMock, rateLimitMock } from "./peers-mock.ts"; import { createRateLimitMiddleware, testMiddleware } from "../src"; describe("Rate limit middleware", () => { @@ -28,21 +28,18 @@ describe("Rate limit middleware", () => { }); test("should call next when within limit", async () => { - rateLimitMock.mockReturnValue((_req: any, _res: any, next: any) => { - next(); - }); const { output } = await testMiddleware({ middleware: createRateLimitMiddleware(), }); - expect(output).toEqual({}); + expect(output).toEqual({ rateLimit: limiterApiMock }); }); test("should reject with 429 when limit exceeded", async () => { - rateLimitMock.mockImplementation((options: any) => { - return (req: any, res: any, next: any) => { + rateLimitMock.mockImplementation((options: any) => + Object.assign((req: any, res: any, next: any) => { options.handler(req, res, next, options); - }; - }); + }, limiterApiMock), + ); const { responseMock } = await testMiddleware({ middleware: createRateLimitMiddleware({ message: "too fast" }), }); @@ -56,14 +53,16 @@ describe("Rate limit middleware", () => { remaining: 99, resetTime: new Date("2026-01-01T00:00:00Z"), }; - rateLimitMock.mockReturnValue((req: any, _res: any, next: any) => { - req.rateLimit = rateLimitInfo; - next(); - }); + rateLimitMock.mockReturnValue( + Object.assign((req: any, _res: any, next: any) => { + req.rateLimit = rateLimitInfo; + next(); + }, limiterApiMock), + ); const { output } = await testMiddleware({ middleware: createRateLimitMiddleware(), }); - expect(output.rateLimit).toEqual(rateLimitInfo); + expect(output.rateLimit).toEqual({ ...rateLimitInfo, ...limiterApiMock }); }); }); }); From 9f28809b3c0e1c84b12b6ac3ec9a59d668f9cff3 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 16 Jun 2026 10:28:25 +0200 Subject: [PATCH 13/15] Readme and Changelog updates. --- CHANGELOG.md | 9 +++++++++ README.md | 40 +++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d460815..0309d92b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Version 28 +### v28.7.0 + +- Added rate limiting support: + - Requires `express-rate-limit` — new optional peer dependency; + - Added the `createRateLimitMiddleware()` method and `EndpointsFactory::useRateLimit()` shorthand; + - When the limit is exceeded, the Middleware throws a `429` HTTP error, handled by your ResultHandler. + - The middleware provides `rateLimit` property to the context with the current rate limit info (`limit`, `used`, + `remaining`, `resetTime`) and the limiter API (`getKey()` and `resetKey()` methods) for programmatic management. + ### v28.6.0 - Changes to the `Integration` generator: diff --git a/README.md b/README.md index c02936131..23da8d5af 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,17 @@ Start your API server with I/O schema validation and custom middlewares in minut 2. [Headers as an input source](#headers-as-an-input-source) 3. [Cookies](#cookies) 4. [Caching](#caching) - 5. [Response customization](#response-customization) - 6. [Empty response](#empty-response) - 7. [Non-JSON response](#non-json-response) including file downloads - 8. [Error handling](#error-handling) - 9. [Production mode](#production-mode) - 10. [HTML Forms (URL encoded)](#html-forms-url-encoded) - 11. [File uploads](#file-uploads) - 12. [Connect to your own express app](#connect-to-your-own-express-app) - 13. [Testing endpoints](#testing-endpoints) - 14. [Testing middlewares](#testing-middlewares) + 5. [Rate limiting](#rate-limiting) + 6. [Response customization](#response-customization) + 7. [Empty response](#empty-response) + 8. [Non-JSON response](#non-json-response) including file downloads + 9. [Error handling](#error-handling) + 10. [Production mode](#production-mode) + 11. [HTML Forms (URL encoded)](#html-forms-url-encoded) + 12. [File uploads](#file-uploads) + 13. [Connect to your own express app](#connect-to-your-own-express-app) + 14. [Testing endpoints](#testing-endpoints) + 15. [Testing middlewares](#testing-middlewares) 6. [Integration and Documentation](#integration-and-documentation) 1. [Zod Plugin](#zod-plugin) 2. [End-to-End Type Safety](#end-to-end-type-safety) @@ -841,7 +842,7 @@ Consider `createCookieMiddleware()` that makes a Middleware providing `setCookie as well as `getCookie()` — alternative to the cookies as an input source: ```ts -import { createCookieMiddleware, Middleware } from "express-zod-api"; +import { Middleware } from "express-zod-api"; const cookieDrivenFactory = factory .useCookies({ httpOnly: true, sameSite: "lax", path: "/" }) // shorthand, recommended base options @@ -868,8 +869,6 @@ Consider the `createCacheMiddleware()` that provides helpers for HTTP caching fo It covers all standard `Cache-Control` directives, conditional request handling, and the "Not Modified" (304) flow: ```ts -import { createCacheMiddleware } from "express-zod-api"; - const avatarEndpoint = factory .useCache({ maxAge: 3600, scope: "public" }) // shorthand, the policy applies to every response .build({ @@ -886,6 +885,21 @@ const avatarEndpoint = factory }); ``` +## Rate limiting + +Install `express-rate-limit`. Consider the `createRateLimitMiddleware()` to enable and configure rate limit on a certain +`EndpointsFactory`. When the limit is exceeded, the Middleware throws a `429` HTTP error, handled by your ResultHandler. + +```ts +const endpoint = factory + .useRateLimit({ windowMs: 60000, max: 100 }) // shorthand, or .addMiddleware(createRateLimitMiddleware()) + .buildVoid({ + handler: async ({ ctx: { rateLimit, logger } }) => { + logger.debug("Features", rateLimit); // { limit, used, remaining, resetTime, getKey, resetKey } + }, + }); +``` + ## Response customization `ResultHandler` is responsible for transmitting consistent responses containing the endpoint output or an error. From 4d76a9690e6c3b55d8c3d6f8813cca424141722f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 16 Jun 2026 12:31:27 +0200 Subject: [PATCH 14/15] Add example and its tests. --- AGENTS.md | 1 + example/factories.ts | 7 ++++--- example/index.spec.ts | 47 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bddbed7b7..a305e13b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ See `src/index.ts` for the complete public API exports. - **`express-zod-api/src/routing.ts`** — Core routing logic - **`express-zod-api/src/testing.ts`** — Test utilities: `testEndpoint` and `testMiddleware` are public, others internal - **`express-zod-api/tests/express-mock.ts`** — Express mocking for tests +- **`express-zod-api/tests/peers-mock.ts`** — Mocks for compression, cookie-parser, express-fileupload, express-rate-limit - **`migration/index.ts`** — Migration ESLint rules - **`CHANGELOG.md`** — Version history and breaking changes diff --git a/example/factories.ts b/example/factories.ts index 6c7d47f3e..95272eacf 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -16,9 +16,10 @@ import { createReadStream } from "node:fs"; import { z } from "zod"; import { stat } from "node:fs/promises"; -/** @desc This factory extends the default one by enforcing the authentication using the specified middleware */ -export const keyAndTokenAuthenticatedEndpointsFactory = - defaultEndpointsFactory.addMiddleware(authMiddleware); +/** @desc This factory extends the default one by enforcing rate limiting and authentication */ +export const keyAndTokenAuthenticatedEndpointsFactory = defaultEndpointsFactory + .useRateLimit({ windowMs: 60000, max: 10 }) + .addMiddleware(authMiddleware); /** @desc This factory adds session read from cookie into context */ export const cookieAuthenticatedFactory = diff --git a/example/index.spec.ts b/example/index.spec.ts index 397054371..93de5546e 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -64,7 +64,9 @@ describe("Example", async () => { }); }); - test("Should handle valid PATCH request", async ({ signal }) => { + test("Should handle valid PATCH request with rate limit headers", async ({ + signal, + }) => { const response = await fetch(`http://localhost:${port}/v1/user/50`, { signal, method: "PATCH", @@ -79,6 +81,10 @@ describe("Example", async () => { }), }); expect(response.status).toBe(200); + expect(response.headers.get("X-RateLimit-Limit")).toBe("10"); + expect( + Number(response.headers.get("X-RateLimit-Remaining")), + ).toBeGreaterThanOrEqual(5); const json = await response.json(); expect(json).toMatchObject({ status: "success", @@ -679,4 +685,43 @@ describe("Example", async () => { expectTypeOf(response).toBeUndefined(); }); }); + + describe("Rate limiting", () => { + test("Should rate limit the update endpoint after exceeding max requests", async ({ + signal, + }) => { + const makeValidPatchRequest = () => + fetch(`http://localhost:${port}/v1/user/50`, { + signal, + method: "PATCH", + headers: { + token: "456", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "123", + name: "John Doe", + birthday: "1974-10-28", + }), + }); + let response: Response; + for (let i = 0; i < 10; i++) { + response = await makeValidPatchRequest(); + if (response.status === 429) break; + } + expect(response!.status).toBe(429); + expect(response!.headers.get("X-RateLimit-Limit")).toBe("10"); + expect( + Number(response!.headers.get("X-RateLimit-Remaining")), + ).toBeLessThanOrEqual(0); + expect( + Number(response!.headers.get("X-RateLimit-Reset")), + ).toBeGreaterThan(Date.now() / 1000); + const json = await response!.json(); + expect(json).toMatchObject({ + status: "error", + error: { message: "Too many requests, please try again later." }, + }); + }); + }); }); From 9f169801e73a8e68f35009e6ee7f0217312e0ca0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 16 Jun 2026 12:33:09 +0200 Subject: [PATCH 15/15] changelog: fix punctuation. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0309d92b3..68c0ce208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Added rate limiting support: - Requires `express-rate-limit` — new optional peer dependency; - Added the `createRateLimitMiddleware()` method and `EndpointsFactory::useRateLimit()` shorthand; - - When the limit is exceeded, the Middleware throws a `429` HTTP error, handled by your ResultHandler. + - When the limit is exceeded, the Middleware throws a `429` HTTP error, handled by your ResultHandler; - The middleware provides `rateLimit` property to the context with the current rate limit info (`limit`, `used`, `remaining`, `resetTime`) and the limiter API (`getKey()` and `resetKey()` methods) for programmatic management.