13 KiB
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(...)andregistry.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 intoapp.openapi(route, handler)and the handler callsc.req.valid("json") - THEN the type of the result equals
z.infer<typeof UserCreateSchema>(nounknownwidening)
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
StandardResponsesis inspected - THEN keys
422,503,504exist and referenceErrorResponseSchema
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'stoJSON()output is passed toErrorResponseSchema.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())thenregistry.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
Useris registered with fields{id, email, password}andpick("User", "UserPublic", ["id", "email"])is called - THEN
registry.get("UserPublic")returns a ZodObject with onlyidandemail
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 wherebase.paths["/a"]exists andaddonDoc.paths["/b"]exists - THEN the merged doc contains both paths
Scenario: prefix collapses double slashes
- WHEN
prefixPaths(doc, "/api/")is called anddoc.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.routescontains the route ANDregistry.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/yamlAND the body parses as YAML
Scenario: filtered view excludes other plugins
- WHEN
GET /docs/billingis 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"