229 lines
13 KiB
Markdown
229 lines
13 KiB
Markdown
<!-- 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 -->
|