Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type RateLimitPluginOptionsInput,
|
||||
type RateLimitPluginOptionsResolved,
|
||||
RateLimitPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,87 @@
|
||||
# `schemas/options` — Zod schema for `createRateLimitPlugin` options
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates the caller-supplied `RateLimitPluginOptions` at the
|
||||
boundary and fills in defaults for every optional field, producing the
|
||||
resolved `RateLimitPluginConfig` the plugin's `onInit` lifecycle and
|
||||
the `RateLimitService` rely on. The schema is the single source of
|
||||
truth for what shape `createRateLimitPlugin` accepts — the factory
|
||||
calls `safeParse(...)` and throws `RateLimitPluginError(INVALID_INPUT)`
|
||||
with the underlying `ZodError` attached as `cause` on validation
|
||||
failure, so callers see a typed error rather than a runtime crash.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultLimit` / `defaultWindowMs` mandatory positive integers.
|
||||
- `routes`, `tenants`, `ipWhitelist` default to empty arrays.
|
||||
- `keyResolver` defaults to `${ip}|${tenantId ?? "_"}` — composable
|
||||
isolation between IPs and tenants out of the box.
|
||||
- `headerStyle` enum: `"x-rate-limit"` (default), `"rfc-9238"`,
|
||||
`"both"`.
|
||||
- `emitRetryAfter` defaults to `true`.
|
||||
- `storage` is a custom Zod runtime check accepting any
|
||||
`RateLimitStorage`-shaped object — caller-supplied adapters
|
||||
(Redis, SurrealDB) pass through untouched.
|
||||
- `.strict()` so typos in option names fail loudly instead of
|
||||
silently doing nothing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const config = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const result = RateLimitPluginOptionsSchema.safeParse({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
routes: [
|
||||
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
|
||||
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
|
||||
],
|
||||
tenants: [
|
||||
{ tenantId: "enterprise", multiplier: 10 },
|
||||
{ tenantId: "internal", bypass: true },
|
||||
],
|
||||
ipWhitelist: ["127.0.0.1"],
|
||||
headerStyle: "both",
|
||||
});
|
||||
if (!result.success) console.error(result.error.issues);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RateLimitPluginOptionsSchema` — Zod schema instance. Parses
|
||||
`RateLimitPluginOptions` (input) into `RateLimitPluginConfig`
|
||||
(output) with every default filled in.
|
||||
- `RateLimitPluginOptionsInput` — `z.input<typeof
|
||||
RateLimitPluginOptionsSchema>` — what callers supply.
|
||||
- `RateLimitPluginOptionsResolved` — `z.output<typeof
|
||||
RateLimitPluginOptionsSchema>` — what the plugin receives.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `keyResolver` uses `z.custom<RateLimitKeyResolver>` — functions
|
||||
cannot be expressed as a JSON-Schema-compatible Zod shape; the
|
||||
custom check enforces `typeof === "function"`.
|
||||
- `storage` and `logger` are runtime references; the schema preserves
|
||||
them as-is via `z.custom` rather than attempting to validate their
|
||||
internal shape.
|
||||
- `defaults` declared inline so `RateLimitPluginOptionsResolved` is a
|
||||
total interface — no `undefined` fallbacks in the consumer.
|
||||
- The schema is `.strict()` to catch typos like
|
||||
`defaultLimti: 100` at the boundary.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RateLimitPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("RateLimitPluginOptionsSchema", () => {
|
||||
it("fills defaults for every optional field", { timeout: 5000 }, () => {
|
||||
const result = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
});
|
||||
expect(result.routes).toEqual([]);
|
||||
expect(result.tenants).toEqual([]);
|
||||
expect(result.ipWhitelist).toEqual([]);
|
||||
expect(typeof result.keyResolver).toBe("function");
|
||||
expect(result.headerStyle).toBe("x-rate-limit");
|
||||
expect(result.emitRetryAfter).toBe(true);
|
||||
});
|
||||
|
||||
it("default keyResolver renders ${ip}|${tenant}", { timeout: 5000 }, () => {
|
||||
const result = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 1,
|
||||
defaultWindowMs: 1,
|
||||
});
|
||||
expect(
|
||||
result.keyResolver({
|
||||
ip: "127.0.0.1",
|
||||
tenantId: undefined,
|
||||
path: "/",
|
||||
method: "GET",
|
||||
})
|
||||
).toBe("127.0.0.1|_");
|
||||
expect(
|
||||
result.keyResolver({
|
||||
ip: "1.2.3.4",
|
||||
tenantId: "ent",
|
||||
path: "/",
|
||||
method: "GET",
|
||||
})
|
||||
).toBe("1.2.3.4|ent");
|
||||
});
|
||||
|
||||
it("rejects negative defaultLimit", { timeout: 5000 }, () => {
|
||||
const result = RateLimitPluginOptionsSchema.safeParse({
|
||||
defaultLimit: -1,
|
||||
defaultWindowMs: 1_000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
const result = RateLimitPluginOptionsSchema.safeParse({
|
||||
defaultLimit: 1,
|
||||
defaultWindowMs: 1,
|
||||
// @ts-expect-error — typos must fail loudly
|
||||
defaultLimti: 999,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through caller-supplied keyResolver", { timeout: 5000 }, () => {
|
||||
const custom = () => "k";
|
||||
const result = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 1,
|
||||
defaultWindowMs: 1,
|
||||
keyResolver: custom,
|
||||
});
|
||||
expect(result.keyResolver).toBe(custom);
|
||||
});
|
||||
|
||||
it("validates RouteRule shape and rejects empty path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = RateLimitPluginOptionsSchema.safeParse({
|
||||
defaultLimit: 1,
|
||||
defaultWindowMs: 1,
|
||||
routes: [{ path: "", limit: 1, windowMs: 1 }],
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates TenantRule shape", { timeout: 5000 }, () => {
|
||||
const good = RateLimitPluginOptionsSchema.safeParse({
|
||||
defaultLimit: 1,
|
||||
defaultWindowMs: 1,
|
||||
tenants: [
|
||||
{ tenantId: "t1", multiplier: 2 },
|
||||
{ tenantId: "t2", bypass: true },
|
||||
],
|
||||
});
|
||||
expect(good.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts all three header styles", { timeout: 5000 }, () => {
|
||||
for (const headerStyle of [
|
||||
"x-rate-limit",
|
||||
"rfc-9238",
|
||||
"both",
|
||||
] as const) {
|
||||
const r = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 1,
|
||||
defaultWindowMs: 1,
|
||||
headerStyle,
|
||||
});
|
||||
expect(r.headerStyle).toBe(headerStyle);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import z from "zod";
|
||||
import type {
|
||||
RateLimitKeyResolver,
|
||||
RateLimitPluginConfig,
|
||||
RateLimitPluginOptions,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Default key resolver — `${ip}|${tenantId ?? "_"}`. Uses pipe rather
|
||||
* than colon to avoid colliding with `keyPrefix:storage-key` joins in
|
||||
* the underlying storage adapter.
|
||||
*/
|
||||
const defaultKeyResolver: RateLimitKeyResolver = (input) =>
|
||||
`${input.ip}|${input.tenantId ?? "_"}`;
|
||||
|
||||
const RouteRuleSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
method: z.string().min(1).optional(),
|
||||
limit: z.number().int().positive(),
|
||||
windowMs: z.number().int().positive(),
|
||||
id: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const TenantRuleSchema = z.object({
|
||||
tenantId: z.string().min(1),
|
||||
multiplier: z.number().positive().optional(),
|
||||
bypass: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for the plugin's caller-facing options. Applies defaults
|
||||
* for every optional field so the resolved config is fully populated.
|
||||
*
|
||||
* `storage` and `logger` are skipped because their values are runtime
|
||||
* objects, not serialisable — they are passed through the schema
|
||||
* untouched via `z.custom`.
|
||||
*/
|
||||
export const RateLimitPluginOptionsSchema = z
|
||||
.object({
|
||||
defaultLimit: z.number().int().nonnegative(),
|
||||
defaultWindowMs: z.number().int().positive(),
|
||||
routes: z.array(RouteRuleSchema).default([]),
|
||||
tenants: z.array(TenantRuleSchema).default([]),
|
||||
ipWhitelist: z.array(z.string().min(1)).default([]),
|
||||
keyResolver: z
|
||||
.custom<RateLimitKeyResolver>((v) => typeof v === "function")
|
||||
.default(() => defaultKeyResolver),
|
||||
headerStyle: z
|
||||
.enum(["x-rate-limit", "rfc-9238", "both"])
|
||||
.default("x-rate-limit"),
|
||||
emitRetryAfter: z.boolean().default(true),
|
||||
storage: z.custom<RateLimitPluginConfig["storage"]>().optional(),
|
||||
logger: z.custom<RateLimitPluginConfig["logger"]>().optional(),
|
||||
})
|
||||
.strict() satisfies z.ZodType<
|
||||
RateLimitPluginConfig,
|
||||
RateLimitPluginOptions
|
||||
>;
|
||||
|
||||
export type RateLimitPluginOptionsInput = z.input<
|
||||
typeof RateLimitPluginOptionsSchema
|
||||
>;
|
||||
export type RateLimitPluginOptionsResolved = z.output<
|
||||
typeof RateLimitPluginOptionsSchema
|
||||
>;
|
||||
Reference in New Issue
Block a user