7.6 KiB
Capability Spec Delta: api-router (api-router)
Package: @suaveplan/api-router
Status: PROPOSED
Every requirement below carries a
**Verified by:**line.
ADDED Requirements
Requirement: Abstract Router interface
The package SHALL export the Router interface with verb methods (get, post, put, patch, delete, head, options, all), a use(middleware: Middleware) chain-appender, and a mount(prefix: string, sub: Router) composition method. Each verb method MUST accept (path, options: RouteOptions, handler: RouteHandler) AND (path, handler) (options optional). The interface MUST be generic over Caps extends CapabilityMap so handlers receive a typed RequestContext<Caps>.
Scenario: every verb method is declared
- WHEN
expectTypeOf<Router>()is checked - THEN every HTTP verb is callable with both 2-arg and 3-arg forms
Scenario: mount prefixes child routes
- WHEN a parent router
rmounts child routercat prefix/billing, ANDcdeclaresGET /invoices - THEN the parent's
match("GET", "/billing/invoices")returns the child's handler
Verified by: src/router/router.spec-conformance.test.ts::"Router declares every verb method with overloaded signatures" AND src/router/router.test.ts::"mount prefixes child routes"
Requirement: import z from "zod" everywhere
Every .ts file using Zod MUST use the default-import form. The named-import form (import z from "zod") is forbidden.
Scenario: no named import of z
- WHEN the test greps every
src/**/*.ts - THEN zero matches for
import zfrom"zod"
Verified by: tests/zod-import-form.test.ts::"every src file uses default-import z"
Requirement: RouteOptions schema covers the design-doc keys
The package SHALL export routeOptionsSchema: z.ZodSchema<RouteOptions> whose inferred type matches RouteOptions from @suaveplan/api-types. The schema MUST include: auth?: string, summary?: string, tags?: readonly string[], request?: { body?, params?, query? } (each a z.ZodSchema), responses?: Record<number, { body: z.ZodSchema }>, runtimeValidation (default true), openapi (default true), streaming (default false), csrf?: boolean, idempotent?: boolean, rateLimit?: { rpm: number } | { rps: number }.
Scenario: defaults apply when fields are absent
- WHEN
routeOptionsSchema.parse({})is called - THEN
runtimeValidation === trueANDopenapi === trueANDstreaming === false
Scenario: unknown option keys rejected
- WHEN
routeOptionsSchema.parse({ badKey: 1 })is called - THEN Zod reports an issue with path
["badKey"]
Verified by: src/route-options/route-options.test.ts::"defaults apply when fields absent" AND src/route-options/route-options.spec-conformance.test.ts::"rejects unknown option keys"
Requirement: Middleware composition with insertion-point guard
The package SHALL export composeMiddleware(list: readonly Middleware[]): Middleware returning a single middleware that runs each entry in order, threading next() correctly. Plugin-supplied middleware MUST only be insertable at the steps-7-8 boundary of the per-request pipeline (design §6). Insertion at an earlier step MUST throw ROUTER.MIDDLEWARE_INSERTION_DENIED.
Scenario: chain runs in order
- WHEN three middlewares logging "A", "B", "C" are composed AND a request flows through
- THEN logs appear in order A, B, C, response, C, B, A (wrap-and-unwrap)
Scenario: insertion before auth rejected
- WHEN plugin code calls
router.insertMiddleware({ step: "pre-auth", mw }) - THEN the call throws
ROUTER.MIDDLEWARE_INSERTION_DENIED
Verified by: src/middleware/compose-middleware.test.ts::"chain runs in declared order" AND src/middleware/insertion-point.test.ts::"plugin middleware rejected before auth step"
Requirement: BlueGreenSlot interface
The package SHALL export BlueGreenSlot representing a per-plugin mount point with three slot positions: live (currently serving), shadow (newly imported, not yet serving), draining (previous live, finishing in-flight requests). The interface MUST expose: setShadow(handler), flip(): DrainingHandle (atomic — returns the old live slot as draining), dispose(slot, opts: { timeoutMs }). A flip on a slot with no shadow MUST throw ROUTER.SLOT_FLIP_FAILED. A dispose whose timeout fires before in-flight requests settle MUST throw ROUTER.SLOT_DISPOSE_TIMEOUT AND the abort signal MUST be wired on the still-pending requests.
Scenario: flip is atomic
- WHEN
setShadow(N+1)is called ANDflip()is called - THEN the returned draining handle references slot N AND subsequent matches route to N+1
Scenario: flip with no shadow rejected
- WHEN
flip()is called withshadow === null - THEN
ROUTER.SLOT_FLIP_FAILEDis thrown
Verified by: src/slot/slot.test.ts::"flip atomically promotes shadow and returns draining handle" AND src/slot/slot.test.ts::"flip with no shadow throws SLOT_FLIP_FAILED"
Requirement: Route collision detection
The package SHALL export Router.match(method, url): RouteMatch | null and Router.collidesWith(method, path): RouteMatch[]. Registering a route whose method + path collides with an existing route on the same plugin MUST throw ROUTER.ROUTE_COLLISION. Cross-plugin collisions are NOT this package's responsibility — they're caught by the host loader against the manifests.
Scenario: same-plugin collision rejected
- WHEN
router.get("/x", h1)is called twice on the same router - THEN the second call throws
ROUTER.ROUTE_COLLISION
Verified by: src/router/router.test.ts::"same-plugin route collision rejected"
Requirement: Error codes registry frozen
The package SHALL export ROUTER_CODES via defineCodes containing exactly: ROUTE_COLLISION, INVALID_PREFIX, MIDDLEWARE_INSERTION_DENIED, SLOT_FLIP_FAILED, SLOT_DISPOSE_TIMEOUT. Every code MUST be thrown by a production path.
Scenario: every code reachable
- WHEN the test enumerates
ROUTER_CODES - THEN every code has a production-path throw site
Verified by: src/error-codes/error-codes.spec-conformance.test.ts::"every ROUTER_CODES entry thrown by production path"
Requirement: Conformance harness for adapters
The package SHALL export runRouterConformance({ factory, label }) from ./testing. The harness MUST exercise: every verb method, mount, middleware chain, route collision, slot flip / drain / dispose, request param parsing, error propagation. Every adapter in Stages 4-7 (api-router-hono, api-router-in-memory, the worker bridge) MUST pass this harness.
Scenario: harness exercises every required case
- WHEN the harness is run against a stub Router built from the abstract interface
- THEN at least one
it(...)exists for verbs, mount, middleware, collision, slot lifecycle
Verified by: src/testing/conformance.test.ts::"harness covers verbs, mount, middleware, collision, slot lifecycle"
Requirement: Subpath exports
Every category (router, request, response, route-options, middleware, route-match, slot, error-codes, testing) SHALL be reachable via its own subpath export. Root barrel uses explicit named re-exports (rule 10).
Scenario: every documented subpath resolves
- WHEN the test imports each subpath
- THEN every import resolves
Verified by: tests/subpaths.test.ts::"every documented subpath is importable" AND tests/dep-graph.test.ts::"root barrel has no export-star"