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/CHANGELOG.md b/CHANGELOG.md index 15d460815..68c0ce208 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. 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." }, + }); + }); + }); }); diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 890d93582..be1f5a521 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/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 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..f63019eb7 --- /dev/null +++ b/express-zod-api/src/rate-limit-middleware.ts @@ -0,0 +1,39 @@ +import createHttpError from "http-errors"; +import type { + Options, + AugmentedRequest, + rateLimit as RateLimitFn, + RateLimitInfo, + RateLimitRequestHandler, +} from "express-rate-limit"; +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 = (options?: Partial) => { + const rateLimit = loadPeer("express-rate-limit"); + const limiter = rateLimit({ + ...options, + handler: (_req, _res, next, optionsUsed) => { + next(createHttpError(429, optionsUsed.message)); + }, + }); + const { getKey, resetKey } = limiter; + const limiterApi: Pick = { + getKey, + resetKey, + }; + return new ExpressMiddleware(limiter, { + provider: (req: AugmentedRequest) => ({ + rateLimit: { + ...limiterApi, + ...req[options?.requestPropertyName ?? "rateLimit"], + } as RateLimitInfo & typeof limiterApi, + }), + }); +}; 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", diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 9696ccec8..c96a54772 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -1,3 +1,4 @@ +import "./peers-mock.ts"; import type { RequestHandler } from "express"; import createHttpError from "http-errors"; import { expectTypeOf } from "vitest"; @@ -10,6 +11,7 @@ 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"; @@ -150,6 +152,15 @@ describe("EndpointsFactory", () => { }); }); + describe(".useRateLimit", () => { + test("should add created rate limit middleware", () => { + 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); diff --git a/express-zod-api/tests/peers-mock.ts b/express-zod-api/tests/peers-mock.ts index ad9587ce2..4a25fb0b7 100644 --- a/express-zod-api/tests/peers-mock.ts +++ b/express-zod-api/tests/peers-mock.ts @@ -2,6 +2,13 @@ const compressionMock = vi.fn(); const fileUploadMock = vi.fn(); const cookieParserMock = 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) => { switch (moduleName) { @@ -11,10 +18,18 @@ 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, + limiterApiMock, +}; 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..f6fcccc14 --- /dev/null +++ b/express-zod-api/tests/rate-limit-middleware.spec.ts @@ -0,0 +1,68 @@ +import { limiterApiMock, 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 () => { + const { output } = await testMiddleware({ + middleware: createRateLimitMiddleware(), + }); + expect(output).toEqual({ rateLimit: limiterApiMock }); + }); + + test("should reject with 429 when limit exceeded", async () => { + 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" }), + }); + 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( + Object.assign((req: any, _res: any, next: any) => { + req.rateLimit = rateLimitInfo; + next(); + }, limiterApiMock), + ); + const { output } = await testMiddleware({ + middleware: createRateLimitMiddleware(), + }); + expect(output.rateLimit).toEqual({ ...rateLimitInfo, ...limiterApiMock }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 958cc4762..31b940b17 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