Files
suaveplan/openspec/archive/2026-05-18-add-api-openapi/specs/api-openapi/spec.md
2026-05-27 23:29:53 +01:00

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