7.2 KiB
Hono middleware — @suaveplan/rules-hono/middleware
(no submodules)
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 singleRule, store the result inc.var.ruleResult, 403 on miss.validationMiddleware(options)— schema-validate body / query / params / headers; 400 on miss;abortEarlytruncates at first failing source.accessControl<TData>(options)—RuleorRulesetevaluation with optional per-key cache viaAccessControlCache.responseValidation(options)— validate the downstream response body; replaces non-conforming responses with 500.rateLimitRule<TData>(options)— 429 on miss with optionalRetry-Afterheader.combineRules(...middlewares)— chain middlewares with first-response short-circuit.MemoryAccessControlCache— drop-inAccessControlCacheimpl with TTL.getDefaultEngine()(re-exported from../engine) — lazy per-process singleton.
Usage: Basic Example
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
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
Schemalikeinterface is intentionally narrow (safeParsereturning a{ success: true, data }or{ success: false, error: { issues: [...] } }discriminated union). Zod, Valibot, and@suaveplan/validationall satisfy this shape without adapter code. responseValidationreadsc.res.clone()so the downstream consumer can still read the original response body. The clone path is paid only when the downstreamcontent-typeis JSON — non-JSON responses skip the validation entirely.combineRulestracksadvancedper child via the suppliednext()argument; ifnextwas never called, the child wrote a response and the chain terminates.MemoryAccessControlCachedoes not run a background eviction loop — it drops entries lazily on read. Long-lived processes that worry about unbounded growth should periodically callcache.clear()or supply their ownAccessControlCacheimplementation backed by Redis / LRU.- Every middleware records an OTel histogram + counter even on error paths so dashboards can distinguish
pass/fail/error/short-circuitoutcomes without parsing logs.