first commit
This commit is contained in:
223
docs/api-stack/api/api-openapi/README.md
Normal file
223
docs/api-stack/api/api-openapi/README.md
Normal 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
|
||||
|
||||
129
docs/api-stack/api/api-openapi/src/emitter/emitter.md
Normal file
129
docs/api-stack/api/api-openapi/src/emitter/emitter.md
Normal 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.
|
||||
71
docs/api-stack/api/api-openapi/src/endpoints/endpoints.md
Normal file
71
docs/api-stack/api/api-openapi/src/endpoints/endpoints.md
Normal 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).
|
||||
@@ -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.
|
||||
123
docs/api-stack/api/api-openapi/src/merger/merger.md
Normal file
123
docs/api-stack/api/api-openapi/src/merger/merger.md
Normal 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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
134
docs/api-stack/api/api-openapi/src/registry/registry.md
Normal file
134
docs/api-stack/api/api-openapi/src/registry/registry.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
101
docs/api-stack/api/api-openapi/src/strictness/strictness.md
Normal file
101
docs/api-stack/api/api-openapi/src/strictness/strictness.md
Normal 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.
|
||||
84
docs/api-stack/api/api-openapi/src/types/openapi.md
Normal file
84
docs/api-stack/api/api-openapi/src/types/openapi.md
Normal 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.
|
||||
Reference in New Issue
Block a user