Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# Add @suaveplan/api-openapi — OpenAPI registry + route factory (3 of 5)
**Package**: `@suaveplan/api-openapi` (NEW, server-tier)
**Status**: PROPOSED
**Type**: new-package (split from upstream `@suaveplan/api`)
- **Spec delta**: ./specs/api-openapi/spec.md
- **Companions**: `add-api-plugin`, `add-api-tenant`, `add-api-migrations`, `enterprise-api`
- **Position in chain**: depends on `add-api-plugin` (consumers register routes via plugin `onInit`)
## Why
Upstream `@suaveplan/api`'s `openapi/` (1,066 LOC) ships an `OpenAPIRegistry`, a doc merger, a typed `RouteBuilder`, and a global `SchemaRegistry`. Splitting it lets `@suaveplan/api-schemas` (the Zod boundary) and downstream `api-plugin-*` packages compose a focused OpenAPI surface without dragging the Hono app orchestrator along.
The upstream layer has two notable issues genesis fixes: (1) the `OpenAPIRegistry` and Hono's `OpenAPIHono.doc()` are **not connected** — registry tracks routes for introspection but the actual doc is generated by Hono, so drift between the two is silent; (2) `getSchemaRegistry()` is a global singleton, untestable in parallel and conflicting with multi-app deployments.
## What changes
Net-new package at `0.1.0`. ADDED:
- `OpenAPIRegistry` class — single source of truth. Tracks `info`, `servers`, `securitySchemes`, `tags`, `routes`. Methods: `registerSecurityScheme`, `registerTag`, `registerRoute`, `getRoutes`, `getRoutesByPlugin`, `getRoutesByTag`, `buildDocument(): OpenAPIDocument` (returns the full 3.1.0 doc, not just info+servers).
- **Hono integration**: `OpenAPIRegistry.applyTo(app: OpenAPIHono)` — registers a single `app.doc(specPath, ...)` handler that serves the registry-built document. Eliminates the drift between registry state and Hono doc output.
- `RouteBuilder` fluent API — type-preserving via `RouteBuilder<TParams, TQuery, TBody, TResponse>` generics; `.params(z)/.query(z)/.body(z)/.response(z)` thread types through; `.build()` returns `TypedRouteConfig` compatible with `@hono/zod-openapi`.
- `route(method, path)` factory + shorthands `GET/POST/PUT/PATCH/DELETE(path)`.
- `StandardResponses` — `400/401/403/404/422/500/503` (widened from upstream's 400/401/403/404/500 to cover the full SuaveplanError statusCode range).
- `ErrorResponseSchema` — Zod schema matching `@suaveplan/error` wire shape (`{ success: false, error: { code, message, details?, requestId?, timestamp, cause? } }`).
- `successResponse(T)`, `paginatedResponse(T)`, `errorResponse(desc)` — response wrapper schemas.
- `SchemaRegistry` class — local instance (no global singleton). Methods: `register`, `get`, `has`, `getNames`, `getAll`, `remove`, `clear`, `extend(baseName, name, ext)`, `partial(baseName, name)`, `pick(baseName, name, keys)`, `omit(baseName, name, keys)`. `defineSchema(schema, meta, registry)` requires an explicit registry instance.
- `CommonSchemas` — `uuid`, `email`, `url`, `datetime`, `positiveInt`, `nonNegativeInt`, `page`, `limit`, `sortOrder` (Zod schemas with `.openapi()` metadata).
- `OpenAPIDocument` + `PathItem` + `Operation` types — structural shape for cross-tool merging.
- `mergeOpenAPIDocuments(base, ...docs)` — same path-merge / components-spread / tag-dedup semantics as upstream.
- `prefixPaths(doc, prefix)`, `filterPathsByTag(doc, tags)`, `removeInternalRoutes(doc)` — doc transforms.
- `BearerAuthScheme`, `ApiKeyScheme` security-scheme presets.
- `OpenAPIError extends SuaveplanError<OpenAPIErrorCode>` — codes `OPENAPI_INVALID_INPUT`, `OPENAPI_SCHEMA_NOT_FOUND`, `OPENAPI_INVALID_BASE_SCHEMA` (e.g. `extend` on a non-ZodObject).
- Plugin integration: `definePluginRoutes(routes: TypedRouteConfig[], registry: OpenAPIRegistry)` helper that plugins call from `onInit` to register routes against both Hono (`app.openapi`) and the registry.
## Out of scope
- **OpenAPI 3.0 support.** Genesis ships 3.1.0 only (matches Hono `OpenAPIHono.getOpenAPI31Document`). A back-compat 3.0 transform is a follow-up.
- **YAML / TOML emit.** Doc is emitted as JSON. YAML adapter belongs in a separate package.
- **Spec validation against the 3.1 schema.** We trust `@hono/zod-openapi`'s output structure; we don't reparse our own emitted spec. A validator package is out of scope.
- **OpenAPI UI rendering** (Swagger / Redoc / Scalar / Altair). These ship as separate plugins (`api-openapi-ui-swagger`, etc — all in the in-flight change set).
## Source mapping
| Capability | Upstream source | Notes |
|---|---|---|
| `OpenAPIRegistry` | `../suaveplan/packages/server/api/src/openapi/openapi-registry.ts` L1-L238 | port + add `buildDocument()` that emits the full doc + `applyTo(app)` Hono integration |
| Doc merging + transforms | `../suaveplan/packages/server/api/src/openapi/openapi-merger.ts` L1-L271 | verbatim port |
| `RouteBuilder` + factories + `StandardResponses` + helpers | `../suaveplan/packages/server/api/src/openapi/route-factory.ts` L1-L298 | port + widen `StandardResponses` to include 422 and 503 |
| `SchemaRegistry` + `CommonSchemas` | `../suaveplan/packages/server/api/src/openapi/schema-registry.ts` L1-L255 | port + drop global singleton; require explicit registry instance |
## Impact
### Affected dependents
Depends on `add-api-plugin` (for the `PluginDescriptor`/`PluginContext` types) and indirectly on `@suaveplan/api-schemas` (existing on genesis). Will be consumed by `@suaveplan/api` (orchestrator) and every `@suaveplan/api-plugin-*` package that ships routes.

View File

@@ -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 -->

View File

@@ -0,0 +1,48 @@
# Tasks — add-api-openapi
## 1. Scaffold
- [ ] 1.1 Create `packages/api/api-openapi/` with `src/{registry,route-factory,schema-registry,merger,plugin-integration,error-codes,types}/` + `tests/setup/`
- [ ] 1.2 `package.json` v0.1.0. Deps: `@suaveplan/error` `@suaveplan/api-plugin` `@suaveplan/api-schemas` `@suaveplan/types` `tslib`, `hono` `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api`. Dev: standard testing. ~8 subpath exports.
- [ ] 1.3 tsconfigs + vite + vitest (100/100/100/100) + polyfills
## 2. Implementation
- [ ] 2.1 `src/error-codes.ts` + `.md` + test — 3 OPENAPI.* codes + `OpenAPIError`
- [ ] 2.2 `src/types/openapi.{ts,md,index.ts}` — `OpenAPIDocument`, `PathItem`, `Operation`, `OpenAPIInfo`, `OpenAPIServer`, `SecurityScheme`, `RegisteredRoute`
- [ ] 2.3 `src/registry/registry.{ts,test.ts,md,index.ts}` — `OpenAPIRegistry` + `buildDocument()` + `applyTo(app)`
- [ ] 2.4 `src/route-factory/route-factory.{ts,test.ts,md,index.ts}` — `RouteBuilder<TParams,TQuery,TBody,TResponse>` + `route/GET/POST/PUT/PATCH/DELETE` + `StandardResponses` (widened) + `ErrorResponseSchema` (+ `successResponse/paginatedResponse/errorResponse`)
- [ ] 2.5 `src/route-factory/route-factory.spec-conformance.test.ts` — `expectTypeOf` end-to-end inference
- [ ] 2.6 `src/schema-registry/schema-registry.{ts,test.ts,md,index.ts}` — instance-based, no global; extend/partial/pick/omit on ZodObject
- [ ] 2.7 `src/schema-registry/common-schemas.{ts,md}` — `CommonSchemas` (uuid/email/url/datetime/positiveInt/nonNegativeInt/page/limit/sortOrder)
- [ ] 2.8 `src/merger/merger.{ts,test.ts,md,index.ts}` — `mergeOpenAPIDocuments`, `prefixPaths`, `filterPathsByTag`, `removeInternalRoutes`
- [ ] 2.9 `src/plugin-integration/plugin-integration.{ts,test.ts,md,index.ts}` — `definePluginRoutes(routes, {app, registry, pluginName})`
- [ ] 2.10 `src/index.ts` — named re-exports (incl. `BearerAuthScheme`, `ApiKeyScheme`)
## 3. Tests
- [ ] 3.1 Co-located `*.test.ts` per spec scenario
- [ ] 3.2 `src/api-openapi.spec-conformance.test.ts` — every OPENAPI.* thrown; no global singleton; `expectTypeOf` on RouteBuilder end-to-end
- [ ] 3.3 `tests/integration/full-doc-roundtrip.test.ts` — real OpenAPIHono + real registry + 3 plugins registering routes → assert merged doc shape
- [ ] 3.4 `tests/property.test.ts` — fast-check `mergeOpenAPIDocuments` associativity (`merge(a, b, c) === merge(merge(a, b), c)`) for arbitrary path sets
- [ ] 3.5 `tests/dep-graph.test.ts`
- [ ] 3.6 Runner imports + timeouts conventions
## 4. Documentation
- [ ] 4.1 Sibling `.md` per impl
- [ ] 4.2 `README.md` — RouteBuilder examples (basic + with auth + paginated), SchemaRegistry derivation patterns, mergeOpenAPIDocuments use cases, `definePluginRoutes` plugin pattern
## 5. Gates
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/api-openapi --force`
- [ ] 5.2 Coverage 100/100/100/100
- [ ] 5.3 `bun scripts/openspec-conformance.ts @suaveplan/api-openapi` exit 0
- [ ] 5.4 Build green
- [ ] 5.5 Banned-pattern + no `export *` + no direct runner imports
## 6. Archive
- [ ] 6.1 Fold spec → `openspec/specs/api-openapi/spec.md`
- [ ] 6.2 mv change → archive
- [ ] 6.3 MANIFEST + STATUS refresh