146 lines
7.6 KiB
Markdown
146 lines
7.6 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|