Files
2026-05-27 23:29:53 +01:00

11 KiB

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<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 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<RouteOptions> 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<number, { body: z.ZodSchema }>, 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<Caps, Options> 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.