# Capability Spec: api-router **Package**: `@suaveplan/api-router` **Status**: SHIPPED (Stage-1 contract, 0.1.0) > Every requirement below carries a `**Verified by:**` line. ## 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`. #### Scenario: every verb method is declared - **WHEN** `expectTypeOf()` 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 `r` mounts child router `c` at prefix `/billing`, AND `c` declares `GET /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 z` from `"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` whose inferred type is the concrete-adapter-superset of `DefaultRouteOptions` from `@suaveplan/api-types/router`. The schema MUST include: `auth?: string`, `summary?: string`, `tags?: readonly string[]`, `request?: { body?, params?, query? }` (each a `z.ZodSchema`), `responses?: Record`, `runtimeValidation` (default `true`), `openapi` (default `true`), `streaming` (default `false`), `csrf?: boolean`, `idempotent?: boolean`, `rateLimit?: { rpm: number } | { rps: number }`. The local `RouteOptions` SHALL be structurally compatible with `DefaultRouteOptions` from api-types — an object shaped against api-types alone MUST parse successfully via `routeOptionsSchema.safeParse`. The local `RouteOptions` is the concrete `Options` type parameter this package supplies to `Router` from api-types. #### Scenario: defaults apply when fields are absent - **WHEN** `routeOptionsSchema.parse({})` is called - **THEN** `runtimeValidation === true` AND `openapi === true` AND `streaming === false` #### Scenario: api-types-shaped options parse via the local schema - **WHEN** an object shaped using only the api-types `DefaultRouteOptions` keys (e.g. `{ auth: "users.read", summary: "List users" }`) is passed to `routeOptionsSchema.safeParse` - **THEN** `success` is `true` #### 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"` AND `src/route-options/route-options.spec-conformance.test.ts::"api-types-shaped objects parse successfully"` --- ### 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 AND `flip()` 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 with `shadow === null` - **THEN** `ROUTER.SLOT_FLIP_FAILED` is 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"` --- ### Requirement: Ships the canonical `runPipelineRouterConformance` harness `@suaveplan/api-router` SHALL export `runPipelineRouterConformance({ label, build })` from `./testing/pipeline-router-conformance` (with an ergonomic re-export through `./testing`). The harness exercises the `PipelineRouter` contract owned by `@suaveplan/api-types/pipeline-router` and proves every concrete adapter (`api-router-in-memory`, the `HonoRouter` pipeline facet in `api-router-hono`, future Stage-7 engines) satisfies the §6 request-pipeline router seam. `@suaveplan/api-host` re-exports the harness from its `./testing` subpath so existing consumers continue to import the same shape without code changes. #### Scenario: harness invariants unchanged from the api-host source location - **WHEN** an adapter that previously passed `runPipelineRouterConformance` from `@suaveplan/api-host/testing` re-targets the harness at `@suaveplan/api-router/testing/pipeline-router-conformance` - **THEN** the same scenarios pass with no signature or behaviour change #### Scenario: ergonomic `./testing` re-export - **WHEN** `import { runPipelineRouterConformance } from "@suaveplan/api-router/testing"` is used - **THEN** the symbol resolves AND matches the dedicated-subpath export by identity **Verified by:** `src/testing/pipeline-router-conformance/pipeline-router-conformance.test.ts` (self-test against ReferencePipelineRouter) AND the `api-router-in-memory` + `api-router-hono` conformance suites. --- ### Requirement: Cycle-free build graph after PipelineRouter harness relocation After the `api-router-pipeline-extraction` change, the workspace build graph MUST NOT contain a cycle between `@suaveplan/api-host` and `@suaveplan/api-router-in-memory`. Turborepo's `bunx turbo run build` MUST complete without emitting `Cyclic dependency detected`. #### Scenario: turbo schedules build with no cycle warning - **WHEN** `bunx turbo run build --filter=@suaveplan/api-host` is run from a clean tree - **THEN** stderr contains no `Cyclic dependency detected` line AND the task graph builds successfully #### Scenario: repo-wide test gate runs end-to-end - **WHEN** `bunx turbo test --output-logs=errors-only` is run - **THEN** the exit code is 0 AND no package reports a cycle warning **Verified by:** `bunx turbo run build --dry=json --filter=@suaveplan/api-host | jq` shows no `api-router-in-memory#build` edge AND `bunx turbo run build --dry=json --filter=@suaveplan/api-router-in-memory | jq` shows no `api-host#build` edge.