Files
suaveplan/openspec/archive/2026-05-25-api-router/specs/api-router/spec.md
2026-05-27 23:29:53 +01:00

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 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 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 === true AND openapi === true AND streaming === 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 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"