8.4 KiB
Capability Spec Delta: api-openapi (add-api-openapi)
Package: @suaveplan/api-openapi
Status: PROPOSED
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 3 codes (OPENAPI_INVALID_INPUT, OPENAPI_SCHEMA_NOT_FOUND, OPENAPI_INVALID_BASE_SCHEMA). 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"
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"