Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: api-openapi
**Package**: `@suaveplan/api-openapi`
**Status**: ACTIVE
## ADDED Requirements
### Requirement: OpenAPIRegistry builds the full OpenAPI 3.1 document
`OpenAPIRegistry.buildDocument()` SHALL emit a complete `OpenAPIDocument` containing `openapi: "3.1.0"`, `info`, `servers`, `paths` (derived from `registeredRoutes`), `components.{schemas, securitySchemes, parameters, responses}`, `tags`, and `security`. Calling `applyTo(app: OpenAPIHono)` SHALL wire `app.doc(specPath, ...)` so the served doc equals `buildDocument()` output.
#### Scenario: applyTo connects registry to Hono
- **WHEN** routes are registered via `registry.registerRoute(...)` and `registry.applyTo(app)` is called, then a GET on the spec path is made
- **THEN** the response body equals `registry.buildDocument()`
**Verified by:** `src/registry/registry.test.ts::"applyTo serves buildDocument output"` AND `src/registry/registry.test.ts::"registered routes appear in paths"`
---
### Requirement: RouteBuilder preserves Zod types through the chain
`RouteBuilder` SHALL preserve `TParams`, `TQuery`, `TBody`, `TResponse` generics through `.params/.query/.body/.response/.build`. The output `TypedRouteConfig` SHALL be assignable to `@hono/zod-openapi`'s `RouteConfig` so `app.openapi(route, handler)` infers `c.req.valid()` types automatically.
#### Scenario: RouteBuilder produces typed inference
- **WHEN** `route("post", "/users").body(UserCreateSchema).response(UserSchema).build()` is fed into `app.openapi(route, handler)` and the handler calls `c.req.valid("json")`
- **THEN** the type of the result equals `z.infer<typeof UserCreateSchema>` (no `unknown` widening)
**Verified by:** `src/route-factory/route-factory.spec-conformance.test.ts::"RouteBuilder threads types end-to-end"` AND `src/route-factory/route-factory.test.ts::"build produces TypedRouteConfig assignable to RouteConfig"`
---
### Requirement: StandardResponses cover the full SuaveplanError statusCode range
`StandardResponses` SHALL include entries for HTTP `400`, `401`, `403`, `404`, `409`, `422`, `429`, `500`, `503`, `504` — each pointing at `ErrorResponseSchema`. Upstream's narrower 400/401/403/404/500-only enum is replaced.
#### Scenario: 422 + 503 + 504 are present
- **WHEN** `StandardResponses` is inspected
- **THEN** keys `422`, `503`, `504` exist and reference `ErrorResponseSchema`
**Verified by:** `src/route-factory/standard-responses.test.ts::"covers 422/429/503/504"` AND `src/route-factory/standard-responses.test.ts::"every entry references ErrorResponseSchema"`
---
### Requirement: ErrorResponseSchema matches @suaveplan/error wire shape
`ErrorResponseSchema` SHALL be `z.object({ success: z.literal(false), error: z.object({ code: z.string(), message: z.string(), details: z.record(z.string(), z.unknown()).optional(), requestId: z.string().optional(), timestamp: z.string(), cause: z.unknown().optional() }) })` — a 1:1 match for `SuaveplanError.toJSON()`. The cause field is added to support genesis's cause-chain feature.
#### Scenario: ErrorResponseSchema accepts a SuaveplanError JSON
- **WHEN** a `SuaveplanError`'s `toJSON()` output is passed to `ErrorResponseSchema.safeParse`
- **THEN** the parse succeeds
**Verified by:** `src/route-factory/error-response-schema.test.ts::"accepts SuaveplanError.toJSON() output"` AND `src/route-factory/error-response-schema.test.ts::"rejects success:true payload"`
---
### Requirement: SchemaRegistry has no global singleton
The package SHALL export `SchemaRegistry` class and `createSchemaRegistry()` factory only — no `getSchemaRegistry()` global accessor. `defineSchema(schema, meta, registry)` SHALL require an explicit `registry: SchemaRegistry` argument. This is a deliberate behaviour change from upstream (which has a global `globalSchemaRegistry` singleton) to eliminate parallel-test conflicts.
#### Scenario: no global accessor exported
- **WHEN** the package barrel is inspected for `getSchemaRegistry`
- **THEN** the symbol is not exported
#### Scenario: defineSchema requires explicit registry
- **WHEN** `defineSchema(MySchema, {name: "X"})` is called with no registry argument
- **THEN** the compile fails
**Verified by:** `src/schema-registry/schema-registry.spec-conformance.test.ts::"no global singleton exported"` AND `src/schema-registry/schema-registry.spec-conformance.test.ts::"defineSchema requires explicit registry argument"`
---
### Requirement: SchemaRegistry derivation helpers preserve ZodObject typing
`SchemaRegistry.extend/partial/pick/omit` SHALL operate only on `ZodObject` base schemas. Calling them with a non-object base SHALL raise `OpenAPIError(OPENAPI_INVALID_BASE_SCHEMA)`. Successful derivations SHALL be registered under the new name and returned.
#### Scenario: extend non-object raises typed error
- **WHEN** `registry.register("primitives.uuid", z.string().uuid())` then `registry.extend("primitives.uuid", "X", { extra: z.string() })` is called
- **THEN** the call raises `OpenAPIError(OPENAPI_INVALID_BASE_SCHEMA)`
#### Scenario: pick + omit derive from registered base
- **WHEN** `User` is registered with fields `{id, email, password}` and `pick("User", "UserPublic", ["id", "email"])` is called
- **THEN** `registry.get("UserPublic")` returns a ZodObject with only `id` and `email`
**Verified by:** `src/schema-registry/schema-registry.test.ts::"extend non-object raises OPENAPI_INVALID_BASE_SCHEMA"` AND `src/schema-registry/schema-registry.test.ts::"pick derives + registers"` AND `src/schema-registry/schema-registry.test.ts::"omit derives + registers"`
---
### Requirement: Doc merger preserves base paths and dedups tags
`mergeOpenAPIDocuments(base, ...docs)` SHALL merge `paths` per-path-item (collision = method union), spread-merge `components.{schemas, securitySchemes, parameters, responses}`, and deduplicate `tags` by name. `prefixPaths(doc, prefix)` SHALL prepend `prefix` to every path (collapsing `//`). `filterPathsByTag(doc, tags)` SHALL keep only operations whose `tags` intersect. `removeInternalRoutes(doc)` SHALL drop operations marked `x-internal: true`.
#### Scenario: merge composes two docs
- **WHEN** `mergeOpenAPIDocuments(base, addonDoc)` is invoked where `base.paths["/a"]` exists and `addonDoc.paths["/b"]` exists
- **THEN** the merged doc contains both paths
#### Scenario: prefix collapses double slashes
- **WHEN** `prefixPaths(doc, "/api/")` is called and `doc.paths["/users"]` exists
- **THEN** the result has `/api/users` (single slash, not `//`)
**Verified by:** `src/merger/merger.test.ts::"merge composes paths"` AND `src/merger/merger.test.ts::"prefix collapses double slashes"` AND `src/merger/merger.test.ts::"filterPathsByTag keeps tag-intersecting ops"` AND `src/merger/merger.test.ts::"removeInternalRoutes drops x-internal: true"`
---
### Requirement: definePluginRoutes registers in both Hono and registry atomically
The package SHALL export `definePluginRoutes(routes, { app, registry, pluginName })` helper. For each route, it SHALL call `app.openapi(route, handler)` AND `registry.registerRoute({ method, path, tags, operationId, pluginName, summary })`. The two calls are paired so the registry and Hono cannot drift.
#### Scenario: registers in both surfaces
- **WHEN** `definePluginRoutes([myRoute], { app, registry, pluginName: "users" })` is called
- **THEN** `app.routes` contains the route AND `registry.getRoutesByPlugin("users")` returns it
**Verified by:** `src/plugin-integration/plugin-integration.test.ts::"registers in Hono and registry together"`
---
### Requirement: Every OPENAPI_* code is thrown from a production path
`OPENAPI_CODES` SHALL define exactly 7 codes (`OPENAPI_INVALID_INPUT`, `OPENAPI_SCHEMA_NOT_FOUND`, `OPENAPI_INVALID_BASE_SCHEMA`, `OPENAPI_MISSING_SCHEMA`, `OPENAPI_SCHEMA_CONVERSION_FAILED`, `OPENAPI_OPERATION_ID_COLLISION`, `OPENAPI_UNKNOWN_PLUGIN_IN_FILTER`). Every code SHALL be raised by at least one production call site.
**Verified by:** `src/api-openapi.spec-conformance.test.ts::"every OPENAPI_* code is thrown from a production path"` AND `src/error-codes/error-codes.test.ts::"defines exactly 7 codes"`
---
### Requirement: OpenAPI 3.1 document emitted from Zod schemas
`createOpenapiEmitter({ manifests, routes, authAdapter, hostConfig })` SHALL ingest every plugin manifest plus every registered route's request/response schemas and produce a single OpenAPI 3.1 document. The emitter memoises the document — repeated `getDocument()` returns the same reference.
#### Scenario: full document covers every plugin
- WHEN two plugins are registered each with two routes
- THEN `emitter.getDocument()` returns an OpenAPI 3.1 document with paths for all four routes
**Verified by:** `src/emitter/emitter.test.ts::"emits a 3.1 document covering every registered route"`
---
### Requirement: Namespacing rules are deterministic
The package SHALL set tags to each plugin's `displayName` (falling back to `name`), prefix component schemas with `<pluginName>.<TypeName>`, and set `operationId` to `<pluginName>.<method>.<oasPath>`. operationIds MUST be unique across the merged document; collisions raise `OPENAPI_OPERATION_ID_COLLISION`.
#### Scenario: operationId is deterministic
- WHEN a billing plugin declares `GET /invoices/:id`
- THEN the emitted operationId is `billing.get./invoices/{id}`
#### Scenario: operationId collision raises error
- WHEN two plugins register routes that produce identical operationIds
- THEN emission raises `OPENAPI_OPERATION_ID_COLLISION`
**Verified by:** `src/namespacing/namespacing.test.ts::"operationId is deterministic"` AND `src/namespacing/namespacing.test.ts::"collision raises OPENAPI_OPERATION_ID_COLLISION"`
---
### Requirement: Security schemes inherit from the active auth adapter
The emitter SHALL read the active auth adapter's `securitySchemes` contribution and attach the default scheme to every route except those listed in any plugin's `publicRoutes`, which receive `security: []`.
#### Scenario: public route has no security
- WHEN a route is listed in `publicRoutes`
- THEN the emitted operation has `security: []`
#### Scenario: non-public route inherits default scheme
- WHEN a route is not on `publicRoutes`
- THEN the emitted operation lists the auth adapter's default scheme
**Verified by:** `src/emitter/emitter.test.ts::"public route emits empty security"` AND `src/emitter/emitter.test.ts::"non-public route inherits adapter scheme"`
---
### Requirement: Endpoints serve json, yaml, and per-plugin filtered views
The package SHALL expose `GET /openapi.json`, `GET /openapi.yaml`, `GET /docs`, and `GET /docs/{plugin}` handlers via `createOpenapiRouter` / `mountOpenapiEndpoints`. The filtered view MUST return only operations tagged with the named plugin; an unknown plugin name returns HTTP 404 with a body carrying `OPENAPI.UNKNOWN_PLUGIN_IN_FILTER`.
#### Scenario: yaml endpoint returns yaml content-type
- WHEN a client requests `GET /openapi.yaml`
- THEN response Content-Type is `application/yaml` AND the body parses as YAML
#### Scenario: filtered view excludes other plugins
- WHEN `GET /docs/billing` is requested in a host with billing + users plugins
- THEN the response references billing only
**Verified by:** `src/endpoints/endpoints.test.ts::"yaml endpoint returns application/yaml"` AND `src/endpoints/endpoints.test.ts::"filtered view excludes other plugins"` AND `src/endpoints/endpoints.test.ts::"filtered view returns 404 for unknown plugin"`
---
### Requirement: Missing schema is handled per strictness setting
The emitter SHALL read `host.openapi.strictness` (or default from `host.environment` — `production` → `"refuse-install"`, otherwise `"warn"`). When `"warn"`, missing request/response schemas log a warning, increment the `api.openapi.missing_schema_total` OTel counter, AND emit with `description: "schema-missing"`. When `"refuse-install"`, missing schemas raise `OPENAPI_MISSING_SCHEMA` BEFORE the plugin reaches `installed`.
#### Scenario: warn mode emits placeholder and increments warning counter
- WHEN a route has no request or response schema AND strictness is `"warn"`
- THEN emission succeeds AND the warning is logged + the OTel counter increments
#### Scenario: refuse-install raises OPENAPI_MISSING_SCHEMA
- WHEN strictness is `"refuse-install"`
- THEN emission raises `OPENAPI_MISSING_SCHEMA`
**Verified by:** `src/emitter/emitter.spec-conformance.test.ts::"warn mode emits placeholder and increments warning counter"` AND `src/emitter/emitter.spec-conformance.test.ts::"refuse-install raises OPENAPI_MISSING_SCHEMA"` AND `src/strictness/strictness.test.ts::"refuse-install raises OPENAPI_MISSING_SCHEMA"`
---
### Requirement: Server-tier package shape
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage.
**Verified by:** `tests/dep-graph.test.ts::"dependencies match the allowlist"` AND `tests/dep-graph.test.ts::"subpath exports cover every feature"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
<!-- markdownlint-enable MD024 -->