Files
2026-05-27 23:29:53 +01:00

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(...) 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"