5.0 KiB
5.0 KiB
emitter
(no submodules)
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 —
displayNamebecomes 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 receivesecurity: []. - 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 raiseOPENAPI.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
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
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 —
requestfor non-GET,responsefor 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 (
displayNameorname), not by the raw plugin name, so two plugins sharing adisplayNameare 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 MUSTstructuredCloneif they need to mutate.