Initial Commit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user