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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
pullfrog[bot] marked this conversation as resolved.

### v28.6.0

- Changes to the `Integration` generator:
Expand Down
40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions example/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
47 changes: 46 additions & 1 deletion example/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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." },
});
});
});
});
5 changes: 5 additions & 0 deletions express-zod-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -89,6 +90,9 @@
"express-fileupload": {
"optional": true
},
"express-rate-limit": {
"optional": true
},
"typescript": {
"optional": true
}
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions express-zod-api/src/endpoints-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -135,6 +136,11 @@ export class EndpointsFactory<
return this.#extend(createCacheMiddleware(...args));
}

/** @desc Shorthand for .addMiddleware(createRateLimitMiddleware()) */
public useRateLimit(...args: Parameters<typeof createRateLimitMiddleware>) {
return this.#extend(createRateLimitMiddleware(...args));
}

/**
* @desc Shorthand for addExpressMiddleware(). Use it for wrapping native Express middlewares.
* @see addExpressMiddleware
Expand Down
1 change: 1 addition & 0 deletions express-zod-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions express-zod-api/src/rate-limit-middleware.ts
Original file line number Diff line number Diff line change
@@ -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<Options>) => {
const rateLimit = loadPeer<typeof RateLimitFn>("express-rate-limit");
const limiter = rateLimit({
...options,
handler: (_req, _res, next, optionsUsed) => {
next(createHttpError(429, optionsUsed.message));
},
});
const { getKey, resetKey } = limiter;
const limiterApi: Pick<RateLimitRequestHandler, "getKey" | "resetKey"> = {
getKey,
resetKey,
};
return new ExpressMiddleware(limiter, {
provider: (req: AugmentedRequest) => ({
rateLimit: {
...limiterApi,
...req[options?.requestPropertyName ?? "rateLimit"],
} as RateLimitInfo & typeof limiterApi,
}),
});
};
3 changes: 3 additions & 0 deletions express-zod-api/tests/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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`] = `
Expand Down Expand Up @@ -86,6 +88,7 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] =
"Middleware",
"createCookieMiddleware",
"createCacheMiddleware",
"createRateLimitMiddleware",
"ResultHandler",
"defaultResultHandler",
"arrayResultHandler",
Expand Down
11 changes: 11 additions & 0 deletions express-zod-api/tests/endpoints-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./peers-mock.ts";
import type { RequestHandler } from "express";
import createHttpError from "http-errors";
import { expectTypeOf } from "vitest";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion express-zod-api/tests/peers-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
};
Loading