first commit
This commit is contained in:
168
docs/foundation/flow/rules-hono/src/middleware/middleware.md
Normal file
168
docs/foundation/flow/rules-hono/src/middleware/middleware.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Hono middleware — `@suaveplan/rules-hono/middleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Six middleware factories that wire `@suaveplan/rules` into a Hono app: `ruleMiddleware`, `validationMiddleware`, `accessControl`, `responseValidation`, `rateLimitRule`, `combineRules`. Each factory accepts a Zod-shaped `Schemalike` (any object exposing `safeParse`), an optional caller-supplied `RuleEngine` (falling back to the lazy `getDefaultEngine`), and per-factory callbacks that override the default deny / error responses.
|
||||
|
||||
Failure modes return structured JSON envelopes (`403` for rule denial, `400` for input validation, `429` for rate-limit, `500` for internal errors) so a Hono app can render them uniformly without bespoke error handlers per route. OTel histograms and counters fire per middleware with an `outcome` label (`pass` / `fail` / `error` / `short-circuit` / `skipped`).
|
||||
|
||||
## Features
|
||||
|
||||
- `ruleMiddleware<TData>(rule, options?)` — evaluate a single `Rule`, store the result in `c.var.ruleResult`, 403 on miss.
|
||||
- `validationMiddleware(options)` — schema-validate body / query / params / headers; 400 on miss; `abortEarly` truncates at first failing source.
|
||||
- `accessControl<TData>(options)` — `Rule` or `Ruleset` evaluation with optional per-key cache via `AccessControlCache`.
|
||||
- `responseValidation(options)` — validate the downstream response body; replaces non-conforming responses with 500.
|
||||
- `rateLimitRule<TData>(options)` — 429 on miss with optional `Retry-After` header.
|
||||
- `combineRules(...middlewares)` — chain middlewares with first-response short-circuit.
|
||||
- `MemoryAccessControlCache` — drop-in `AccessControlCache` impl with TTL.
|
||||
- `getDefaultEngine()` (re-exported from `../engine`) — lazy per-process singleton.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { condition } from "@suaveplan/rules";
|
||||
import { ruleMiddleware } from "@suaveplan/rules-hono/middleware";
|
||||
|
||||
const app = new Hono();
|
||||
app.use("*", ruleMiddleware(condition("age", "greaterThanOrEqual", 18)));
|
||||
app.get("/", (c) => c.json({ ok: true }));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import z from "zod";
|
||||
import { condition, RuleEngine } from "@suaveplan/rules";
|
||||
import {
|
||||
accessControl,
|
||||
combineRules,
|
||||
MemoryAccessControlCache,
|
||||
rateLimitRule,
|
||||
responseValidation,
|
||||
ruleMiddleware,
|
||||
validationMiddleware,
|
||||
} from "@suaveplan/rules-hono/middleware";
|
||||
|
||||
const engine = new RuleEngine({ maxDepth: 16 });
|
||||
const cache = new MemoryAccessControlCache();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use(
|
||||
"/admin/*",
|
||||
combineRules(
|
||||
validationMiddleware({
|
||||
headers: z.object({ "x-tenant": z.string().min(1) }),
|
||||
}),
|
||||
accessControl({
|
||||
rules: condition("role", "equals", "admin"),
|
||||
engine,
|
||||
extractContext: (c) => ({
|
||||
data: {
|
||||
role: c.req.header("x-role") ?? "guest",
|
||||
},
|
||||
}),
|
||||
cacheKey: (c) => c.req.header("x-tenant") ?? "anon",
|
||||
cache,
|
||||
cacheTtlMs: 30_000,
|
||||
}),
|
||||
rateLimitRule({
|
||||
rules: condition("requests", "lessThan", 100),
|
||||
extractContext: () => ({ data: { requests: 1 } }),
|
||||
retryAfterSeconds: 60,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/users",
|
||||
validationMiddleware({
|
||||
body: z.object({ email: z.string().email() }),
|
||||
abortEarly: true,
|
||||
}),
|
||||
responseValidation({
|
||||
schema: z.object({ id: z.string() }),
|
||||
}),
|
||||
async (c) => c.json({ id: "u1" }),
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ruleMiddleware<TData>(rule, options?)`
|
||||
|
||||
| Option | Type | Default |
|
||||
|---|---|---|
|
||||
| `engine` | `RuleEngine` | `getDefaultEngine()` |
|
||||
| `extractData(c)` | sync/async | reads JSON body if `content-type: application/json` |
|
||||
| `onError(c, err)` | sync/async | `c.json({ error }, 500)` |
|
||||
| `onDenied(c, result)` | sync/async | `c.json({ passed: false, errors }, 403)` |
|
||||
|
||||
Stores the `EvaluationResult` on `c.var.ruleResult`.
|
||||
|
||||
### `validationMiddleware(options)`
|
||||
|
||||
| Option | Type | Notes |
|
||||
|---|---|---|
|
||||
| `body` / `query` / `params` / `headers` | `Schemalike` | any object with `safeParse`. |
|
||||
| `abortEarly` | `boolean` | when `true`, stop at first failing source. |
|
||||
| `onError(c, errors)` | sync/async | default `c.json({ valid: false, errors }, 400)`. |
|
||||
|
||||
`Schemalike` rejection issues are coerced into `HonoValidationError { source, field, message, code? }`.
|
||||
|
||||
### `accessControl<TData>(options)`
|
||||
|
||||
| Option | Type | Notes |
|
||||
|---|---|---|
|
||||
| `rules` | `Rule \| Ruleset` | discriminated via the presence of `id` + `name`. |
|
||||
| `engine` | `RuleEngine` | default `getDefaultEngine()`. |
|
||||
| `extractContext(c)` | sync/async | default reads JSON body. |
|
||||
| `cacheKey(c)` | `() => string \| undefined` | required when `cache` is set. |
|
||||
| `cache` | `AccessControlCache` | optional. |
|
||||
| `cacheTtlMs` | `number` | default `5000`. |
|
||||
| `onDenied(c, result)` | sync/async | default `c.json({ passed: false, errors }, 403)`. |
|
||||
|
||||
Stores the result on `c.var.accessResult`. Throws `RulesError(RULES.PLUGIN_FAILED)` at construction when `cache` is set without `cacheKey`.
|
||||
|
||||
### `responseValidation(options)`
|
||||
|
||||
| Option | Type | Notes |
|
||||
|---|---|---|
|
||||
| `schema` | `Schemalike` | validates the JSON response body. |
|
||||
| `onError(c, errors)` | sync/async | default `c.json({ valid: false, errors }, 500)`. |
|
||||
|
||||
Non-JSON responses pass through unchanged (`outcome: "skipped"`).
|
||||
|
||||
### `rateLimitRule<TData>(options)`
|
||||
|
||||
| Option | Type | Notes |
|
||||
|---|---|---|
|
||||
| `rules` | `Rule \| Ruleset` | |
|
||||
| `extractContext(c)` | sync/async | default reads JSON body. |
|
||||
| `engine` | `RuleEngine` | default `getDefaultEngine()`. |
|
||||
| `retryAfterSeconds` | `number` | sets the `Retry-After` header on 429. |
|
||||
| `onLimited(c, result)` | sync/async | default `c.json({ limited: true, errors }, 429, headers)`. |
|
||||
|
||||
### `combineRules(...middlewares)`
|
||||
|
||||
Chains middlewares sequentially; the first child whose `next()` is never called terminates the chain and its response is propagated.
|
||||
|
||||
### `class MemoryAccessControlCache`
|
||||
|
||||
- `get(key) -> EvaluationResult | undefined` — drops expired entries lazily.
|
||||
- `set(key, value, ttlMs) -> void`.
|
||||
- `size() -> number`.
|
||||
- `clear() -> void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `Schemalike` interface is intentionally narrow (`safeParse` returning a `{ success: true, data }` or `{ success: false, error: { issues: [...] } }` discriminated union). Zod, Valibot, and `@suaveplan/validation` all satisfy this shape without adapter code.
|
||||
- `responseValidation` reads `c.res.clone()` so the downstream consumer can still read the original response body. The clone path is paid only when the downstream `content-type` is JSON — non-JSON responses skip the validation entirely.
|
||||
- `combineRules` tracks `advanced` per child via the supplied `next()` argument; if `next` was never called, the child wrote a response and the chain terminates.
|
||||
- `MemoryAccessControlCache` does not run a background eviction loop — it drops entries lazily on read. Long-lived processes that worry about unbounded growth should periodically call `cache.clear()` or supply their own `AccessControlCache` implementation backed by Redis / LRU.
|
||||
- Every middleware records an OTel histogram + counter even on error paths so dashboards can distinguish `pass` / `fail` / `error` / `short-circuit` outcomes without parsing logs.
|
||||
Reference in New Issue
Block a user