first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
# @suaveplan/api-openapi - OpenAPI 3.1 registry, typed RouteBuilder, schema registry, and document merger for the Suaveplan API. Single source of truth that wires into Hono via OpenAPIRegistry.applyTo(app) — eliminating drift between the introspection registry and the served /openapi.json. Ships StandardResponses spanning the full SuaveplanError statusCode range (400/401/403/404/409/422/429/500/503/504), an ErrorResponseSchema carrying the cause field of SuaveplanError.toJSON(), an instance-only SchemaRegistry (no global singleton) with extend/partial/pick/omit guarded by OPENAPI_INVALID_BASE_SCHEMA, mergeOpenAPIDocuments / prefixPaths / filterPathsByTag / removeInternalRoutes transforms, and a definePluginRoutes helper that registers atomically against both Hono and the registry.
OpenAPI 3.1 registry, typed `RouteBuilder`, schema registry, and
document merger for the Suaveplan API. Single source of truth that
wires into Hono via `OpenAPIRegistry.applyTo(app)`, eliminating drift
between registry introspection and the served `/openapi.json`.
<!-- begin:auto-docs -->
## Documentation tree
- [emitter](./src/emitter/emitter.md) — `createOpenapiEmitter` is the aggregator the spec calls for: it ingests every plugin's manifest, every registered route…
- [emitter](./src/emitter/emitter.md) — `createOpenapiEmitter` is the aggregator the spec calls for: it ingests every plugin's manifest, every registered route…
- [endpoints](./src/endpoints/endpoints.md) — `endpoints` mounts the four HTTP routes the design doc reserves for the OpenAPI surface — `/openapi.json`, `/openapi.ya…
- [endpoints](./src/endpoints/endpoints.md) — `endpoints` mounts the four HTTP routes the design doc reserves for the OpenAPI surface — `/openapi.json`, `/openapi.ya…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for `@suaveplan/api-openapi`. Every public failure path in…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for `@suaveplan/api-openapi`. Every public failure path in…
- [merger](./src/merger/merger.md) — OpenAPI document transforms — merge, prefix, filter, redact internal routes. Multi-plugin servers compose the final pub…
- [merger](./src/merger/merger.md) — OpenAPI document transforms — merge, prefix, filter, redact internal routes. Multi-plugin servers compose the final pub…
- [namespacing](./src/namespacing/namespacing.md) — The `namespacing` module turns a plugin's structural identity (`name`, `displayName`) plus a registered route's `(metho…
- [namespacing](./src/namespacing/namespacing.md) — The `namespacing` module turns a plugin's structural identity (`name`, `displayName`) plus a registered route's `(metho…
- [plugin-integration](./src/plugin-integration/plugin-integration.md) — `definePluginRoutes` is the single helper plugins call from their `onInit` to register routes. Every call atomically up…
- [plugin-integration](./src/plugin-integration/plugin-integration.md) — `definePluginRoutes` is the single helper plugins call from their `onInit` to register routes. Every call atomically up…
- [registry](./src/registry/registry.md) — Centralised OpenAPI 3.1 registry that holds `info`, `servers`, `securitySchemes`, `tags`, and `routes`. Acts as the sin…
- [registry](./src/registry/registry.md) — Centralised OpenAPI 3.1 registry that holds `info`, `servers`, `securitySchemes`, `tags`, and `routes`. Acts as the sin…
- [route-factory](./src/route-factory/route-factory.md) — Typed route construction utilities for `@hono/zod-openapi`. The `RouteBuilder` is a fluent generic that threads `TParam…
- [route-factory](./src/route-factory/route-factory.md) — Typed route construction utilities for `@hono/zod-openapi`. The `RouteBuilder` is a fluent generic that threads `TParam…
- [schema-registry](./src/schema-registry/schema-registry.md) — Instance-based registry for reusable Zod schemas. Each schema is bound to a stable name and may be derived into related…
- [common-schemas](./src/schema-registry/common-schemas.md) — Library of common Zod schemas decorated with OpenAPI `example` metadata. Covers the universal HTTP query / path / pagin…
- [schema-registry](./src/schema-registry/schema-registry.md) — Instance-based registry for reusable Zod schemas. Each schema is bound to a stable name and may be derived into related…
- [strictness](./src/strictness/strictness.md) — `strictness` resolves the host's missing-schema policy for the OpenAPI emitter and applies it consistently across every…
- [strictness](./src/strictness/strictness.md) — `strictness` resolves the host's missing-schema policy for the OpenAPI emitter and applies it consistently across every…
- [types](./src/types/openapi.md) — Structural TypeScript types for OpenAPI 3.1 documents, supporting shapes (info / server / security scheme), registered-…
- [openapi](./src/types/openapi.md) — Structural TypeScript types for OpenAPI 3.1 documents, supporting shapes (info / server / security scheme), registered-…
<!-- end:auto-docs -->
## Features
- **`OpenAPIRegistry`** — tracks `info`, `servers`, `securitySchemes`,
`tags`, `routes`. `buildDocument()` emits a full 3.1 document.
`applyTo(app)` wires a Hono GET handler that serves `buildDocument()`
output — no drift possible.
- **`RouteBuilder`** — fluent generic that threads
`TParams`/`TQuery`/`TBody`/`TResponse` through to the final
`TypedRouteConfig`. Hono's `app.openapi(route, handler)` then infers
`c.req.valid()` to the exact Zod type.
- **`StandardResponses`** — covers the full SuaveplanError statusCode
range (400/401/403/404/409/422/429/500/503/504).
- **`ErrorResponseSchema`** — 1:1 match for `SuaveplanError.toJSON()`
including the `cause` field.
- **`SchemaRegistry`** — instance-based (no singleton),
`extend/partial/pick/omit` derivation helpers that raise typed
`OPENAPI.SCHEMA_NOT_FOUND` / `OPENAPI.INVALID_BASE_SCHEMA` errors.
- **`CommonSchemas`** — `uuid`, `email`, `url`, `datetime`,
`positiveInt`, `nonNegativeInt`, `page`, `limit`, `sortOrder`.
- **`mergeOpenAPIDocuments` / `prefixPaths` / `filterPathsByTag` /
`removeInternalRoutes`** — pure document transforms.
- **`definePluginRoutes(routes, { app, registry, pluginName })`** —
atomic dual-surface registration; the only way a plugin should add
routes.
## Quick start
### Build a typed route
```ts
import z from "zod";
import { GET, successResponse } from "@suaveplan/api-openapi/route-factory";
const ListUsers = GET("/users")
.tags("users")
.operationId("listUsers")
.response(successResponse(z.array(z.object({ id: z.string() }))))
.build();
```
### With auth + body validation
```ts
import { POST, successResponse } from "@suaveplan/api-openapi/route-factory";
const CreateUser = POST("/users")
.tags("users")
.operationId("createUser")
.security([{ Bearer: [] }])
.body(z.object({ email: z.string().email() }))
.response(successResponse(z.object({ id: z.string() })))
.build();
```
### Paginated response
```ts
import { GET, paginatedResponse } from "@suaveplan/api-openapi/route-factory";
const ListUsersPaginated = GET("/users")
.tags("users")
.query(
z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
)
.response(paginatedResponse(z.object({ id: z.string() })))
.build();
```
### Schema registry derivation
```ts
import z from "zod";
import { createSchemaRegistry } from "@suaveplan/api-openapi/schema-registry";
const reg = createSchemaRegistry();
reg.register(
"User",
z.object({
id: z.string().uuid(),
email: z.string().email(),
password: z.string(),
})
);
const UserPublic = reg.omit("User", "UserPublic", ["password"]);
const UserPatch = reg.partial("User", "UserPatch");
const UserAudit = reg.extend("User", "UserAudit", {
lastSeenAt: z.string().datetime(),
});
```
### Merge multiple plugin docs
```ts
import {
mergeOpenAPIDocuments,
prefixPaths,
removeInternalRoutes,
} from "@suaveplan/api-openapi/merger";
const final = removeInternalRoutes(
mergeOpenAPIDocuments(
baseDoc,
prefixPaths(usersDoc, "/v1"),
prefixPaths(projectsDoc, "/v1")
)
);
```
### Plugin pattern with `definePluginRoutes`
```ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi/registry";
import { definePluginRoutes } from "@suaveplan/api-openapi/plugin-integration";
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "Public API", version: "1.0.0" },
});
definePluginRoutes(
[
{
route: ListUsers,
handler: (c) => c.json({ success: true as const, data: [] }, 200),
},
{
route: CreateUser,
handler: (c) =>
c.json(
{ success: true as const, data: { id: "1" } },
200
),
},
],
{ app, registry, pluginName: "users" }
);
// Single line connects registry → Hono. No drift possible.
registry.applyTo(app);
```
## Tier
Server-tier (Hono-typed). Runs in Bun + Node.
## Modules
- **`./error-codes`** — stable `OPENAPI.*` registry (7 codes) +
`OpenAPIError` class.
- **`./types`** — structural `OpenAPIDocument`, `Operation`, `PathItem`,
`RegisteredRoute`, security-scheme types, `HTTP_METHODS`.
- **`./registry`** — `OpenAPIRegistry`, `createOpenAPIRegistry`,
`applyTo(app)` (Hono GET handler), default `BearerAuthScheme` /
`ApiKeyScheme`.
- **`./route-factory`** — fluent `RouteBuilder` (`GET`/`POST`/`PUT`/
`PATCH`/`DELETE`), `successResponse` / `errorResponse` /
`paginatedResponse`, `StandardResponses` (400-504),
`ErrorResponseSchema` matching `SuaveplanError.toJSON()`.
- **`./schema-registry`** — instance-based `SchemaRegistry` with
`extend`/`partial`/`pick`/`omit` derivation + `CommonSchemas`.
- **`./merger`** — `mergeOpenAPIDocuments`, `prefixPaths`,
`filterPathsByTag`, `removeInternalRoutes`, `extractOpenAPIDoc`.
- **`./plugin-integration`** — `definePluginRoutes` (atomic
dual-surface registration; the only way a plugin should add routes).
- **`./namespacing`** — `assignOperationIds`, `tagFor`,
`componentPrefix`, `normaliseRoutePath`. Deterministic operationId
(`<plugin>.<method>.<oasPath>`) with `OPENAPI.OPERATION_ID_COLLISION`
on duplicates.
- **`./strictness`** — `resolveStrictness`, `reportMissingSchema`,
`getMissingSchemaCounter`. `warn` vs `refuse-install` policy +
`api.openapi.missing_schema_total` OTel counter.
- **`./emitter`** — `createOpenapiEmitter({ manifests, routes,
authAdapter, hostConfig })`. The Stage-8.6 aggregator: namespacing +
strictness + security inheritance + memoised JSON/YAML/filtered
output. Emits `api.openapi.emission_total` and
`api.openapi.conversion_error_total` OTel counters.
- **`./endpoints`** — `createOpenapiRouter` / `mountOpenapiEndpoints`.
Mounts `GET /openapi.json`, `/openapi.yaml`, `/docs`, `/docs/:plugin`.
## License
MIT

View File

@@ -0,0 +1,129 @@
# emitter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createOpenapiEmitter` is the aggregator the spec calls for: it ingests
every plugin's manifest, every registered route (with its Zod-converted
schemas), the active auth adapter's security scheme, and the host
config, then produces a single OpenAPI 3.1 document covering the entire
mounted plugin set. The emitter is the single source of truth served at
`/openapi.json` and consumed by the docs UI sibling packages.
By centralising emission, the host eliminates drift: every plugin's
routes flow through the same namespacing rules, the same strictness
policy, and the same security inheritance. Plugins cannot hand-author
OpenAPI fragments that disagree with what their Hono runtime actually
serves.
## Features
- **Manifest-driven tags + components** — `displayName` becomes the tag,
`<pluginName>.<TypeName>` becomes the component schema prefix.
- **Deterministic operationIds** — every route receives
`<plugin>.<method>.<oasPath>`. Collisions raise the moment they
appear.
- **Security inheritance** — every operation lists the active auth
adapter's scheme except for routes listed in any plugin's
`publicRoutes`, which receive `security: []`.
- **Strictness gate** — routes missing request + response schemas warn
(with an OTel counter) or refuse install, per `host.openapi.strictness`.
- **Per-plugin filtered view** — `getDocumentForPlugin(name)` returns
only operations tagged with that plugin; misses raise
`OPENAPI.UNKNOWN_PLUGIN_IN_FILTER`.
- **JSON + YAML serialisation** — `getJson()` / `getYaml()` produce
serialised bodies for the HTTP endpoints.
- **OTel emission + conversion-error counters** — visibility on
document production and Zod→OAS conversion failures.
- **Lifecycle dedup** — the constructed document is memoised; repeated
`getDocument()` calls return the same reference.
## Usage: Basic
```ts
import { createOpenapiEmitter } from "@suaveplan/api-openapi/emitter";
const emitter = createOpenapiEmitter({
manifests: [
{ name: "billing", displayName: "Billing", description: "Invoices" },
],
routes: [
{
pluginName: "billing",
route: { method: "GET", path: "/invoices/:id", tags: [] },
responseSchema: { type: "object" },
},
],
authAdapter: {
name: "Bearer",
scheme: { type: "http", scheme: "bearer" },
},
hostConfig: {
info: { title: "host", version: "1.0.0" },
},
});
emitter.getJson();
```
## Usage: Advanced
```ts
import { createOpenapiEmitter } from "@suaveplan/api-openapi/emitter";
import { z } from "zod";
const Invoice = z.object({ id: z.string() });
const emitter = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: { method: "GET", path: "/invoices/:id", tags: [] },
// Thunk → emitter wraps a thrown converter error in
// OPENAPI.SCHEMA_CONVERSION_FAILED and increments the
// conversion counter.
responseSchema: () => ({ $ref: "#/components/schemas/billing.Invoice" }),
components: { Invoice: { type: "object" } },
},
],
hostConfig: {
info: { title: "host", version: "1.0.0" },
environment: "production",
openapi: { strictness: "refuse-install" },
},
});
emitter.getDocumentForPlugin("billing");
```
## API Reference
- `createOpenapiEmitter(input) → OpenapiEmitter`.
- `OpenapiEmitter.getDocument() → OpenAPIDocument` — memoised.
- `OpenapiEmitter.getJson() / getYaml() → string`.
- `OpenapiEmitter.getDocumentForPlugin(name) → OpenAPIDocument`.
- `OpenapiEmitter.getPluginNames() → readonly string[]`.
- `OpenapiEmitter.getStrictness() → StrictnessMode`.
- `EmitterPluginManifest`, `EmitterRouteInput`, `EmitterAuthAdapter`,
`EmitterHostConfig` — input bag types.
- `__resetEmitterCounters()` — test-only seam.
- Constants: `EMISSION_COUNTER_NAME`, `CONVERSION_ERROR_COUNTER_NAME`.
## Implementation Notes
- Schemas may be supplied as a pre-converted object or as a thunk; the
thunk form gives the emitter a chance to catch + wrap conversion
errors with `OPENAPI.SCHEMA_CONVERSION_FAILED`. Pre-converted objects
bypass the catch (the caller has already succeeded).
- Strictness application is per route and bound to whichever slot is
primary for the method — `request` for non-GET, `response` for GET —
so the failure mode mirrors what API clients actually rely on.
- Per-plugin filtered views are cached on first request. The filter
matches by the resolved tag (`displayName` or `name`), not by the raw
plugin name, so two plugins sharing a `displayName` are merged into a
single filtered view (and the host's manifest validator should reject
such collisions upstream).
- Memoisation: `getDocument()` returns the same object reference on
every call; mutations to the returned document leak across callers
by design — callers MUST `structuredClone` if they need to mutate.

View File

@@ -0,0 +1,71 @@
# endpoints
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`endpoints` mounts the four HTTP routes the design doc reserves for the
OpenAPI surface — `/openapi.json`, `/openapi.yaml`, `/docs`, and
`/docs/:plugin` — onto a Hono app. The handlers are minimal: they read
the memoised emitter, set the appropriate `Content-Type`, and return
the body. UI sibling packages (Scalar, Swagger, ReDoc) replace the
default HTML stubs with their renderers.
Separating endpoints from the emitter keeps the host wiring simple: the
host builds an emitter once at boot, then mounts the router at whatever
path it prefers (commonly `/`).
## Features
- `createOpenapiRouter({ emitter })` — standalone Hono router with the
four routes wired.
- `mountOpenapiEndpoints(app, { emitter })` — convenience that attaches
the router onto an existing Hono app.
- 404 + typed body for unknown plugins via the filtered view (the
emitter raises `OPENAPI.UNKNOWN_PLUGIN_IN_FILTER` and the handler
catches it).
- Content negotiation: JSON, YAML, HTML — each handler sets its
`Content-Type` explicitly.
## Usage: Basic
```ts
import { Hono } from "hono";
import { mountOpenapiEndpoints } from "@suaveplan/api-openapi/endpoints";
const app = new Hono();
mountOpenapiEndpoints(app, { emitter });
// app now serves GET /openapi.json, /openapi.yaml, /docs, /docs/:plugin
```
## Usage: Advanced
```ts
import { Hono } from "hono";
import { createOpenapiRouter } from "@suaveplan/api-openapi/endpoints";
const root = new Hono();
const docs = createOpenapiRouter({ emitter });
root.route("/admin/api", docs);
// Routes mount at /admin/api/openapi.json, etc.
```
## API Reference
- `createOpenapiRouter(options) → Hono`.
- `mountOpenapiEndpoints(app, options) → app` — returns the passed app.
- `OpenapiEndpointsOptions = { emitter: OpenapiEmitter }`.
## Implementation Notes
- The HTML stubs are bare on purpose. UI sibling packages
(`@suaveplan/api-openapi-ui-scalar`, etc.) own the docs experience;
this package only ships the data-bearing endpoints.
- Body returns: `c.body()` is used (not `c.json()` / `c.text()`) so the
serialised string the emitter already produces is forwarded verbatim
— no double-stringify for JSON, no JSON-encoding of YAML.
- The unknown-plugin 404 returns the OpenAPI error code in the body so
clients can disambiguate from "this plugin exists but has no routes"
scenarios (where the filtered view returns an empty `paths` object).

View File

@@ -0,0 +1,84 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed error class for
`@suaveplan/api-openapi`. Every public failure path in the package raises
an `OpenAPIError` carrying one of the three `OPENAPI.*` codes, allowing
downstream consumers to branch on `error.code` for recovery, telemetry,
or user-facing message rendering. The registry is frozen at module load
so codes cannot be added or mutated at runtime, which keeps the wire
contract stable across versions.
The package intentionally exposes a small, deliberate code surface —
this is an OpenAPI document builder, not a request validator, so most
failure modes are caller-error rather than runtime-error. Three codes
cover the entire surface.
## Features
- Frozen `OPENAPI_CODES` const map produced by `defineCodes("OPENAPI", …)`.
- `OpenAPIErrorCode` discriminated string-literal union.
- `OpenAPIError` subclass of `SuaveplanError` with public constructor,
redundant `openapiCode` field for symmetric narrowing, and full
`cause` / `context` / `requestId` propagation.
## Usage: Basic
```ts
import { OPENAPI_CODES, OpenAPIError } from "@suaveplan/api-openapi/error-codes";
throw new OpenAPIError(
OPENAPI_CODES.SCHEMA_NOT_FOUND,
`schema "${name}" is not registered`
);
```
## Usage: Advanced
```ts
import { OPENAPI_CODES, OpenAPIError } from "@suaveplan/api-openapi/error-codes";
try {
registry.extend("User", "UserPublic", { exposedAt: z.string() });
} catch (e) {
if (e instanceof OpenAPIError && e.code === OPENAPI_CODES.INVALID_BASE_SCHEMA) {
// The base name resolved to a non-ZodObject. Re-throw with
// a richer context so the caller knows which schema failed.
throw new OpenAPIError(e.code, e.message, {
cause: e,
context: { baseName: "User", attemptedExtension: "UserPublic" },
});
}
throw e;
}
```
## API Reference
- `OPENAPI_CODES` — frozen `{ INVALID_INPUT, SCHEMA_NOT_FOUND, INVALID_BASE_SCHEMA }` mapping local keys to `"OPENAPI.*"` wire codes.
- `type OpenAPIErrorCode` — union of the three wire codes.
- `class OpenAPIError extends SuaveplanError<OpenAPIErrorCode>` — public constructor `(code, message, options?)`, mirrors code in `.openapiCode`.
## Implementation Notes
The registry deliberately defines exactly three codes — adding new ones
without bumping a major and updating consumers risks silent gaps in
downstream error branches. New failure modes should either map onto one
of the existing codes or be added in a coordinated change covered by an
OpenSpec delta.
`OPENAPI.INVALID_BASE_SCHEMA` is the one behavioural divergence from
upstream `@suaveplan/api`'s schema-registry — upstream silently returned
`undefined` from `extend`/`partial`/`pick`/`omit` when the base was not
a `ZodObject`. We raise so the caller learns why the derivation failed,
which removes a class of "schema mysteriously missing" bugs that were
near-impossible to diagnose in upstream.
The class is side-effect-imported by the package root barrel
(`src/index.ts`) so consumers picking up `@suaveplan/api-openapi`
surface the code registry without depending on a sub-export path.

View File

@@ -0,0 +1,123 @@
# merger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenAPI document transforms — merge, prefix, filter, redact internal
routes. Multi-plugin servers compose the final published spec from
multiple sources (the registry's `buildDocument()`, the Hono
`getOpenAPI31Document()` output, hand-crafted addon docs), and the
merger is the deterministic operation that combines them without
losing information or producing collisions silently.
Every transform returns a new document — the inputs are never mutated —
so consumers can pipeline `prefixPaths(removeInternalRoutes(merge(...)))`
without worrying about aliasing.
## Features
- `mergeOpenAPIDocuments(base, ...docs)` — per-path-item path merge,
spread-merge of components, dedup tags by name, concat servers.
- `prefixPaths(doc, prefix)` — prepends a prefix to every path,
collapsing duplicate slashes so `prefix="/api/"` + `path="/users"`
yields `/api/users`.
- `filterPathsByTag(doc, tags[])` — keeps only operations whose `tags`
array intersects the supplied list.
- `removeInternalRoutes(doc)` — drops operations marked `x-internal: true`.
- `extractOpenAPIDoc(app, info)` — pulls a fully-rendered document out
of a Hono `OpenAPIHono` for inspection or merging.
## Usage: Basic
```ts
import {
mergeOpenAPIDocuments,
} from "@suaveplan/api-openapi/merger";
const base: OpenAPIDocument = {
openapi: "3.1.0",
info: { title: "Combined", version: "1.0.0" },
paths: { "/a": { get: { responses: {} } } },
};
const addon: Partial<OpenAPIDocument> = {
paths: { "/b": { post: { responses: {} } } },
};
const merged = mergeOpenAPIDocuments(base, addon);
console.log(Object.keys(merged.paths)); // ["/a", "/b"]
```
## Usage: Advanced
```ts
import {
extractOpenAPIDoc,
filterPathsByTag,
mergeOpenAPIDocuments,
prefixPaths,
removeInternalRoutes,
} from "@suaveplan/api-openapi/merger";
import { OpenAPIHono } from "@hono/zod-openapi";
const usersApp = new OpenAPIHono();
const projectsApp = new OpenAPIHono();
// … register routes …
const usersDoc = extractOpenAPIDoc(usersApp, {
title: "Users",
version: "1.0.0",
});
const projectsDoc = extractOpenAPIDoc(projectsApp, {
title: "Projects",
version: "1.0.0",
});
const merged = mergeOpenAPIDocuments(
{ openapi: "3.1.0", info: { title: "Public API", version: "1.0.0" }, paths: {} },
prefixPaths(usersDoc, "/v1"),
prefixPaths(projectsDoc, "/v1")
);
// Hide internal endpoints + restrict to public-facing tags.
const publicDoc = filterPathsByTag(
removeInternalRoutes(merged),
["users", "projects"]
);
```
## API Reference
- `function mergeOpenAPIDocuments(base, ...docs): OpenAPIDocument`
- `function prefixPaths(doc, prefix): OpenAPIDocument`
- `function extractOpenAPIDoc(app, info): OpenAPIDocument`
- `function filterPathsByTag(doc, tags[]): OpenAPIDocument`
- `function removeInternalRoutes(doc): OpenAPIDocument`
- `interface MergeOptions { pathPrefix?: string; overridePaths?: boolean }` — reserved for future use.
## Implementation Notes
`mergeOpenAPIDocuments` is left-associative — merging `(a, b, c)` is the
same as `merge(merge(a, b), c)`. This property is asserted under
fast-check at 200 runs in `tests/property.test.ts`, ensuring the merger
remains a monoid even as future spread-rules land.
`mergeOpenAPIDocuments` does **not** detect cross-document collisions
on the same `(method, path)`. The last value wins. That is the intended
upstream behaviour and the only reasonable default — strict-detection
mode is reserved for a follow-up.
`prefixPaths` uses `String.replace(/\/+/g, "/")` to collapse duplicate
slashes. This is the only place double-slashes can be introduced (the
prefix `/api/` plus path `/users` would yield `/api//users` without
collapse). The transform is idempotent: applying it twice with the same
prefix yields the same paths.
`filterPathsByTag` drops paths whose every operation is removed,
preventing empty `PathItem` shells in the result. `removeInternalRoutes`
applies the same rule for `x-internal`.
The transforms intentionally do not validate the input documents — they
treat the input as best-effort structural data. Validation belongs to a
separate spec-validator package (out of scope here).

View File

@@ -0,0 +1,89 @@
# namespacing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `namespacing` module turns a plugin's structural identity (`name`,
`displayName`) plus a registered route's `(method, path)` tuple into the
deterministic OpenAPI surface fields — tag name, component-schema
prefix, normalised OAS path, and `operationId`. It is the bridge between
the plugin-namespaced runtime and the single merged OpenAPI 3.1
document `@suaveplan/api-openapi` emits.
Determinism matters: clients regenerate SDKs against the document and
expect stable `operationId`s across boots. Collisions — two plugins
producing the same id — would silently overwrite the earlier route in
client toolchains, so the module raises `OPENAPI.OPERATION_ID_COLLISION`
the moment a duplicate appears.
## Features
- `tagFor(plugin)` — prefers `displayName`, falls back to `name`.
- `componentPrefix(plugin)` — `<pluginName>` literal, used by the
emitter as the prefix for `components.schemas` entries.
- `normaliseRoutePath(path)` — rewrites Express-style `:id` parameters
into the OAS `{id}` form. Idempotent.
- `operationIdFor(plugin, method, path)` — full deterministic id:
`<plugin>.<method>.<oasPath>`.
- `assignOperationIds(inputs)` — bulk walk that respects pre-set
`operationId` values and raises a typed error on collision.
## Usage: Basic
```ts
import {
assignOperationIds,
operationIdFor,
tagFor,
} from "@suaveplan/api-openapi/namespacing";
const plugin = { name: "billing", displayName: "Billing" };
operationIdFor(plugin, "GET", "/invoices/:id");
// => "billing.get./invoices/{id}"
tagFor(plugin); // => "Billing"
```
## Usage: Advanced
```ts
import { assignOperationIds } from "@suaveplan/api-openapi/namespacing";
import type { RegisteredRoute } from "@suaveplan/api-openapi/types";
const plugins = [
{ name: "billing", displayName: "Billing" },
{ name: "users" },
];
const routes: ReadonlyArray<{
plugin: (typeof plugins)[number];
route: RegisteredRoute;
}> = [
{ plugin: plugins[0]!, route: { method: "GET", path: "/invoices/:id", tags: [] } },
{ plugin: plugins[1]!, route: { method: "GET", path: "/users/:id", tags: [] } },
];
const namespaced = assignOperationIds(routes);
// Throws OPENAPI.OPERATION_ID_COLLISION if any duplicate appears.
```
## API Reference
- `NamespacePluginInfo` — `{ name, displayName? }`.
- `tagFor(plugin) → string`.
- `componentPrefix(plugin) → string`.
- `normaliseRoutePath(path: string) → string`.
- `operationIdFor(plugin, method, path) → string`.
- `assignOperationIds(inputs) → NamespacedRoute[]`.
- `NamespacedRoute` — `{ pluginName, tag, operationId, method, path, route }`.
## Implementation Notes
- Path normalisation uses the regex `:([A-Za-z_][\w-]*)` so colons
preceded by non-identifier characters (e.g. `:443` in a server URL)
are not rewritten.
- Method is lower-cased to match OAS path-item key casing.
- Pre-set `operationId` overrides the derived one — useful for stable
ids that pre-date the convention. Collisions still raise.
- The module does not know about strictness; the emitter applies
strictness to missing schemas separately.

View File

@@ -0,0 +1,125 @@
# plugin-integration
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`definePluginRoutes` is the single helper plugins call from their `onInit`
to register routes. Every call atomically updates both the Hono
`OpenAPIHono` app (via `app.openapi(route, handler)`) and the
`OpenAPIRegistry` (via `registry.registerRoute(...)`). The two surfaces
cannot drift — there is no path through the helper that touches only
one of them.
Eliminates a class of upstream bugs where a plugin would either
register against Hono but forget the registry (operation served but
not discoverable) or the inverse (discoverable but not served).
## Features
- Atomically registers each route against both Hono and the registry.
- Records `pluginName` on every registry entry for later `registry.getRoutesByPlugin(name)` lookups.
- Carries `operationId`, `summary`, `description`, `deprecated`, and `security` through into the registry record.
- Returns the list of `RegisteredRoute` entries for caller-side post-processing.
- Defensive-copies `tags` and `security` arrays so the route config remains immutable from the registry's perspective.
## Usage: Basic
```ts
import { OpenAPIHono, z } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi/registry";
import { GET, successResponse } from "@suaveplan/api-openapi/route-factory";
import { definePluginRoutes } from "@suaveplan/api-openapi/plugin-integration";
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "X", version: "1.0.0" },
});
const ListUsers = GET("/users")
.tags("users")
.operationId("listUsers")
.response(successResponse(z.array(z.object({ id: z.string() }))))
.build();
definePluginRoutes(
[
{
route: ListUsers,
handler: (c) => c.json({ success: true as const, data: [] }, 200),
},
],
{ app, registry, pluginName: "users" }
);
```
## Usage: Advanced
```ts
import { definePluginRoutes } from "@suaveplan/api-openapi/plugin-integration";
import { definePlugin } from "@suaveplan/api-plugin";
interface MyServices {
app: OpenAPIHono;
registry: OpenAPIRegistry;
}
export const projectsPlugin = definePlugin<{
/* config shape */
}>({
name: "projects",
version: "0.1.0",
onInit(ctx) {
const app = ctx.getService("app") as MyServices["app"];
const registry = ctx.getService("registry") as MyServices["registry"];
const created = definePluginRoutes(
[
/* { route, handler } entries built with RouteBuilder */
],
{ app, registry, pluginName: "projects" }
);
ctx.logger.info(`projects: registered ${created.length} routes`);
},
});
```
## API Reference
- `type RouteHandler = (c: Context) => Response | Promise<Response> | unknown`
- `interface PluginRouteEntry { route: RouteConfig; handler: RouteHandler }`
- `interface DefinePluginRoutesOptions { app: OpenAPIHono; registry: OpenAPIRegistry; pluginName: string }`
- `function definePluginRoutes(entries, options): RegisteredRoute[]`
## Implementation Notes
The helper iterates the entries sequentially — there is no parallelism.
Hono's `app.openapi` is synchronous and side-effecting, so concurrency
would gain nothing. Sequential iteration also gives well-defined
ordering in `registry.getRoutesByPlugin(name)`: the array reflects the
order routes were declared in the plugin source, which is the order
authors expect when consuming the registry for documentation or
introspection.
The `RegisteredRoute` record copies the route's `tags` and `security`
arrays defensively. A plugin could otherwise mutate the array on its
`RouteConfig` post-registration and silently alter the registry's
introspection view.
`route.method` is passed through to the registry as-is. Hono normalises
methods to lowercase, so the registry receives lowercase, matching the
expectations of `OpenAPIRegistry.buildDocument()`.
The helper is intentionally a free function (not a method on the
registry). Decoupling it from the registry class means plugins can
substitute their own "register-both-surfaces" implementation in tests
without touching the registry contract.
The handler type is `(c: Context) => Response | Promise<Response> | unknown`
— Hono's actual handler signature is broader (typed under generics
that depend on the specific RouteConfig). Trying to surface that
inference at the helper boundary would require every caller to thread
the same generics, defeating the helper's purpose. We accept the loose
signature and trust the call site of `app.openapi` to type-check the
handler against the route's contract.

View File

@@ -0,0 +1,134 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralised OpenAPI 3.1 registry that holds `info`, `servers`,
`securitySchemes`, `tags`, and `routes`. Acts as the single source of
truth across the package: plugins register against it, the merger
consumes its `buildDocument()` output, and the Hono integration wires
it directly to `app.doc(...)` via `applyTo(app)`. Eliminates the
upstream's silent drift between the introspection registry and the
served `/openapi.json`.
The registry is instance-based — never a singleton — so multi-tenant
servers, parallel tests, and ephemeral spec previews all run without
shared state.
## Features
- Constructor takes `info`, optional `servers`, optional `security`
(security-scheme records), and optional `globalSecurity` (default
security requirements for every operation).
- `registerSecurityScheme(scheme)` / `registerTag(name, description?)`
/ `registerRoute(route)`.
- `getRoutes()`, `getRoutesByPlugin(name)`, `getRoutesByTag(tag)`,
`updateInfo(partial)`, `addServer(server)`, `clearRoutes()`.
- `buildDocument()` returns the full `OpenAPIDocument` (3.1.0, info,
servers, paths derived from registered routes, components.securitySchemes,
tags, security).
- `applyTo(app, specPath = "/openapi.json")` registers a GET handler on
the Hono app that returns `buildDocument()` as JSON.
- `BearerAuthScheme` + `ApiKeyScheme` presets.
- Factory `createOpenAPIRegistry(options)`.
## Usage: Basic
```ts
import { createOpenAPIRegistry } from "@suaveplan/api-openapi/registry";
const registry = createOpenAPIRegistry({
info: { title: "Users API", version: "1.0.0" },
});
registry.registerRoute({
method: "get",
path: "/users",
tags: ["users"],
operationId: "listUsers",
summary: "List all users",
});
const doc = registry.buildDocument();
console.log(doc.paths["/users"]?.get?.operationId); // "listUsers"
```
## Usage: Advanced
```ts
import { OpenAPIHono } from "@hono/zod-openapi";
import {
BearerAuthScheme,
createOpenAPIRegistry,
} from "@suaveplan/api-openapi/registry";
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "Multi-plugin API", version: "0.1.0" },
servers: [{ url: "https://api.example.com" }],
security: [BearerAuthScheme],
globalSecurity: [{ Bearer: [] }],
});
registry.registerRoute({
method: "post",
path: "/projects",
tags: ["projects"],
pluginName: "projects",
operationId: "createProject",
deprecated: false,
security: [{ Bearer: [] }],
});
// Single line wires the registry to Hono. The served `/openapi.json` is
// always `buildDocument()` — no drift possible.
registry.applyTo(app, "/openapi.json");
```
## API Reference
- `class OpenAPIRegistry`
- `constructor(options: OpenAPIRegistryOptions)`
- `registerSecurityScheme(scheme: SecurityScheme): void`
- `registerTag(name: string, description?: string): void`
- `registerRoute(route: RegisteredRoute): void`
- `getRoutes(): RegisteredRoute[]` — defensive copy
- `getRoutesByPlugin(pluginName: string): RegisteredRoute[]`
- `getRoutesByTag(tag: string): RegisteredRoute[]`
- `updateInfo(info: Partial<OpenAPIInfo>): void`
- `addServer(server: OpenAPIServer): void`
- `clearRoutes(): void`
- `buildDocument(): OpenAPIDocument`
- `applyTo(app: OpenAPIHono, specPath?: string): void`
- `function createOpenAPIRegistry(options): OpenAPIRegistry`
- `const BearerAuthScheme: SecurityScheme`
- `const ApiKeyScheme: SecurityScheme`
- `const DEFAULT_SPEC_PATH = "/openapi.json"`
## Implementation Notes
`buildDocument()` synthesises `paths` from the registered routes — each
route becomes an `Operation` keyed by method under its `path`. Multiple
methods on the same path collapse into one `PathItem` (registry collisions
on the same `(method, path)` overwrite — last registration wins, mirroring
Hono's `app.openapi` semantics).
The synthesised `Operation` body is intentionally lean — `operationId`,
`summary`, `description`, `tags`, `deprecated`, `security`, and an empty
`responses: {}`. Request/response Zod-derived schemas are added by the
plugin integration when the registry is paired with `app.openapi(route, …)`
and merged into the registry doc via `mergeOpenAPIDocuments`. Keeping
the registry's emitted operations schema-free means the registry stays
zero-cost to build (no Zod traversal) and reusable for non-Hono targets.
`applyTo` registers a plain `app.get(specPath, …)` handler rather than
`app.doc(specPath, …)` so the served body is exactly `buildDocument()`.
Hono's `app.doc` invokes its own document builder, which would defeat
the purpose of using the registry as the source of truth.
All getters return defensive copies of arrays — internal mutation of
`routes` / `servers` from caller code is impossible without going
through the explicit `register*` / `clearRoutes` / `addServer` methods.

View File

@@ -0,0 +1,123 @@
# route-factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Typed route construction utilities for `@hono/zod-openapi`. The
`RouteBuilder` is a fluent generic that threads `TParams`, `TQuery`,
`TBody`, and `TResponse` from `.params/.query/.body/.response` calls
through to the final `.build()` output. The resulting `TypedRouteConfig`
is assignable to `RouteConfig`, so `app.openapi(route, handler)` infers
`c.req.valid("params" | "query" | "json")` to the exact Zod type
without `unknown` widening.
Also exports the wire-shape `ErrorResponseSchema`, the wider
`StandardResponses` (covering every status code a `SuaveplanError` can
emit — 400/401/403/404/409/422/429/500/503/504), and the
`successResponse` / `paginatedResponse` / `errorResponse` envelope
helpers.
## Features
- `RouteBuilder<TParams, TQuery, TBody, TResponse>` fluent generic.
- `route(method, path)` factory + `GET`/`POST`/`PUT`/`PATCH`/`DELETE` shorthands.
- `StandardResponses` widened to cover the full SuaveplanError status range.
- `ErrorResponseSchema` carrying the `cause` field from `SuaveplanError.toJSON()`.
- `successResponse(schema)`, `paginatedResponse(itemSchema)`, `errorResponse(description)` helpers.
## Usage: Basic
```ts
import z from "zod";
import { GET, successResponse } from "@suaveplan/api-openapi/route-factory";
const ListUsers = GET("/users")
.tags("users")
.operationId("listUsers")
.response(successResponse(z.array(z.object({ id: z.string() }))))
.build();
```
## Usage: Advanced
```ts
import { OpenAPIHono } from "@hono/zod-openapi";
import z from "zod";
import {
POST,
paginatedResponse,
successResponse,
} from "@suaveplan/api-openapi/route-factory";
const UserParam = z.object({ id: z.string().uuid() });
const UserCreate = z.object({ email: z.string().email() });
const User = z.object({ id: z.string(), email: z.string() });
const createUser = POST("/users/:id")
.tags("users")
.operationId("createUser")
.deprecated()
.security([{ Bearer: [] }])
.params(UserParam)
.body(UserCreate)
.response(successResponse(User))
.build();
const listUsers = POST("/users/list")
.tags("users")
.response(paginatedResponse(User))
.build();
const app = new OpenAPIHono();
app.openapi(createUser, async (c) => {
const params = c.req.valid("param"); // typed as z.infer<typeof UserParam>
const body = c.req.valid("json"); // typed as z.infer<typeof UserCreate>
return c.json({ success: true, data: { id: params.id, email: body.email } });
});
```
## API Reference
- `type RouteMethod = "get"|"post"|"put"|"patch"|"delete"`
- `interface RouteOptions<TParams, TQuery, TBody, TResponse>`
- `const ErrorResponseSchema` + `type ErrorResponse`
- `function successResponse<T>(dataSchema: T)`
- `function errorResponse(description: string)`
- `function paginatedResponse<T>(itemSchema: T)`
- `const StandardResponses` — 400/401/403/404/409/422/429/500/503/504 each pointing at `ErrorResponseSchema`
- `type TypedRouteConfig<TParams, TQuery, TBody, TResponse>`
- `class RouteBuilder<TParams, TQuery, TBody, TResponse>`
- `function route(method, path): RouteBuilder`
- `const GET/POST/PUT/PATCH/DELETE`
## Implementation Notes
`TypedRouteConfig` uses `Omit<RouteConfig, "request"> & { request: … }`
so the `request` property is non-optional. Hono's `InputType*`
conditional types only match when `request` is present, so making it
optional silently downgrades inference to `unknown`.
The builder records state in a private object rather than directly on
`this` so the four `.params/.query/.body/.response` overloads can re-cast
the receiver to a new generic instantiation without TypeScript losing
track of the actual stored values.
`StandardResponses` is widened to cover every status `SuaveplanError`
emits. Upstream omitted 422 (validation), 429 (rate limit), 503
(service unavailable), and 504 (gateway timeout), forcing every plugin
that wanted to document those responses to redefine them locally — a
common source of doc drift. The widened set is now the single source.
`ErrorResponseSchema` adds the `cause` field on top of upstream's
shape. SuaveplanError supports a cause chain, and serialising it gives
clients enough context to diagnose without requesting logs. `cause` is
typed `z.unknown()` because the chain may be heterogeneous —
SuaveplanError descendants, native `Error`s, or serialised payloads.
`responses` always merges `200: Success` first and then spreads
`StandardResponses` — so consumers can override `200` by passing
`.response(…)` but cannot accidentally drop one of the standard error
slots.

View File

@@ -0,0 +1,95 @@
# common-schemas
## Purpose
Library of common Zod schemas decorated with OpenAPI `example` metadata.
Covers the universal HTTP query / path / pagination primitives that
every API plugin reaches for — UUID, email, URL, ISO datetime, signed
and unsigned integers, page/limit/sort order. Pulling these into a
single module ensures every plugin documents the same constraints,
prevents drift, and makes the OpenAPI spec consistent across the entire
multi-plugin server.
The schemas are exported as a single `CommonSchemas` const object so
consumers can spread, destructure, or `.openapi(...)`-extend any subset.
They are deliberately not pre-registered against any `SchemaRegistry` —
each consumer chooses whether (and under which name) to register them.
## Features
- `uuid`, `email`, `url`, `datetime` — string formats with examples.
- `positiveInt`, `nonNegativeInt` — integer constraints.
- `page` / `limit` — coercing pagination inputs with sane defaults.
- `sortOrder` — `"asc" | "desc"` enum, default `"desc"`.
- Every entry carries an OpenAPI `example` so the generated spec is rich.
## Usage: Basic
```ts
import z from "zod";
import { CommonSchemas } from "@suaveplan/api-openapi/schema-registry";
const ListQuery = z.object({
page: CommonSchemas.page,
limit: CommonSchemas.limit,
sort: CommonSchemas.sortOrder,
});
```
## Usage: Advanced
```ts
import z from "zod";
import {
CommonSchemas,
createSchemaRegistry,
} from "@suaveplan/api-openapi/schema-registry";
const registry = createSchemaRegistry();
// Register pagination primitives once under shared names so OpenAPI emits
// `#/components/schemas/pagination.page` references rather than inlining
// the same constraint in every operation.
registry.register("pagination.page", CommonSchemas.page);
registry.register("pagination.limit", CommonSchemas.limit);
registry.register("pagination.sortOrder", CommonSchemas.sortOrder);
const ListUsersQuery = z.object({
page: registry.get("pagination.page")!,
limit: registry.get("pagination.limit")!,
sort: registry.get("pagination.sortOrder")!,
q: z.string().optional(),
});
```
## API Reference
- `CommonSchemas.uuid: ZodString` — UUID string with example.
- `CommonSchemas.email: ZodString` — email string.
- `CommonSchemas.url: ZodString` — URL string.
- `CommonSchemas.datetime: ZodString` — ISO-8601 datetime.
- `CommonSchemas.positiveInt: ZodNumber` — `> 0`, integer.
- `CommonSchemas.nonNegativeInt: ZodNumber` — `≥ 0`, integer.
- `CommonSchemas.page: ZodCoerce<number>` — `min 1`, default `1`.
- `CommonSchemas.limit: ZodCoerce<number>` — `min 1, max 100`, default `20`.
- `CommonSchemas.sortOrder: ZodEnum<"asc" | "desc">` — default `"desc"`.
## Implementation Notes
`page` and `limit` use `z.coerce.number()` rather than `z.number()` so
they accept query-string inputs (always strings on the wire). Without
coercion every plugin would have to wrap them in `.transform(Number)`
manually, defeating the point of a shared catalogue.
The `.openapi({ example: ... })` annotation surfaces in the generated
spec without affecting validation. Consumers that want richer
descriptions (`{ description: "..." }`) call `.openapi(...)` on a copy
rather than mutating the shared schema.
`sortOrder` defaults to `"desc"` because the most common pagination
pattern is "latest first". Plugins that want a different default override
locally with `CommonSchemas.sortOrder.default("asc")`.
`CommonSchemas` is `as const` — TypeScript surfaces every field with its
narrowest literal type. Consumers can `typeof CommonSchemas` to derive
per-field types without importing each schema individually.

View File

@@ -0,0 +1,115 @@
# schema-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Instance-based registry for reusable Zod schemas. Each schema is bound
to a stable name and may be derived into related shapes via
`.extend/.partial/.pick/.omit`. The registry is deliberately
instance-only — there is no module-level singleton — so multi-tenant
servers and parallel tests cannot accidentally share state. Calls into
the derivation helpers raise typed `OpenAPIError`s when the base name
is unknown or refers to a non-`ZodObject` schema, replacing upstream's
silent `undefined` return.
## Features
- `register(name, schema, description?)` / `get(name)` / `has(name)` / `getDescription(name)`.
- `getNames()`, `getAll()`, `remove(name)`, `clear()`.
- `extend(baseName, name, extension)`, `partial(baseName, name)`,
`pick(baseName, name, keys[])`, `omit(baseName, name, keys[])`.
- `createSchemaRegistry()` factory — no global accessor.
- `defineSchema(schema, meta, registry)` — registry argument is required.
- Typed `OPENAPI.SCHEMA_NOT_FOUND` / `OPENAPI.INVALID_BASE_SCHEMA` errors from derivation helpers.
## Usage: Basic
```ts
import z from "zod";
import {
createSchemaRegistry,
defineSchema,
} from "@suaveplan/api-openapi/schema-registry";
const registry = createSchemaRegistry();
defineSchema(z.object({ id: z.string(), email: z.string() }), { name: "User" }, registry);
const User = registry.get("User");
```
## Usage: Advanced
```ts
import z from "zod";
import {
createSchemaRegistry,
} from "@suaveplan/api-openapi/schema-registry";
const registry = createSchemaRegistry();
registry.register(
"User",
z.object({
id: z.string().uuid(),
email: z.string().email(),
password: z.string(),
}),
"Internal user shape including hashed password"
);
// Public shape — drop `password`.
const UserPublic = registry.omit("User", "UserPublic", ["password"]);
// Patch shape — every field optional.
const UserPatch = registry.partial("User", "UserPatch");
// Extended shape — add audit field.
const UserAudit = registry.extend("User", "UserAudit", {
lastSeenAt: z.string().datetime(),
});
console.log(registry.getNames()); // ["User","UserPublic","UserPatch","UserAudit"]
```
## API Reference
- `class SchemaRegistry`
- `register<T>(name, schema, description?): T`
- `get<T>(name): T | undefined`
- `has(name): boolean`
- `getDescription(name): string | undefined`
- `getNames(): string[]`
- `getAll(): Map<string, z.ZodType>`
- `remove(name): boolean`
- `clear(): void`
- `extend(baseName, name, extension): z.ZodObject` — throws `OPENAPI.SCHEMA_NOT_FOUND` or `OPENAPI.INVALID_BASE_SCHEMA`
- `partial(baseName, name): z.ZodObject` — same throw semantics
- `pick(baseName, name, keys[]): z.ZodObject` — same throw semantics
- `omit(baseName, name, keys[]): z.ZodObject` — same throw semantics
- `function createSchemaRegistry(): SchemaRegistry`
- `function defineSchema<T>(schema: T, meta: {name, description?}, registry: SchemaRegistry): T`
## Implementation Notes
`requireObjectBase` is the central guard. It throws
`OPENAPI.SCHEMA_NOT_FOUND` when the name is unknown and
`OPENAPI.INVALID_BASE_SCHEMA` when the registered schema is not a
`ZodObject`. Both branches carry the base name in the error context so
the call site can be diagnosed without a stack walk.
`pick` and `omit` take a `string[]` keys list rather than the Zod
shape-mask object because OpenAPI consumers usually serialise key lists
from configuration, not from compile-time literals. The shape mask is
constructed internally to match Zod's expected input.
`defineSchema` requires the `registry` argument by position (third arg).
Calling it without is a compile error. This is the deliberate behavioural
divergence from upstream's `defineSchema(schema, meta)` which always
wrote to a process-global registry — a footgun that turned every test
file into a coupled neighbour.
The class uses `private readonly` Maps for `schemas` and `descriptions`.
External mutation is impossible without going through `register` /
`remove` / `clear`, which gives the registry well-defined ownership of
both maps and lets `getAll()` safely return a fresh copy.

View File

@@ -0,0 +1,101 @@
# strictness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`strictness` resolves the host's missing-schema policy for the OpenAPI
emitter and applies it consistently across every route. It encodes the
two-mode contract from the spec: `warn` (emit + warn + count) and
`refuse-install` (raise before the plugin is installed). The same
helper is wired into both the emitter and the host's plugin-install
gate so missing schemas behave identically wherever they're detected.
The module also owns the `api.openapi.missing_schema_total` OTel
counter — without an OTel meter exporter wired into the host, missing
schemas silently accumulate. Surfacing them as a per-plugin per-route
labelled metric makes drift visible to anyone watching the
observability stack.
## Features
- `resolveStrictness({ configured, environment })` — typed precedence
resolver. Configured value wins; otherwise `"production"` → refuse,
`"development"`/anything else → warn.
- `reportMissingSchema(input)` — single chokepoint that either throws
or logs+counts. Accepts an injectable logger and counter so test
setups can assert exact behaviour with a real `MeterProvider`.
- `getMissingSchemaCounter()` — lazy accessor over the package meter so
production callers do not need to thread the counter through.
## Usage: Basic
```ts
import {
reportMissingSchema,
resolveStrictness,
} from "@suaveplan/api-openapi/strictness";
const mode = resolveStrictness({ environment: "production" });
// "refuse-install"
reportMissingSchema({
mode,
plugin: "billing",
method: "GET",
path: "/invoices/:id",
slot: "response",
});
// throws OpenAPIError(OPENAPI.MISSING_SCHEMA)
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
__resetMissingSchemaCounter,
reportMissingSchema,
} from "@suaveplan/api-openapi/strictness";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
__resetMissingSchemaCounter();
const logger = { warn: (msg: string) => console.warn(msg) } as const;
reportMissingSchema({
mode: "warn",
plugin: "billing",
method: "POST",
path: "/invoices",
slot: "request",
logger,
});
// → counter increments, logger.warn called
```
## API Reference
- `StrictnessMode` — `"warn" | "refuse-install"`.
- `HostEnvironment` — `"development" | "production"`.
- `resolveStrictness(input) → StrictnessMode`.
- `reportMissingSchema(input) → void` (throws on `refuse-install`).
- `getMissingSchemaCounter() → Counter`.
- `__resetMissingSchemaCounter()` — test-only seam.
- Constants: `MISSING_SCHEMA_COUNTER_NAME`, `OPENAPI_METER_NAME`.
## Implementation Notes
- The counter is acquired lazily so registering the package side-effect
does not require an OTel SDK at import time. Tests reset the cache
via `__resetMissingSchemaCounter()` before binding a fresh
`MeterProvider`.
- The counter labels — `plugin`, `route`, `slot` — match the spec's
scenario assertion (`{ plugin, route }`) and add `slot` so request
vs response misses can be distinguished without a second metric.
- `LoggerLike.warn` is optional in the contract (`@suaveplan/types`);
the call is guarded with optional chaining so structured loggers that
only ship `info` + `error` still work.

View File

@@ -0,0 +1,84 @@
# types/openapi
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structural TypeScript types for OpenAPI 3.1 documents, supporting shapes
(info / server / security scheme), registered-route metadata, and the
HTTP-method literal union. These are the contract surfaces exchanged
between the registry, the merger, the route factory, and external
consumers — keeping them in one file makes the public surface easy to
audit and prevents shape drift between modules.
The types intentionally mirror just the slice of OpenAPI 3.1 that this
package emits and merges. Full spec coverage (callbacks, links, runtime
expressions) is out of scope — the package targets server-tier route
documentation, not a general-purpose spec authoring tool.
## Features
- `OpenAPIInfo`, `OpenAPIServer`, `SecurityScheme` — top-level document atoms.
- `RegisteredRoute` — registry-internal metadata for a single registered route.
- `Operation`, `PathItem`, `OpenAPIDocument` — structural document shape used by the merger.
- `HttpMethod` literal union + `HTTP_METHODS` readonly array.
- Open-ended `x-${string}` index signatures so vendor extensions pass through without weakening field types.
## Usage: Basic
```ts
import type { OpenAPIDocument } from "@suaveplan/api-openapi/types";
const doc: OpenAPIDocument = {
openapi: "3.1.0",
info: { title: "My API", version: "1.0.0" },
paths: {},
};
```
## Usage: Advanced
```ts
import { HTTP_METHODS } from "@suaveplan/api-openapi/types";
import type { Operation, PathItem } from "@suaveplan/api-openapi/types";
function eachOp(item: PathItem, fn: (m: string, op: Operation) => void): void {
for (const m of HTTP_METHODS) {
const op = item[m];
if (op) fn(m, op);
}
}
```
## API Reference
- `interface OpenAPIInfo` — title, version, optional description / contact / license / x-extensions.
- `interface OpenAPIServer` — url, description, variables, x-extensions.
- `interface SecurityScheme` — name + type discriminator + scheme/bearerFormat/in/description.
- `interface RegisteredRoute` — internal record: method, path, tags, operationId, pluginName, summary, description, deprecated, security.
- `interface Operation` — OpenAPI operation, x-extension friendly.
- `interface PathItem` — keyed by lowercase HTTP method.
- `interface OpenAPIDocument` — info + servers + paths + components + tags + security.
- `type HttpMethod` — `"get" | "post" | "put" | "patch" | "delete" | "options" | "head"`.
- `const HTTP_METHODS` — readonly array of every literal.
## Implementation Notes
All fields except `info.title`, `info.version`, `openapi`, and `paths`
are optional, matching the OpenAPI 3.1 schema. The merger and registry
construct documents with `paths: {}` and grow them as routes register —
never with `paths: undefined`.
`Operation.responses` is typed as `Record<string, unknown>` rather than a
structurally-typed response object because the actual response shapes
are produced by `@hono/zod-openapi`'s `createRoute` and depend on the
caller's Zod schemas. Tightening the type would require importing zod
into the types module and would cascade compile-time dependencies. The
merger only manipulates whole responses, never their internals, so the
weaker shape is safe.
`HTTP_METHODS` exists alongside the literal union because the merger
and filter helpers need to iterate the methods at runtime. Keeping the
two in this single module guarantees they cannot drift.