187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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.
|