Initial Commit

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

View File

@@ -0,0 +1,80 @@
import { z } from "@hono/zod-openapi";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { OPENAPI_CODES, OpenAPIError } from "./error-codes/error-codes.js";
import * as Barrel from "./index.js";
import { POST, successResponse } from "./route-factory/route-factory.js";
import { createSchemaRegistry } from "./schema-registry/schema-registry.js";
describe("spec-conformance — every OPENAPI_* code is thrown from a production path", () => {
it("OPENAPI.SCHEMA_NOT_FOUND", { timeout: 5000 }, () => {
const reg = createSchemaRegistry();
let err: unknown;
try {
reg.extend("missing", "x", { k: z.string() });
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(OpenAPIError);
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.SCHEMA_NOT_FOUND);
});
it("OPENAPI.INVALID_BASE_SCHEMA", { timeout: 5000 }, () => {
const reg = createSchemaRegistry();
reg.register("p", z.string());
let err: unknown;
try {
reg.partial("p", "x");
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(
OPENAPI_CODES.INVALID_BASE_SCHEMA
);
});
it("OPENAPI.INVALID_INPUT is thrown from definePluginRoutes boundary", {
timeout: 5000,
}, async () => {
const { definePluginRoutes } = await import(
"./plugin-integration/plugin-integration.js"
);
let err: unknown;
try {
definePluginRoutes(
[],
// Missing pluginName → INVALID_INPUT
{ app: {}, registry: {} } as never
);
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.INVALID_INPUT);
});
});
describe("spec-conformance — no global singleton in the barrel", () => {
it("does not export getSchemaRegistry", { timeout: 5000 }, () => {
expect(Object.keys(Barrel)).not.toContain("getSchemaRegistry");
});
it("does not export globalSchemaRegistry", { timeout: 5000 }, () => {
expect(Object.keys(Barrel)).not.toContain("globalSchemaRegistry");
});
});
describe("spec-conformance — RouteBuilder end-to-end inference", () => {
it("threads body type to c.req.valid('json')", { timeout: 5000 }, () => {
const Body = z.object({ name: z.string() });
const Resp = z.object({ id: z.string() });
const r = POST("/users")
.body(Body)
.response(successResponse(Resp))
.build();
// Compile-time: r must carry the Body's Zod type through
// the TypedRouteConfig structural shape.
expectTypeOf(r.request).toMatchTypeOf<{
body: { content: { "application/json": { schema: typeof Body } } };
}>();
});
});

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,153 @@
import type { Counter } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import type { RegisteredRoute } from "../types/openapi.js";
import { createOpenapiEmitter, type EmitterHostConfig } from "./emitter.js";
function rec(
method: string,
path: string,
extra: Partial<RegisteredRoute> = {}
): RegisteredRoute {
return { method, path, tags: [], ...extra };
}
function captureLogger(): {
logger: LoggerLike;
warns: Array<{ msg: string; ctx?: Readonly<Record<string, unknown>> }>;
} {
const warns: Array<{
msg: string;
ctx?: Readonly<Record<string, unknown>>;
}> = [];
return {
warns,
logger: {
debug: () => undefined,
info: () => undefined,
warn: (msg, ctx) => warns.push({ msg, ctx }),
error: () => undefined,
},
};
}
function counterStub(): {
counter: Counter;
calls: Array<{ value: number; attrs?: object }>;
} {
const calls: Array<{ value: number; attrs?: object }> = [];
return {
counter: {
add(value: number, attrs?: object) {
calls.push({ value, attrs });
},
} as unknown as Counter,
calls,
};
}
const hostBase: EmitterHostConfig = {
info: { title: "host", version: "1.0.0" },
};
describe("spec-conformance — strictness modes", () => {
it("warn mode emits placeholder and increments warning counter", {
timeout: 5000,
}, () => {
const { logger, warns } = captureLogger();
// Inject the missing-schema counter via the strictness module's
// reportMissingSchema path. The emitter does not accept a missing
// counter directly; the warning surface is the OTel meter, so we
// verify (a) the document emits, (b) the placeholder appears, and
// (c) the logger receives the structured warning.
const e = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
// No requestSchema, no responseSchema.
route: rec("GET", "/no-schema"),
},
],
hostConfig: { ...hostBase, openapi: { strictness: "warn" } },
logger,
});
const op = e.getDocument().paths["/no-schema"]?.get;
expect(op).toBeDefined();
// Placeholder is encoded into responses["200"].description.
expect(
(op?.responses["200"] as { description: string }).description
).toBe("schema-missing");
expect(warns.length).toBeGreaterThan(0);
expect(warns[0]?.msg).toContain("missing");
});
it("refuse-install raises OPENAPI_MISSING_SCHEMA", {
timeout: 5000,
}, () => {
let err: unknown;
try {
createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: rec("GET", "/no-schema"),
},
],
hostConfig: {
...hostBase,
openapi: { strictness: "refuse-install" },
},
});
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(OpenAPIError);
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.MISSING_SCHEMA);
});
it("production environment defaults to refuse-install", {
timeout: 5000,
}, () => {
let err: unknown;
try {
createOpenapiEmitter({
manifests: [{ name: "billing" }],
routes: [
{
pluginName: "billing",
route: rec("GET", "/x"),
},
],
hostConfig: {
...hostBase,
environment: "production",
},
});
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.MISSING_SCHEMA);
});
it("development environment defaults to warn", { timeout: 5000 }, () => {
// counterStub asserts no production-path crash AND that wiring
// an injected counter remains a no-op for this code path.
const { calls } = counterStub();
const e = createOpenapiEmitter({
manifests: [{ name: "billing" }],
routes: [
{
pluginName: "billing",
route: rec("GET", "/x"),
},
],
hostConfig: { ...hostBase, environment: "development" },
});
expect(e.getStrictness()).toBe("warn");
// No conversion failure occurred → injected counter unused.
expect(calls).toEqual([]);
});
});

View File

@@ -0,0 +1,764 @@
import { type Counter, metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parse as yamlParse } from "yaml";
import {
OPENAPI_CODES,
type OpenAPIError,
} from "../error-codes/error-codes.js";
import { __resetMissingSchemaCounter } from "../strictness/strictness.js";
import type { RegisteredRoute } from "../types/openapi.js";
import {
__resetEmitterCounters,
CONVERSION_ERROR_COUNTER_NAME,
createOpenapiEmitter,
EMISSION_COUNTER_NAME,
type EmitterAuthAdapter,
type EmitterHostConfig,
type EmitterPluginManifest,
type EmitterRouteInput,
} from "./emitter.js";
function route(
method: string,
path: string,
extra: Partial<RegisteredRoute> = {}
): RegisteredRoute {
return { method, path, tags: [], ...extra };
}
const bearerAdapter: EmitterAuthAdapter = {
name: "Bearer",
scheme: { type: "http", scheme: "bearer" },
};
const hostConfig: EmitterHostConfig = {
info: { title: "host", version: "1.0.0" },
servers: [{ url: "https://api.example.test" }],
};
function counterStub(): {
counter: Counter;
calls: Array<{ value: number; attrs?: object }>;
} {
const calls: Array<{ value: number; attrs?: object }> = [];
const counter = {
add(value: number, attrs?: object) {
calls.push({ value, attrs });
},
} as unknown as Counter;
return { counter, calls };
}
describe("createOpenapiEmitter — basic emission", () => {
it("emits a 3.1 document covering every registered route", {
timeout: 5000,
}, () => {
const manifests: EmitterPluginManifest[] = [
{
name: "billing",
displayName: "Billing",
description: "Invoices",
},
{
name: "users",
displayName: "Users",
description: "People",
},
];
const routes: EmitterRouteInput[] = [
{
pluginName: "billing",
route: route("GET", "/invoices/:id"),
responseSchema: { type: "object" },
},
{
pluginName: "billing",
route: route("POST", "/invoices"),
requestSchema: { type: "object" },
responseSchema: { type: "object" },
responseStatus: 201,
},
{
pluginName: "users",
route: route("GET", "/users/:id"),
responseSchema: { type: "object" },
},
{
pluginName: "users",
route: route("POST", "/users"),
requestSchema: { type: "object" },
responseSchema: { type: "object" },
},
];
const e = createOpenapiEmitter({
manifests,
routes,
authAdapter: bearerAdapter,
hostConfig,
});
const doc = e.getDocument();
expect(doc.openapi).toBe("3.1.0");
expect(Object.keys(doc.paths).sort()).toEqual([
"/invoices",
"/invoices/{id}",
"/users",
"/users/{id}",
]);
expect(doc.tags?.map((t) => t.name).sort()).toEqual([
"Billing",
"Users",
]);
expect(doc.components?.securitySchemes?.["Bearer"]).toEqual({
type: "http",
scheme: "bearer",
});
// operationIds are namespaced
expect(
(doc.paths["/invoices/{id}"]?.get as { operationId: string })
.operationId
).toBe("billing.get./invoices/{id}");
// Memoisation
expect(e.getDocument()).toBe(doc);
expect(e.getPluginNames()).toEqual(["billing", "users"]);
});
it("public route emits empty security", { timeout: 5000 }, () => {
const e = createOpenapiEmitter({
manifests: [
{
name: "billing",
displayName: "Billing",
routes: { publicRoutes: ["/health"] },
},
],
routes: [
{
pluginName: "billing",
route: route("GET", "/health"),
responseSchema: { type: "object" },
},
{
pluginName: "billing",
route: route("GET", "/invoices/:id"),
responseSchema: { type: "object" },
},
],
authAdapter: bearerAdapter,
hostConfig,
});
const doc = e.getDocument();
expect(doc.paths["/health"]?.get?.security).toEqual([]);
expect(doc.paths["/invoices/{id}"]?.get?.security).toEqual([
{ Bearer: [] },
]);
});
it("non-public route inherits adapter scheme", { timeout: 5000 }, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/invoices/:id"),
responseSchema: { type: "object" },
},
],
authAdapter: bearerAdapter,
hostConfig,
});
const doc = e.getDocument();
expect(doc.paths["/invoices/{id}"]?.get?.security).toEqual([
{ Bearer: [] },
]);
});
it("without an auth adapter, routes have no security entry", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/x"),
responseSchema: { type: "object" },
},
],
hostConfig,
});
const doc = e.getDocument();
expect(doc.paths["/x"]?.get?.security).toBeUndefined();
expect(doc.components?.securitySchemes).toBeUndefined();
});
it("components are prefixed by plugin name", { timeout: 5000 }, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/invoices/:id", {
summary: "Get invoice",
description: "Fetch a single invoice",
deprecated: true,
}),
responseSchema: { type: "object" },
components: { Invoice: { type: "object" } },
},
],
authAdapter: bearerAdapter,
hostConfig,
});
const doc = e.getDocument();
expect(doc.components?.schemas).toEqual({
"billing.Invoice": { type: "object" },
});
expect(doc.paths["/invoices/{id}"]?.get?.summary).toBe("Get invoice");
expect(doc.paths["/invoices/{id}"]?.get?.description).toBe(
"Fetch a single invoice"
);
expect(doc.paths["/invoices/{id}"]?.get?.deprecated).toBe(true);
});
it("throws OPENAPI.INVALID_INPUT for routes referencing unknown plugins", {
timeout: 5000,
}, () => {
let err: unknown;
try {
createOpenapiEmitter({
manifests: [{ name: "billing" }],
routes: [
{
pluginName: "ghost",
route: route("GET", "/x"),
responseSchema: { type: "object" },
},
],
hostConfig,
});
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.INVALID_INPUT);
});
it("raises OPENAPI.UNKNOWN_PLUGIN_IN_FILTER for unknown plugin", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/x"),
responseSchema: { type: "object" },
},
],
hostConfig,
});
let err: unknown;
try {
e.getDocumentForPlugin("ghost");
} catch (ex) {
err = ex;
}
expect((err as OpenAPIError).code).toBe(
OPENAPI_CODES.UNKNOWN_PLUGIN_IN_FILTER
);
});
it("per-plugin view contains only that plugin's routes and is memoised", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [
{ name: "billing", displayName: "Billing" },
{ name: "users", displayName: "Users" },
],
routes: [
{
pluginName: "billing",
route: route("GET", "/invoices"),
responseSchema: { type: "object" },
},
{
pluginName: "users",
route: route("GET", "/users"),
responseSchema: { type: "object" },
},
],
hostConfig,
});
const v1 = e.getDocumentForPlugin("billing");
const v2 = e.getDocumentForPlugin("billing");
expect(v1).toBe(v2);
expect(Object.keys(v1.paths)).toEqual(["/invoices"]);
expect(v1.tags?.map((t) => t.name)).toEqual(["Billing"]);
});
it("getJson and getYaml round-trip the same document", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/invoices"),
responseSchema: { type: "object" },
},
],
hostConfig,
});
const fromJson = JSON.parse(e.getJson()) as Record<string, unknown>;
const fromYaml = yamlParse(e.getYaml()) as Record<string, unknown>;
expect(fromJson).toEqual(fromYaml);
});
it("thunk-style requestSchema is invoked and embedded", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("POST", "/invoices"),
requestSchema: () => ({ type: "object" }),
responseSchema: () => ({ type: "object" }),
},
],
hostConfig,
});
const post = e.getDocument().paths["/invoices"]?.post;
expect(post?.requestBody).toEqual({
required: true,
content: { "application/json": { schema: { type: "object" } } },
});
});
it("schema conversion failure raises OPENAPI.SCHEMA_CONVERSION_FAILED and bumps the injected counter", {
timeout: 5000,
}, () => {
const { counter, calls } = counterStub();
let err: unknown;
try {
createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("POST", "/invoices"),
requestSchema: () => {
throw new Error("zod-to-openapi blew up");
},
responseSchema: { type: "object" },
},
],
hostConfig,
conversionErrorCounter: counter,
});
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(
OPENAPI_CODES.SCHEMA_CONVERSION_FAILED
);
expect(calls).toEqual([
{
value: 1,
attrs: {
plugin: "billing",
route: "POST /invoices",
slot: "request",
},
},
]);
});
it("schema conversion failure on response uses the response slot", {
timeout: 5000,
}, () => {
const { counter, calls } = counterStub();
let err: unknown;
try {
createOpenapiEmitter({
manifests: [{ name: "billing", displayName: "Billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/x"),
responseSchema: () => {
throw new Error("blow");
},
},
],
hostConfig,
conversionErrorCounter: counter,
});
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(
OPENAPI_CODES.SCHEMA_CONVERSION_FAILED
);
expect(calls[0]?.attrs).toEqual({
plugin: "billing",
route: "GET /x",
slot: "response",
});
});
it("non-Error converter cause flows through cause: undefined", {
timeout: 5000,
}, () => {
let err: unknown;
try {
createOpenapiEmitter({
manifests: [{ name: "billing" }],
routes: [
{
pluginName: "billing",
route: route("GET", "/x"),
// biome-aware: throwing a string is legitimate test fodder.
responseSchema: (() => {
throw "not-an-error";
}) as () => Record<string, unknown>,
},
],
hostConfig,
conversionErrorCounter: counterStub().counter,
});
} catch (e) {
err = e;
}
expect((err as OpenAPIError).cause).toBeUndefined();
});
it("host servers carry through and are omitted when absent", {
timeout: 5000,
}, () => {
const e1 = createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [],
hostConfig: { info: { title: "h", version: "1" } },
});
expect(e1.getDocument().servers).toBeUndefined();
const e2 = createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [],
hostConfig: {
info: { title: "h", version: "1" },
servers: [{ url: "https://x.test" }],
},
});
expect(e2.getDocument().servers).toEqual([{ url: "https://x.test" }]);
});
it("manifest description is reflected in the tag entry", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [
{
name: "billing",
displayName: "Billing",
description: "Invoices",
},
],
routes: [],
hostConfig,
});
expect(e.getDocument().tags).toEqual([
{ name: "Billing", description: "Invoices" },
]);
});
it("tags with no description still appear", { timeout: 5000 }, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "users" }],
routes: [],
hostConfig,
});
expect(e.getDocument().tags).toEqual([{ name: "users" }]);
});
it("duplicate displayName tags dedup", { timeout: 5000 }, () => {
const e = createOpenapiEmitter({
manifests: [
{ name: "billing", displayName: "Common" },
{ name: "billing2", displayName: "Common" },
],
routes: [],
hostConfig,
});
expect(e.getDocument().tags).toEqual([{ name: "Common" }]);
});
it("getStrictness reflects configured value", { timeout: 5000 }, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [],
hostConfig: {
info: { title: "h", version: "1" },
openapi: { strictness: "warn" },
},
});
expect(e.getStrictness()).toBe("warn");
});
it("emission counter increments via OTel MeterProvider", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.disable();
metrics.setGlobalMeterProvider(provider);
__resetEmitterCounters();
__resetMissingSchemaCounter();
createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [],
hostConfig: { info: { title: "h", version: "1" } },
});
await reader.forceFlush();
const ms = exporter.getMetrics();
const counter = ms
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === EMISSION_COUNTER_NAME);
expect(counter?.dataPoints[0]?.value).toBe(1);
await reader.shutdown();
metrics.disable();
__resetEmitterCounters();
});
it("conversion counter via OTel meter when not injected", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.disable();
metrics.setGlobalMeterProvider(provider);
__resetEmitterCounters();
__resetMissingSchemaCounter();
try {
createOpenapiEmitter({
manifests: [{ name: "p", displayName: "P" }],
routes: [
{
pluginName: "p",
route: route("POST", "/x"),
requestSchema: () => {
throw new Error("blow");
},
responseSchema: { type: "object" },
},
],
hostConfig: { info: { title: "h", version: "1" } },
});
} catch {
// expected
}
await reader.forceFlush();
const ms = exporter.getMetrics();
const counter = ms
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === CONVERSION_ERROR_COUNTER_NAME);
expect(counter?.dataPoints[0]?.value).toBe(1);
await reader.shutdown();
metrics.disable();
__resetEmitterCounters();
});
it("components block is populated by security schemes alone when no plugin components", {
timeout: 5000,
}, () => {
// Bearer adapter present but no plugin components → securitySchemes-only branch.
const e = createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [],
authAdapter: bearerAdapter,
hostConfig: { info: { title: "h", version: "1" } },
});
const doc = e.getDocument();
expect(doc.components?.schemas).toBeUndefined();
expect(doc.components?.securitySchemes).toBeDefined();
});
it("components block is populated by component schemas alone when no auth adapter", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [{ name: "p", displayName: "P" }],
routes: [
{
pluginName: "p",
route: route("GET", "/x"),
responseSchema: { type: "object" },
components: { Thing: { type: "object" } },
},
],
hostConfig: { info: { title: "h", version: "1" } },
});
const doc = e.getDocument();
expect(doc.components?.schemas).toEqual({
"p.Thing": { type: "object" },
});
expect(doc.components?.securitySchemes).toBeUndefined();
});
it("filtered view tolerates a document with no top-level tags", {
timeout: 5000,
}, () => {
// Manifest with neither displayName nor name produces an entry,
// but to exercise the `document.tags?` undefined branch we mutate
// an emitter that produced no tags. The emitter always emits at
// least one tag per manifest, so we synthesise the case by
// calling the filter path on a freshly-built emitter and then
// overwriting document.tags to undefined via the returned ref.
const e = createOpenapiEmitter({
manifests: [{ name: "p", displayName: "P" }],
routes: [
{
pluginName: "p",
route: route("GET", "/x"),
responseSchema: { type: "object" },
},
],
hostConfig: { info: { title: "h", version: "1" } },
});
// The emitter memoises the document by reference — mutating it
// here is intentional for branch coverage of the
// `document.tags?.filter ?? []` fallback in the filtered view.
(e.getDocument() as { tags?: unknown }).tags = undefined;
const v = e.getDocumentForPlugin("p");
expect(v.tags).toEqual([]);
});
it("empty manifests and routes yields a tag-less document", {
timeout: 5000,
}, () => {
const e = createOpenapiEmitter({
manifests: [],
routes: [],
hostConfig: { info: { title: "h", version: "1" } },
});
expect(e.getDocument().tags).toBeUndefined();
});
it("non-GET route missing schema uses the request slot", {
timeout: 5000,
}, () => {
const warns: string[] = [];
createOpenapiEmitter({
manifests: [{ name: "p", displayName: "P" }],
routes: [{ pluginName: "p", route: route("POST", "/x") }],
hostConfig: {
info: { title: "h", version: "1" },
openapi: { strictness: "warn" },
},
logger: {
debug: () => undefined,
info: () => undefined,
warn: (msg) => warns.push(msg),
error: () => undefined,
},
});
expect(warns[0]).toContain("request");
});
it("conversionCounter is memoised across two failures", {
timeout: 5000,
}, () => {
// Force two conversion failures so the cached-conversion branch
// (cachedConversion !== undefined) is exercised.
const { counter } = counterStub();
for (let i = 0; i < 2; i++) {
try {
createOpenapiEmitter({
manifests: [{ name: "p", displayName: "P" }],
routes: [
{
pluginName: "p",
route: route("POST", `/x${i}`),
requestSchema: () => {
throw new Error("boom");
},
responseSchema: { type: "object" },
},
],
hostConfig: { info: { title: "h", version: "1" } },
conversionErrorCounter: counter,
});
} catch {
// expected
}
}
// Drive the in-package conversion counter (no injected counter)
// to hit the cached-conversion branch directly.
try {
createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [
{
pluginName: "p",
route: route("POST", "/y"),
requestSchema: () => {
throw new Error("boom");
},
responseSchema: { type: "object" },
},
],
hostConfig: { info: { title: "h", version: "1" } },
});
} catch {
// expected
}
try {
createOpenapiEmitter({
manifests: [{ name: "p" }],
routes: [
{
pluginName: "p",
route: route("POST", "/z"),
requestSchema: () => {
throw new Error("boom");
},
responseSchema: { type: "object" },
},
],
hostConfig: { info: { title: "h", version: "1" } },
});
} catch {
// expected
}
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,468 @@
import { type Counter, metrics } from "@opentelemetry/api";
import type { LoggerLike } from "@suaveplan/types";
import { stringify as yamlStringify } from "yaml";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import {
assignOperationIds,
componentPrefix,
type NamespacePluginInfo,
tagFor,
} from "../namespacing/namespacing.js";
import {
type HostEnvironment,
reportMissingSchema,
resolveStrictness,
type StrictnessMode,
} from "../strictness/strictness.js";
import type {
OpenAPIDocument,
OpenAPIInfo,
OpenAPIServer,
Operation,
PathItem,
RegisteredRoute,
} from "../types/openapi.js";
/**
* Subset of `PluginManifest` ingested by the emitter. Decoupled from the
* full manifest contract so callers can synthesise minimal manifests in
* tests without satisfying every field.
*/
export interface EmitterPluginManifest extends NamespacePluginInfo {
readonly displayName?: string;
readonly description?: string;
readonly scopes?: ReadonlyArray<{
readonly name: string;
readonly description?: string;
}>;
readonly routes?: {
readonly prefix?: string;
readonly publicRoutes?: readonly string[];
};
readonly openapi?: {
readonly contributes?: boolean;
readonly externalDocs?: string;
};
}
/**
* Route input accepted by the emitter — pairs a plugin name with the
* registry-recorded {@link RegisteredRoute} plus any pre-built
* request/response schema slots already resolved to OpenAPI shape.
*
* Schemas are accepted as already-converted OpenAPI Schema Objects (the
* upstream Zod→OAS conversion is the caller's responsibility — typically
* via `@hono/zod-openapi`'s internal `zod-to-openapi`). Routes with no
* `requestSchema` and no `responseSchema` trigger the strictness gate.
*/
export interface EmitterRouteInput {
readonly pluginName: string;
readonly route: RegisteredRoute;
readonly requestSchema?:
| Record<string, unknown>
| (() => Record<string, unknown>);
readonly responseSchema?:
| Record<string, unknown>
| (() => Record<string, unknown>);
readonly responseStatus?: number;
/** Component-schema entries to register under `<plugin>.<TypeName>`. */
readonly components?: Readonly<Record<string, Record<string, unknown>>>;
}
/**
* The active auth adapter's contribution. Mirrors `@suaveplan/api-auth`'s
* `SecuritySchemeContribution` without taking a runtime dep — the emitter
* only needs the structural pair `{ name, scheme }`.
*/
export interface EmitterAuthAdapter {
readonly name: string;
readonly scheme: Record<string, unknown>;
}
/**
* Host-level config block read from `host.json`. The emitter copies
* these through verbatim — `info`, `servers`, optional contact + license.
*/
export interface EmitterHostConfig {
readonly info: OpenAPIInfo;
readonly servers?: readonly OpenAPIServer[];
readonly openapi?: {
readonly strictness?: StrictnessMode;
};
readonly environment?: HostEnvironment;
}
/**
* Input bag accepted by {@link createOpenapiEmitter}.
*/
export interface CreateOpenapiEmitterInput {
readonly manifests: readonly EmitterPluginManifest[];
readonly routes: readonly EmitterRouteInput[];
readonly authAdapter?: EmitterAuthAdapter;
readonly hostConfig: EmitterHostConfig;
readonly logger?: LoggerLike;
readonly emissionCounter?: Counter;
readonly conversionErrorCounter?: Counter;
}
/**
* Public surface returned by {@link createOpenapiEmitter}. Owns the
* memoised document, exposes JSON + YAML serialisation, and a filtered
* per-plugin view used by the `/docs/{plugin}` endpoint.
*/
export interface OpenapiEmitter {
/** Return the merged OpenAPI 3.1 document as a plain object. */
getDocument(): OpenAPIDocument;
/** Return the merged document serialised as canonical JSON. */
getJson(): string;
/** Return the merged document serialised as YAML. */
getYaml(): string;
/** Per-plugin filtered view; raises UNKNOWN_PLUGIN_IN_FILTER on misses. */
getDocumentForPlugin(pluginName: string): OpenAPIDocument;
/** Names of every plugin known to the emitter. */
getPluginNames(): readonly string[];
/** Strictness mode the emitter resolved at construction time. */
getStrictness(): StrictnessMode;
}
/** Counter name surfaced when the emitter produces a document. */
export const EMISSION_COUNTER_NAME = "api.openapi.emission_total";
/** Counter name surfaced when zod→OAS conversion raises. */
export const CONVERSION_ERROR_COUNTER_NAME =
"api.openapi.conversion_error_total";
const METER_NAME = "@suaveplan/api-openapi";
let cachedEmission: Counter | undefined;
let cachedConversion: Counter | undefined;
function emissionCounter(): Counter {
if (cachedEmission === undefined) {
cachedEmission = metrics
.getMeter(METER_NAME)
.createCounter(EMISSION_COUNTER_NAME, {
description: "Number of OpenAPI documents produced",
});
}
return cachedEmission;
}
function conversionCounter(): Counter {
if (cachedConversion === undefined) {
cachedConversion = metrics
.getMeter(METER_NAME)
.createCounter(CONVERSION_ERROR_COUNTER_NAME, {
description: "Number of zod→OAS schema conversion failures",
});
}
return cachedConversion;
}
/**
* Reset cached counters — exposed for tests rebinding a fresh
* `MeterProvider` between cases.
*/
export function __resetEmitterCounters(): void {
cachedEmission = undefined;
cachedConversion = undefined;
}
type SchemaSource = Record<string, unknown> | (() => Record<string, unknown>);
function resolveSchema(
source: SchemaSource,
plugin: string,
method: string,
path: string,
slot: "request" | "response",
injected?: Counter
): Record<string, unknown> {
if (typeof source !== "function") return source;
try {
return source();
} catch (cause) {
const c = injected ?? conversionCounter();
c.add(1, { plugin, route: `${method.toUpperCase()} ${path}`, slot });
throw new OpenAPIError(
OPENAPI_CODES.SCHEMA_CONVERSION_FAILED,
`failed to convert ${slot} schema for ${method.toUpperCase()} ${path} (plugin "${plugin}")`,
{
cause: cause instanceof Error ? cause : undefined,
context: { plugin, method, path, slot },
}
);
}
}
/**
* Assemble the OpenAPI emitter.
*
* Steps performed at construction:
*
* 1. Resolve strictness from `hostConfig.openapi.strictness` →
* `hostConfig.environment` → default `"warn"`.
* 2. Index manifests by plugin name. Routes pointing at unknown
* plugin names raise `OPENAPI.INVALID_INPUT`.
* 3. Walk every route through {@link assignOperationIds} — collisions
* raise `OPENAPI.OPERATION_ID_COLLISION`.
* 4. Apply strictness to routes missing both request and response
* schemas (`refuse-install` raises, `warn` increments the counter).
* 5. Build paths, components, tags. Security inherits the active
* auth adapter's scheme except on `publicRoutes`.
* 6. Increment the emission counter.
*
* The resulting document is memoised. Repeated `getDocument()` calls
* return the same reference, which trivially satisfies the spec's
* "lifecycle dedup" requirement.
*/
export function createOpenapiEmitter(
input: CreateOpenapiEmitterInput
): OpenapiEmitter {
const strictness = resolveStrictness({
...(input.hostConfig.openapi?.strictness !== undefined
? { configured: input.hostConfig.openapi.strictness }
: {}),
...(input.hostConfig.environment !== undefined
? { environment: input.hostConfig.environment }
: {}),
});
const manifestByName = new Map<string, EmitterPluginManifest>();
for (const manifest of input.manifests) {
manifestByName.set(manifest.name, manifest);
}
// Validate every route has a known plugin.
for (const r of input.routes) {
if (!manifestByName.has(r.pluginName)) {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
`route references unknown plugin "${r.pluginName}"`
);
}
}
// Namespace + collision check.
const namespaced = assignOperationIds(
input.routes.map((r) => ({
plugin: manifestByName.get(r.pluginName) as EmitterPluginManifest,
route: r.route,
}))
);
// Apply strictness.
for (const r of input.routes) {
if (r.requestSchema === undefined && r.responseSchema === undefined) {
const slot =
r.route.method.toUpperCase() === "GET" ? "response" : "request";
reportMissingSchema({
mode: strictness,
plugin: r.pluginName,
method: r.route.method,
path: r.route.path,
slot,
...(input.logger ? { logger: input.logger } : {}),
});
}
}
// Build document.
const paths: Record<string, PathItem> = {};
const componentSchemas: Record<string, unknown> = {};
const tags = new Map<string, { name: string; description?: string }>();
const publicRoutesByPlugin = new Map<string, Set<string>>();
for (const manifest of input.manifests) {
const publics = new Set<string>(manifest.routes?.publicRoutes ?? []);
publicRoutesByPlugin.set(manifest.name, publics);
const tagName = tagFor(manifest);
if (!tags.has(tagName)) {
const entry: { name: string; description?: string } = {
name: tagName,
};
if (manifest.description !== undefined) {
entry.description = manifest.description;
}
tags.set(tagName, entry);
}
}
for (let i = 0; i < input.routes.length; i++) {
const r = input.routes[i] as EmitterRouteInput;
const n = namespaced[i] as (typeof namespaced)[number];
const manifest = manifestByName.get(
r.pluginName
) as EmitterPluginManifest;
const publics = publicRoutesByPlugin.get(r.pluginName);
// Register components under the plugin prefix.
const prefix = componentPrefix(manifest);
if (r.components !== undefined) {
for (const [typeName, schema] of Object.entries(r.components)) {
componentSchemas[`${prefix}.${typeName}`] = schema;
}
}
const op: Operation = { responses: {} };
op.operationId = n.operationId;
op.tags = [n.tag];
if (r.route.summary !== undefined) op.summary = r.route.summary;
if (r.route.description !== undefined) {
op.description = r.route.description;
}
if (r.route.deprecated === true) op.deprecated = true;
// Request body.
if (r.requestSchema !== undefined) {
const reqSchema = resolveSchema(
r.requestSchema,
r.pluginName,
r.route.method,
r.route.path,
"request",
input.conversionErrorCounter
);
op.requestBody = {
required: true,
content: { "application/json": { schema: reqSchema } },
};
}
// Response body.
const status = (r.responseStatus ?? 200).toString();
if (r.responseSchema !== undefined) {
const resSchema = resolveSchema(
r.responseSchema,
r.pluginName,
r.route.method,
r.route.path,
"response",
input.conversionErrorCounter
);
op.responses[status] = {
description: "OK",
content: { "application/json": { schema: resSchema } },
};
} else {
op.responses[status] = { description: "schema-missing" };
}
// Security inheritance.
if (publics?.has(r.route.path) === true) {
op.security = [];
} else if (input.authAdapter !== undefined) {
op.security = [{ [input.authAdapter.name]: [] }];
}
const item = paths[n.path] ?? {};
const method = n.method as keyof PathItem;
item[method] = op;
paths[n.path] = item;
}
// Security schemes.
const securitySchemes: Record<string, unknown> = {};
if (input.authAdapter !== undefined) {
securitySchemes[input.authAdapter.name] = input.authAdapter.scheme;
}
const document: OpenAPIDocument = {
openapi: "3.1.0",
info: input.hostConfig.info,
paths,
};
if (
input.hostConfig.servers !== undefined &&
input.hostConfig.servers.length > 0
) {
document.servers = [...input.hostConfig.servers];
}
if (
Object.keys(componentSchemas).length > 0 ||
Object.keys(securitySchemes).length > 0
) {
document.components = {};
if (Object.keys(componentSchemas).length > 0) {
document.components.schemas = componentSchemas;
}
if (Object.keys(securitySchemes).length > 0) {
document.components.securitySchemes = securitySchemes;
}
}
if (tags.size > 0) {
document.tags = Array.from(tags.values());
}
// Emission counter.
const ec = input.emissionCounter ?? emissionCounter();
ec.add(1, { plugins: input.manifests.length });
const pluginNames = input.manifests.map((m) => m.name);
// Cache the per-plugin filtered views lazily.
const filteredCache = new Map<string, OpenAPIDocument>();
function getDocument(): OpenAPIDocument {
return document;
}
function getJson(): string {
return JSON.stringify(document);
}
function getYaml(): string {
return yamlStringify(document);
}
function getDocumentForPlugin(pluginName: string): OpenAPIDocument {
if (!manifestByName.has(pluginName)) {
throw new OpenAPIError(
OPENAPI_CODES.UNKNOWN_PLUGIN_IN_FILTER,
`unknown plugin "${pluginName}" in filtered docs request`,
{ context: { pluginName } }
);
}
const cached = filteredCache.get(pluginName);
if (cached !== undefined) return cached;
const targetTag = tagFor(
manifestByName.get(pluginName) as EmitterPluginManifest
);
const filteredPaths: Record<string, PathItem> = {};
for (const [path, item] of Object.entries(document.paths)) {
const kept: PathItem = {};
for (const method of [
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
] as const) {
const op = item[method];
if (op?.tags?.includes(targetTag) === true) {
kept[method] = op;
}
}
if (Object.keys(kept).length > 0) {
filteredPaths[path] = kept;
}
}
const out: OpenAPIDocument = {
...document,
paths: filteredPaths,
tags: document.tags?.filter((t) => t.name === targetTag) ?? [],
};
filteredCache.set(pluginName, out);
return out;
}
return {
getDocument,
getJson,
getYaml,
getDocumentForPlugin,
getPluginNames: () => pluginNames,
getStrictness: () => strictness,
};
}

View File

@@ -0,0 +1,12 @@
export {
__resetEmitterCounters,
CONVERSION_ERROR_COUNTER_NAME,
type CreateOpenapiEmitterInput,
createOpenapiEmitter,
EMISSION_COUNTER_NAME,
type EmitterAuthAdapter,
type EmitterHostConfig,
type EmitterPluginManifest,
type EmitterRouteInput,
type OpenapiEmitter,
} from "./emitter.js";

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,128 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { parse as yamlParse } from "yaml";
import {
createOpenapiEmitter,
type EmitterPluginManifest,
type EmitterRouteInput,
} from "../emitter/emitter.js";
import { OPENAPI_CODES } from "../error-codes/error-codes.js";
import type { RegisteredRoute } from "../types/openapi.js";
import { createOpenapiRouter, mountOpenapiEndpoints } from "./endpoints.js";
function r(
method: string,
path: string,
extra: Partial<RegisteredRoute> = {}
): RegisteredRoute {
return { method, path, tags: [], ...extra };
}
function buildEmitter() {
const manifests: EmitterPluginManifest[] = [
{ name: "billing", displayName: "Billing", description: "Invoices" },
{ name: "users", displayName: "Users", description: "People" },
];
const routes: EmitterRouteInput[] = [
{
pluginName: "billing",
route: r("GET", "/invoices/:id"),
responseSchema: { type: "object" },
},
{
pluginName: "users",
route: r("GET", "/users/:id"),
responseSchema: { type: "object" },
},
];
return createOpenapiEmitter({
manifests,
routes,
hostConfig: { info: { title: "host", version: "1.0.0" } },
});
}
describe("openapi endpoints — content-types and body shape", () => {
it("json endpoint returns application/json", {
timeout: 5000,
}, async () => {
const app = createOpenapiRouter({ emitter: buildEmitter() });
const res = await app.request("/openapi.json");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const body = (await res.json()) as { openapi: string };
expect(body.openapi).toBe("3.1.0");
});
it("yaml endpoint returns application/yaml", {
timeout: 5000,
}, async () => {
const app = createOpenapiRouter({ emitter: buildEmitter() });
const res = await app.request("/openapi.yaml");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/yaml");
const body = await res.text();
const parsed = yamlParse(body) as { openapi: string };
expect(parsed.openapi).toBe("3.1.0");
});
it("docs index lists every plugin", { timeout: 5000 }, async () => {
const app = createOpenapiRouter({ emitter: buildEmitter() });
const res = await app.request("/docs");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
const html = await res.text();
expect(html).toContain("billing");
expect(html).toContain("users");
});
it("filtered view excludes other plugins", { timeout: 5000 }, async () => {
const app = createOpenapiRouter({ emitter: buildEmitter() });
const res = await app.request("/docs/billing");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("billing");
// The HTML stub shows only the requested plugin's name in the
// h1, but the spec also requires that the underlying filtered
// document contains no users routes — that is asserted in
// emitter.test.ts. Here we sanity-check the HTTP shape.
});
it("filtered view returns 404 for unknown plugin", {
timeout: 5000,
}, async () => {
const app = createOpenapiRouter({ emitter: buildEmitter() });
const res = await app.request("/docs/ghost");
expect(res.status).toBe(404);
const body = (await res.json()) as { code: string };
expect(body.code).toBe(OPENAPI_CODES.UNKNOWN_PLUGIN_IN_FILTER);
});
it("mountOpenapiEndpoints attaches to an existing app", {
timeout: 5000,
}, async () => {
const app = new Hono();
const out = mountOpenapiEndpoints(app, {
emitter: buildEmitter(),
});
expect(out).toBe(app);
const res = await app.request("/openapi.json");
expect(res.status).toBe(200);
});
it("unexpected errors propagate from the filtered handler", {
timeout: 5000,
}, async () => {
const emitter = buildEmitter();
// Wrap getDocumentForPlugin to throw a non-OpenAPIError.
const broken = {
...emitter,
getDocumentForPlugin: () => {
throw new Error("kaboom");
},
};
const app = createOpenapiRouter({ emitter: broken });
const res = await app.request("/docs/billing");
expect(res.status).toBe(500);
});
});

View File

@@ -0,0 +1,95 @@
import { Hono } from "hono";
import type { OpenapiEmitter } from "../emitter/emitter.js";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
/**
* Options accepted by {@link mountOpenapiEndpoints} and its sibling
* factories. The `emitter` is the single source of truth.
*/
export interface OpenapiEndpointsOptions {
readonly emitter: OpenapiEmitter;
}
/**
* Build the standalone Hono router that serves every OpenAPI endpoint:
*
* - `GET /openapi.json` → JSON of the merged document.
* - `GET /openapi.yaml` → YAML of the merged document.
* - `GET /docs` → HTML stub linking to `/openapi.json` (UI sibling
* packages mount richer renderers themselves).
* - `GET /docs/:plugin` → per-plugin filtered HTML stub.
*
* The router is intentionally self-contained so the host can mount it
* at any path. Use {@link mountOpenapiEndpoints} to attach the router
* onto an existing Hono app.
*/
export function createOpenapiRouter(options: OpenapiEndpointsOptions): Hono {
const app = new Hono();
const { emitter } = options;
app.get("/openapi.json", (c) => {
c.header("Content-Type", "application/json");
return c.body(emitter.getJson());
});
app.get("/openapi.yaml", (c) => {
c.header("Content-Type", "application/yaml");
return c.body(emitter.getYaml());
});
app.get("/docs", (c) => {
c.header("Content-Type", "text/html; charset=utf-8");
return c.body(renderRootDocsHtml(emitter));
});
app.get("/docs/:plugin", (c) => {
const plugin = c.req.param("plugin");
try {
const doc = emitter.getDocumentForPlugin(plugin);
c.header("Content-Type", "text/html; charset=utf-8");
return c.body(renderPluginDocsHtml(plugin, doc.info.title));
} catch (err) {
if (
err instanceof OpenAPIError &&
err.code === OPENAPI_CODES.UNKNOWN_PLUGIN_IN_FILTER
) {
c.header("Content-Type", "application/json");
return c.body(
JSON.stringify({
code: err.code,
message: err.message,
}),
404
);
}
throw err;
}
});
return app;
}
/**
* Mount the OpenAPI endpoints onto an existing Hono app. Returns the
* passed app so callers can chain.
*/
export function mountOpenapiEndpoints<TApp extends Hono>(
app: TApp,
options: OpenapiEndpointsOptions
): TApp {
app.route("/", createOpenapiRouter(options));
return app;
}
function renderRootDocsHtml(emitter: OpenapiEmitter): string {
const items = emitter
.getPluginNames()
.map((p) => `<li><a href="/docs/${p}">${p}</a></li>`)
.join("");
const title = emitter.getDocument().info.title;
return `<!doctype html><html><head><meta charset="utf-8"><title>${title} — OpenAPI</title></head><body><h1>${title}</h1><p><a href="/openapi.json">openapi.json</a> · <a href="/openapi.yaml">openapi.yaml</a></p><h2>Plugins</h2><ul>${items}</ul></body></html>`;
}
function renderPluginDocsHtml(plugin: string, hostTitle: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>${hostTitle} — ${plugin}</title></head><body><h1>${plugin}</h1><p><a href="/openapi.json">full openapi.json</a></p></body></html>`;
}

View File

@@ -0,0 +1,5 @@
export {
createOpenapiRouter,
mountOpenapiEndpoints,
type OpenapiEndpointsOptions,
} from "./endpoints.js";

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,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPENAPI_CODES, OpenAPIError } from "./error-codes.js";
describe("OPENAPI_CODES", () => {
it("defines exactly 7 codes", { timeout: 5000 }, () => {
expect(Object.keys(OPENAPI_CODES)).toHaveLength(7);
});
it("namespaces every code under OPENAPI.*", { timeout: 5000 }, () => {
for (const value of Object.values(OPENAPI_CODES)) {
expect(value.startsWith("OPENAPI.")).toBe(true);
}
});
it("freezes the registry", { timeout: 5000 }, () => {
expect(Object.isFrozen(OPENAPI_CODES)).toBe(true);
});
it("exposes every documented code", { timeout: 5000 }, () => {
expect(OPENAPI_CODES.INVALID_INPUT).toBe("OPENAPI.INVALID_INPUT");
expect(OPENAPI_CODES.SCHEMA_NOT_FOUND).toBe("OPENAPI.SCHEMA_NOT_FOUND");
expect(OPENAPI_CODES.INVALID_BASE_SCHEMA).toBe(
"OPENAPI.INVALID_BASE_SCHEMA"
);
expect(OPENAPI_CODES.MISSING_SCHEMA).toBe("OPENAPI.MISSING_SCHEMA");
expect(OPENAPI_CODES.SCHEMA_CONVERSION_FAILED).toBe(
"OPENAPI.SCHEMA_CONVERSION_FAILED"
);
expect(OPENAPI_CODES.OPERATION_ID_COLLISION).toBe(
"OPENAPI.OPERATION_ID_COLLISION"
);
expect(OPENAPI_CODES.UNKNOWN_PLUGIN_IN_FILTER).toBe(
"OPENAPI.UNKNOWN_PLUGIN_IN_FILTER"
);
});
});
describe("OpenAPIError", () => {
it("carries the code via .code and .openapiCode", { timeout: 5000 }, () => {
const err = new OpenAPIError(OPENAPI_CODES.SCHEMA_NOT_FOUND, "missing");
expect(err.code).toBe("OPENAPI.SCHEMA_NOT_FOUND");
expect(err.openapiCode).toBe("OPENAPI.SCHEMA_NOT_FOUND");
});
it("preserves the message and context", { timeout: 5000 }, () => {
const err = new OpenAPIError(OPENAPI_CODES.INVALID_INPUT, "boom", {
context: { name: "User" },
});
expect(err.message).toBe("boom");
expect(err.context).toEqual({ name: "User" });
});
it("threads cause through to Error.cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new OpenAPIError(
OPENAPI_CODES.INVALID_BASE_SCHEMA,
"outer",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("is an Error subclass with a stable name", { timeout: 5000 }, () => {
const err = new OpenAPIError(OPENAPI_CODES.INVALID_INPUT, "x");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("OpenAPIError");
});
});

View File

@@ -0,0 +1,57 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-openapi`.
*
* Every public failure path raises an {@link OpenAPIError} carrying one of
* these namespaced `OPENAPI.*` codes:
*
* - `INVALID_INPUT` — caller passed structurally invalid arguments
* (e.g. a non-Zod value into a builder method that accepts schemas).
* - `SCHEMA_NOT_FOUND` — a derivation helper (`extend`/`partial`/`pick`/
* `omit`) referenced a base schema name that has not been registered.
* - `INVALID_BASE_SCHEMA` — the base schema referenced exists but is
* not a `ZodObject`.
* - `MISSING_SCHEMA` — a route has no request or response schema and
* the emitter is configured with strictness `"refuse-install"`.
* - `SCHEMA_CONVERSION_FAILED` — `@asteasolutions/zod-to-openapi` (or
* the host's Zod-to-OAS bridge) raised when converting a registered
* schema to an OpenAPI schema object.
* - `OPERATION_ID_COLLISION` — two routes emit the same `operationId`
* after namespacing (`<plugin>.<method>.<path>`).
* - `UNKNOWN_PLUGIN_IN_FILTER` — the per-plugin filtered docs view
* (`GET /docs/{plugin}`) was requested for a plugin not registered
* with the emitter.
*/
export const OPENAPI_CODES = defineCodes("OPENAPI", [
"INVALID_INPUT",
"SCHEMA_NOT_FOUND",
"INVALID_BASE_SCHEMA",
"MISSING_SCHEMA",
"SCHEMA_CONVERSION_FAILED",
"OPERATION_ID_COLLISION",
"UNKNOWN_PLUGIN_IN_FILTER",
] as const);
export type OpenAPIErrorCode =
(typeof OPENAPI_CODES)[keyof typeof OPENAPI_CODES];
/**
* Concrete error class for the api-openapi package. Extends
* {@link SuaveplanError} so `instanceof OpenAPIError` narrows `code` to
* {@link OpenAPIErrorCode}. The constructor is widened to `public` so
* callers may construct errors directly.
*/
export class OpenAPIError extends SuaveplanError<OpenAPIErrorCode> {
public readonly openapiCode: OpenAPIErrorCode;
public constructor(
code: OpenAPIErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.openapiCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { OpenAPIErrorCode } from "./error-codes.js";
export {
OPENAPI_CODES,
OpenAPIError,
} from "./error-codes.js";

View File

@@ -0,0 +1,118 @@
// Side-effect import — surfaces error-codes registry on bare
// `import "@suaveplan/api-openapi"`.
import "./error-codes/error-codes.js";
// Emitter
export {
__resetEmitterCounters,
CONVERSION_ERROR_COUNTER_NAME,
type CreateOpenapiEmitterInput,
createOpenapiEmitter,
EMISSION_COUNTER_NAME,
type EmitterAuthAdapter,
type EmitterHostConfig,
type EmitterPluginManifest,
type EmitterRouteInput,
type OpenapiEmitter,
} from "./emitter/emitter.js";
// Endpoints
export {
createOpenapiRouter,
mountOpenapiEndpoints,
type OpenapiEndpointsOptions,
} from "./endpoints/endpoints.js";
// Error codes
export {
OPENAPI_CODES,
OpenAPIError,
type OpenAPIErrorCode,
} from "./error-codes/error-codes.js";
// Merger
export {
extractOpenAPIDoc,
filterPathsByTag,
type MergeOptions,
mergeOpenAPIDocuments,
prefixPaths,
removeInternalRoutes,
} from "./merger/merger.js";
// Namespacing
export {
assignOperationIds,
componentPrefix,
type NamespacedRoute,
type NamespacedRouteInput,
type NamespacePluginInfo,
normaliseRoutePath,
operationIdFor,
tagFor,
} from "./namespacing/namespacing.js";
// Plugin integration
export {
type DefinePluginRoutesOptions,
definePluginRoutes,
type PluginRouteEntry,
type RouteHandler,
} from "./plugin-integration/plugin-integration.js";
// Registry
export {
ApiKeyScheme,
BearerAuthScheme,
createOpenAPIRegistry,
DEFAULT_SPEC_PATH,
OpenAPIRegistry,
type OpenAPIRegistryOptions,
} from "./registry/registry.js";
// Route factory
export {
DELETE,
type ErrorResponse,
ErrorResponseSchema,
errorResponse,
GET,
PATCH,
POST,
PUT,
paginatedResponse,
RouteBuilder,
type RouteMethod,
type RouteOptions,
route,
StandardResponses,
successResponse,
type TypedRouteConfig,
} from "./route-factory/route-factory.js";
// Schema registry
export { CommonSchemas } from "./schema-registry/common-schemas.js";
export {
createSchemaRegistry,
defineSchema,
SchemaRegistry,
} from "./schema-registry/schema-registry.js";
// Strictness
export {
__resetMissingSchemaCounter,
getMissingSchemaCounter,
type HostEnvironment,
MISSING_SCHEMA_COUNTER_NAME,
type MissingSchemaSlot,
OPENAPI_METER_NAME,
type ReportMissingSchemaInput,
type ResolveStrictnessInput,
reportMissingSchema,
resolveStrictness,
type StrictnessMode,
} from "./strictness/strictness.js";
// Types
export {
HTTP_METHODS,
type HttpMethod,
type OpenAPIDocument,
type OpenAPIInfo,
type OpenAPIServer,
type Operation,
type PathItem,
type RegisteredRoute,
type SecurityScheme,
} from "./types/openapi.js";

View File

@@ -0,0 +1,8 @@
export type { MergeOptions } from "./merger.js";
export {
extractOpenAPIDoc,
filterPathsByTag,
mergeOpenAPIDocuments,
prefixPaths,
removeInternalRoutes,
} from "./merger.js";

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,233 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { OpenAPIDocument } from "../types/openapi.js";
import {
extractOpenAPIDoc,
filterPathsByTag,
mergeOpenAPIDocuments,
prefixPaths,
removeInternalRoutes,
} from "./merger.js";
const baseDoc = (): OpenAPIDocument => ({
openapi: "3.1.0",
info: { title: "T", version: "1.0.0" },
paths: { "/a": { get: { responses: { "200": { description: "ok" } } } } },
tags: [{ name: "alpha" }],
});
describe("mergeOpenAPIDocuments", () => {
it("merge composes paths", { timeout: 5000 }, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
paths: {
"/b": { post: { responses: {} } },
},
});
expect(Object.keys(merged.paths).sort()).toEqual(["/a", "/b"]);
});
it("colliding paths union their methods", { timeout: 5000 }, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
paths: { "/a": { post: { responses: {} } } },
});
expect(merged.paths["/a"]?.get).toBeDefined();
expect(merged.paths["/a"]?.post).toBeDefined();
});
it("colliding methods — last write wins", { timeout: 5000 }, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
paths: {
"/a": {
get: { responses: { "200": { description: "B" } } },
},
},
});
const responses = merged.paths["/a"]?.get?.responses as Record<
string,
{ description: string }
>;
expect(responses["200"]?.description).toBe("B");
});
it("dedups tags by name", { timeout: 5000 }, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
tags: [{ name: "alpha" }, { name: "beta" }],
});
expect(merged.tags?.map((t) => t.name).sort()).toEqual([
"alpha",
"beta",
]);
});
it("spread-merges components", { timeout: 5000 }, () => {
const base = {
...baseDoc(),
components: { schemas: { User: { type: "object" } } },
};
const merged = mergeOpenAPIDocuments(base, {
components: {
schemas: { Project: { type: "object" } },
securitySchemes: { Bearer: { type: "http" } },
parameters: { idParam: {} },
responses: { default: {} },
},
});
expect(merged.components?.schemas).toMatchObject({
User: { type: "object" },
Project: { type: "object" },
});
expect(merged.components?.securitySchemes).toMatchObject({
Bearer: { type: "http" },
});
expect(merged.components?.parameters?.["idParam"]).toBeDefined();
expect(merged.components?.responses?.["default"]).toBeDefined();
});
it("concats servers from add-on docs", { timeout: 5000 }, () => {
const base = { ...baseDoc(), servers: [{ url: "https://a" }] };
const merged = mergeOpenAPIDocuments(base, {
servers: [{ url: "https://b" }],
});
expect(merged.servers?.map((s) => s.url).sort()).toEqual([
"https://a",
"https://b",
]);
});
it("works without components on base", { timeout: 5000 }, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
components: { schemas: { X: {} } },
});
expect(merged.components?.schemas?.["X"]).toBeDefined();
});
it("doc.components with only securitySchemes (no other sub-keys)", {
timeout: 5000,
}, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
components: { securitySchemes: { Bearer: { type: "http" } } },
});
expect(merged.components?.securitySchemes).toMatchObject({
Bearer: { type: "http" },
});
expect(merged.components?.schemas).toEqual({});
});
it("doc.servers merges into a base without servers", {
timeout: 5000,
}, () => {
const merged = mergeOpenAPIDocuments(baseDoc(), {
servers: [{ url: "https://only" }],
});
expect(merged.servers?.map((s) => s.url)).toEqual(["https://only"]);
});
it("merging with no docs returns a clone with identical paths/info/tags", {
timeout: 5000,
}, () => {
const b = baseDoc();
const m = mergeOpenAPIDocuments(b);
expect(m).not.toBe(b);
expect(m.paths).toEqual(b.paths);
expect(m.info).toEqual(b.info);
expect(m.tags).toEqual(b.tags);
});
});
describe("prefixPaths", () => {
it("prefix collapses double slashes", { timeout: 5000 }, () => {
const out = prefixPaths(baseDoc(), "/api/");
expect(Object.keys(out.paths)).toEqual(["/api/a"]);
});
it("prefix without trailing slash", { timeout: 5000 }, () => {
const out = prefixPaths(baseDoc(), "/v1");
expect(Object.keys(out.paths)).toEqual(["/v1/a"]);
});
});
describe("filterPathsByTag", () => {
it("keeps tag-intersecting ops", { timeout: 5000 }, () => {
const doc: OpenAPIDocument = {
openapi: "3.1.0",
info: { title: "T", version: "1.0.0" },
paths: {
"/a": {
get: { responses: {}, tags: ["alpha"] },
post: { responses: {}, tags: ["beta"] },
},
"/b": { get: { responses: {}, tags: ["beta"] } },
},
tags: [{ name: "alpha" }, { name: "beta" }],
};
const out = filterPathsByTag(doc, ["alpha"]);
expect(out.paths["/a"]?.get).toBeDefined();
expect(out.paths["/a"]?.post).toBeUndefined();
expect(out.paths["/b"]).toBeUndefined();
expect(out.tags?.map((t) => t.name)).toEqual(["alpha"]);
});
it("drops the tags array gracefully when absent", {
timeout: 5000,
}, () => {
const doc: OpenAPIDocument = {
openapi: "3.1.0",
info: { title: "T", version: "1.0.0" },
paths: { "/a": { get: { responses: {}, tags: ["x"] } } },
};
const out = filterPathsByTag(doc, ["x"]);
expect(out.paths["/a"]).toBeDefined();
expect(out.tags).toBeUndefined();
});
});
describe("removeInternalRoutes", () => {
it("drops x-internal: true", { timeout: 5000 }, () => {
const doc: OpenAPIDocument = {
openapi: "3.1.0",
info: { title: "T", version: "1.0.0" },
paths: {
"/pub": { get: { responses: {} } },
"/int": {
get: {
responses: {},
["x-internal" as `x-${string}`]: true,
},
},
},
};
const out = removeInternalRoutes(doc);
expect(out.paths["/pub"]).toBeDefined();
expect(out.paths["/int"]).toBeUndefined();
});
it("keeps non-internal methods even when sibling is internal", {
timeout: 5000,
}, () => {
const doc: OpenAPIDocument = {
openapi: "3.1.0",
info: { title: "T", version: "1.0.0" },
paths: {
"/x": {
get: { responses: {} },
post: {
responses: {},
["x-internal" as `x-${string}`]: true,
},
},
},
};
const out = removeInternalRoutes(doc);
expect(out.paths["/x"]?.get).toBeDefined();
expect(out.paths["/x"]?.post).toBeUndefined();
});
});
describe("extractOpenAPIDoc", () => {
it("extracts a doc from a Hono app", { timeout: 5000 }, () => {
const app = new OpenAPIHono();
const doc = extractOpenAPIDoc(app, { title: "X", version: "1.0.0" });
expect(doc.openapi).toBe("3.1.0");
expect(doc.info.title).toBe("X");
});
});

View File

@@ -0,0 +1,189 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import {
HTTP_METHODS,
type OpenAPIDocument,
type Operation,
type PathItem,
} from "../types/openapi.js";
/**
* Optional merge controls accepted by {@link mergeOpenAPIDocuments}.
*/
export interface MergeOptions {
/** Prefix prepended to every path being merged in. */
pathPrefix?: string;
/** Last-write-wins behaviour on path-method collisions. */
overridePaths?: boolean;
}
/**
* Merge `base` with one or more partial documents.
*
* - Paths merge per-path-item — colliding paths concatenate their
* method maps (collisions on the same method keep the latest entry).
* - Components spread-merge across schemas / securitySchemes /
* parameters / responses.
* - Tags deduplicate by name (first occurrence wins).
* - Servers carry through the base; partial servers concatenate.
*/
export function mergeOpenAPIDocuments(
base: OpenAPIDocument,
...docs: Array<Partial<OpenAPIDocument>>
): OpenAPIDocument {
const baseComponents = base.components ?? {};
const merged: OpenAPIDocument = {
...base,
paths: { ...base.paths },
components: {
schemas: { ...baseComponents.schemas },
securitySchemes: { ...baseComponents.securitySchemes },
parameters: { ...baseComponents.parameters },
responses: { ...baseComponents.responses },
},
tags: base.tags ? [...base.tags] : [],
...(base.servers ? { servers: [...base.servers] } : {}),
};
for (const doc of docs) {
if (doc.paths) {
for (const [path, pathItem] of Object.entries(doc.paths)) {
const existing = merged.paths[path];
merged.paths[path] = existing
? { ...existing, ...pathItem }
: { ...pathItem };
}
}
if (doc.components) {
const dst = merged.components as NonNullable<
OpenAPIDocument["components"]
>;
if (doc.components.schemas) {
dst.schemas = { ...dst.schemas, ...doc.components.schemas };
}
if (doc.components.securitySchemes) {
dst.securitySchemes = {
...dst.securitySchemes,
...doc.components.securitySchemes,
};
}
if (doc.components.parameters) {
dst.parameters = {
...dst.parameters,
...doc.components.parameters,
};
}
if (doc.components.responses) {
dst.responses = {
...dst.responses,
...doc.components.responses,
};
}
}
if (doc.tags) {
const tags = (merged.tags ??= []);
const seen = new Set(tags.map((t) => t.name));
for (const tag of doc.tags) {
if (!seen.has(tag.name)) {
tags.push(tag);
seen.add(tag.name);
}
}
}
if (doc.servers) {
merged.servers = [...(merged.servers ?? []), ...doc.servers];
}
}
return merged;
}
/**
* Prepend `prefix` to every path in `doc`, collapsing duplicate
* slashes. The original document is returned unchanged.
*/
export function prefixPaths(
doc: OpenAPIDocument,
prefix: string
): OpenAPIDocument {
const prefixed: Record<string, PathItem> = {};
for (const [path, pathItem] of Object.entries(doc.paths)) {
const next = `${prefix}${path}`.replace(/\/+/g, "/");
prefixed[next] = pathItem;
}
return { ...doc, paths: prefixed };
}
/**
* Extract a registry-shaped OpenAPI document from a Hono `OpenAPIHono`
* instance. Useful for tests that want to compare what Hono would emit
* against what the registry emits.
*/
export function extractOpenAPIDoc(
app: OpenAPIHono,
info: { title: string; version: string }
): OpenAPIDocument {
return app.getOpenAPI31Document({
openapi: "3.1.0",
info,
}) as OpenAPIDocument;
}
/**
* Keep only operations whose `tags` intersect `tags`. Paths whose every
* operation is filtered out are dropped. The document-level `tags`
* array is filtered to the same set.
*/
export function filterPathsByTag(
doc: OpenAPIDocument,
tags: string[]
): OpenAPIDocument {
const tagSet = new Set(tags);
const filtered: Record<string, PathItem> = {};
for (const [path, pathItem] of Object.entries(doc.paths)) {
const kept: PathItem = {};
for (const method of HTTP_METHODS) {
const op = pathItem[method];
if (op?.tags?.some((t) => tagSet.has(t))) {
kept[method] = op;
}
}
if (Object.keys(kept).length > 0) {
filtered[path] = kept;
}
}
return {
...doc,
paths: filtered,
...(doc.tags
? { tags: doc.tags.filter((t) => tagSet.has(t.name)) }
: {}),
};
}
/**
* Drop every operation marked `x-internal: true`. Paths whose entire
* method map is removed are dropped from the result.
*/
export function removeInternalRoutes(doc: OpenAPIDocument): OpenAPIDocument {
const out: Record<string, PathItem> = {};
for (const [path, pathItem] of Object.entries(doc.paths)) {
const kept: PathItem = {};
for (const method of HTTP_METHODS) {
const op = pathItem[method] as
| (Operation & { "x-internal"?: boolean })
| undefined;
if (op && op["x-internal"] !== true) {
kept[method] = op;
}
}
if (Object.keys(kept).length > 0) {
out[path] = kept;
}
}
return { ...doc, paths: out };
}

View File

@@ -0,0 +1,10 @@
export {
assignOperationIds,
componentPrefix,
type NamespacedRoute,
type NamespacedRouteInput,
type NamespacePluginInfo,
normaliseRoutePath,
operationIdFor,
tagFor,
} from "./namespacing.js";

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,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import type { RegisteredRoute } from "../types/openapi.js";
import {
assignOperationIds,
componentPrefix,
normaliseRoutePath,
operationIdFor,
tagFor,
} from "./namespacing.js";
const billing = { name: "billing", displayName: "Billing" } as const;
const users = { name: "users" } as const;
function route(
method: string,
path: string,
extra: Partial<RegisteredRoute> = {}
): RegisteredRoute {
return { method, path, tags: [], ...extra };
}
describe("namespacing — tagFor / componentPrefix / normaliseRoutePath", () => {
it("tagFor prefers displayName", { timeout: 5000 }, () => {
expect(tagFor(billing)).toBe("Billing");
});
it("tagFor falls back to name when displayName absent", {
timeout: 5000,
}, () => {
expect(tagFor(users)).toBe("users");
});
it("componentPrefix returns plugin name", { timeout: 5000 }, () => {
expect(componentPrefix(billing)).toBe("billing");
});
it("normaliseRoutePath rewrites :params to {params}", {
timeout: 5000,
}, () => {
expect(normaliseRoutePath("/invoices/:id")).toBe("/invoices/{id}");
expect(normaliseRoutePath("/a/:b/c/:d")).toBe("/a/{b}/c/{d}");
});
it("normaliseRoutePath is idempotent on already-OAS paths", {
timeout: 5000,
}, () => {
expect(normaliseRoutePath("/invoices/{id}")).toBe("/invoices/{id}");
});
it("normaliseRoutePath does not rewrite non-identifier colons", {
timeout: 5000,
}, () => {
// Port-like segments use a digit immediately after the colon — must
// not be confused with a path parameter.
expect(normaliseRoutePath("/host:443/path")).toBe("/host:443/path");
});
});
describe("namespacing — operationIdFor", () => {
it("operationId is deterministic", { timeout: 5000 }, () => {
// From spec scenario: GET /invoices/:id → billing.get./invoices/{id}
expect(operationIdFor(billing, "GET", "/invoices/:id")).toBe(
"billing.get./invoices/{id}"
);
});
it("operationId lower-cases method", { timeout: 5000 }, () => {
expect(operationIdFor(billing, "POST", "/invoices")).toBe(
"billing.post./invoices"
);
});
});
describe("namespacing — assignOperationIds", () => {
it("assigns derived ids when route has none", { timeout: 5000 }, () => {
const out = assignOperationIds([
{ plugin: billing, route: route("GET", "/invoices/:id") },
]);
expect(out).toHaveLength(1);
expect(out[0]?.operationId).toBe("billing.get./invoices/{id}");
expect(out[0]?.tag).toBe("Billing");
expect(out[0]?.method).toBe("get");
expect(out[0]?.path).toBe("/invoices/{id}");
expect(out[0]?.pluginName).toBe("billing");
});
it("respects pre-set operationId", { timeout: 5000 }, () => {
const out = assignOperationIds([
{
plugin: billing,
route: route("GET", "/x", { operationId: "custom" }),
},
]);
expect(out[0]?.operationId).toBe("custom");
});
it("collision raises OPENAPI_OPERATION_ID_COLLISION", {
timeout: 5000,
}, () => {
// Two plugins emit the same operationId via custom override.
let err: unknown;
try {
assignOperationIds([
{
plugin: billing,
route: route("GET", "/a", { operationId: "shared" }),
},
{
plugin: users,
route: route("GET", "/b", { operationId: "shared" }),
},
]);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(OpenAPIError);
expect((err as OpenAPIError).code).toBe(
OPENAPI_CODES.OPERATION_ID_COLLISION
);
const ctx = (err as OpenAPIError).context as
| { operationId?: string }
| undefined;
expect(ctx?.operationId).toBe("shared");
});
it("does not collide across distinct routes", { timeout: 5000 }, () => {
const out = assignOperationIds([
{ plugin: billing, route: route("GET", "/invoices") },
{ plugin: billing, route: route("POST", "/invoices") },
{ plugin: users, route: route("GET", "/users/:id") },
]);
expect(out).toHaveLength(3);
expect(new Set(out.map((r) => r.operationId)).size).toBe(3);
});
});

View File

@@ -0,0 +1,135 @@
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import type { HttpMethod, RegisteredRoute } from "../types/openapi.js";
/**
* Lightweight view of a plugin manifest used by the namespacing layer.
* Drawn from `@suaveplan/api-types#PluginManifest` — we accept only the
* fields needed to derive tags, operationIds, and component prefixes so
* the namespacing helpers stay decoupled from full-manifest evolution.
*/
export interface NamespacePluginInfo {
readonly name: string;
readonly displayName?: string;
}
/**
* Derive the OpenAPI tag for a plugin. Falls back to `name` when
* `displayName` is omitted. Tags are deduplicated by the emitter at
* the document level.
*/
export function tagFor(plugin: NamespacePluginInfo): string {
return plugin.displayName ?? plugin.name;
}
/**
* Derive the component-schema prefix for a plugin.
*
* `<pluginName>.<TypeName>` — e.g. `billing.Invoice`. Plugin names are
* accepted verbatim; callers MUST already validate them at manifest
* ingestion time (see `@suaveplan/api-manifest`).
*/
export function componentPrefix(plugin: NamespacePluginInfo): string {
return plugin.name;
}
/**
* Convert an Express-style route path (`/invoices/:id`) into the OpenAPI
* `{param}` form (`/invoices/{id}`). Idempotent on already-OAS paths.
*/
export function normaliseRoutePath(path: string): string {
return path.replace(/:([A-Za-z_][\w-]*)/g, "{$1}");
}
/**
* Build the deterministic operationId for a route.
*
* `<pluginName>.<method>.<oasPath>` — e.g. `billing.get./invoices/{id}`.
* Lower-cased method. Path normalised through {@link normaliseRoutePath}.
*/
export function operationIdFor(
plugin: NamespacePluginInfo,
method: HttpMethod | string,
path: string
): string {
return `${plugin.name}.${method.toLowerCase()}.${normaliseRoutePath(path)}`;
}
/**
* Per-route entry used by {@link assignOperationIds}. Carries the owning
* plugin so the emitter can attach the right tag + prefix.
*/
export interface NamespacedRouteInput {
readonly plugin: NamespacePluginInfo;
readonly route: RegisteredRoute;
}
/**
* Output of {@link assignOperationIds} — every input route enriched
* with the canonical operationId, tag, and OAS-normalised path.
*/
export interface NamespacedRoute {
readonly pluginName: string;
readonly tag: string;
readonly operationId: string;
readonly method: string;
readonly path: string;
readonly route: RegisteredRoute;
}
/**
* Walk every route and attach the canonical operationId.
*
* Pre-existing `operationId` on a route is respected unless it collides
* with another route's derived id. When two routes resolve to the same
* operationId, {@link OpenAPIError} with {@link OPENAPI_CODES.OPERATION_ID_COLLISION}
* is raised — the host MUST refuse to install the colliding plugin
* BEFORE traffic reaches it.
*/
export function assignOperationIds(
inputs: readonly NamespacedRouteInput[]
): NamespacedRoute[] {
const seen = new Map<string, NamespacedRoute>();
const result: NamespacedRoute[] = [];
for (const input of inputs) {
const { plugin, route } = input;
const derived = operationIdFor(plugin, route.method, route.path);
const operationId = route.operationId ?? derived;
const oasPath = normaliseRoutePath(route.path);
const tag = tagFor(plugin);
const entry: NamespacedRoute = {
pluginName: plugin.name,
tag,
operationId,
method: route.method.toLowerCase(),
path: oasPath,
route,
};
const prior = seen.get(operationId);
if (prior !== undefined) {
throw new OpenAPIError(
OPENAPI_CODES.OPERATION_ID_COLLISION,
`operationId "${operationId}" collides between plugins "${prior.pluginName}" and "${plugin.name}"`,
{
context: {
operationId,
first: {
plugin: prior.pluginName,
method: prior.method,
path: prior.path,
},
second: {
plugin: plugin.name,
method: entry.method,
path: entry.path,
},
},
}
);
}
seen.set(operationId, entry);
result.push(entry);
}
return result;
}

View File

@@ -0,0 +1,6 @@
export type {
DefinePluginRoutesOptions,
PluginRouteEntry,
RouteHandler,
} from "./plugin-integration.js";
export { definePluginRoutes } from "./plugin-integration.js";

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,228 @@
import { OpenAPIHono, z } from "@hono/zod-openapi";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import { createOpenAPIRegistry } from "../registry/registry.js";
import { GET, POST, successResponse } from "../route-factory/route-factory.js";
import { definePluginRoutes } from "./plugin-integration.js";
describe("definePluginRoutes", () => {
it("registers in Hono and registry together", { timeout: 5000 }, () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
const list = GET("/users")
.tags("users")
.operationId("listUsers")
.response(successResponse(z.array(z.object({ id: z.string() }))))
.build();
const recorded = definePluginRoutes(
[
{
route: list,
handler: (c) =>
c.json({ success: true as const, data: [] }, 200),
},
],
{ app, registry, pluginName: "users" }
);
expect(recorded).toHaveLength(1);
expect(
app.routes.some((r) => r.path === "/users" && r.method === "GET")
).toBe(true);
const fromRegistry = registry.getRoutesByPlugin("users");
expect(fromRegistry).toHaveLength(1);
expect(fromRegistry[0]?.operationId).toBe("listUsers");
});
it("carries summary/description/deprecated/security into the registry", {
timeout: 5000,
}, () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
const r = POST("/things")
.tags("things")
.summary("Create thing")
.description("Creates a thing")
.deprecated()
.security([{ Bearer: ["admin"] }])
.body(z.object({ name: z.string() }))
.response(successResponse(z.object({ id: z.string() })))
.build();
definePluginRoutes(
[
{
route: r,
handler: (c) =>
c.json(
{
success: true as const,
data: { id: "1" },
},
200
),
},
],
{ app, registry, pluginName: "things" }
);
const [rec] = registry.getRoutesByPlugin("things");
expect(rec?.summary).toBe("Create thing");
expect(rec?.description).toBe("Creates a thing");
expect(rec?.deprecated).toBe(true);
expect(rec?.security).toEqual([{ Bearer: ["admin"] }]);
});
it("defensive-copies tags array", { timeout: 5000 }, () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
const r = GET("/x").tags("a", "b").response(z.object({})).build();
const [recorded] = definePluginRoutes(
[{ route: r, handler: (c) => c.json({}) }],
{ app, registry, pluginName: "p" }
);
// Mutating the recorded array must not change the route config.
(recorded?.tags as string[]).push("mutated");
expect(r.tags).toEqual(["a", "b"]);
});
it("returns the list of registered records", { timeout: 5000 }, () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
const r1 = GET("/a").response(z.object({})).build();
const r2 = GET("/b").response(z.object({})).build();
const out = definePluginRoutes(
[
{ route: r1, handler: (c) => c.json({}) },
{ route: r2, handler: (c) => c.json({}) },
],
{ app, registry, pluginName: "multi" }
);
expect(out.map((x) => x.path).sort()).toEqual(["/a", "/b"]);
});
});
describe("definePluginRoutes input validation", () => {
const makeOk = () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
const route = GET("/x").response(z.object({})).build();
return { app, registry, route };
};
const expectInvalidInput = (fn: () => unknown): void => {
let err: unknown;
try {
fn();
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(OpenAPIError);
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.INVALID_INPUT);
};
it("throws INVALID_INPUT when options is null", { timeout: 5000 }, () => {
expectInvalidInput(() => definePluginRoutes([], null as never));
});
it("throws INVALID_INPUT when options.app is missing", {
timeout: 5000,
}, () => {
const { registry } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([], { registry, pluginName: "p" } as never)
);
});
it("throws INVALID_INPUT when options.registry is missing", {
timeout: 5000,
}, () => {
const { app } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([], { app, pluginName: "p" } as never)
);
});
it("throws INVALID_INPUT when pluginName is empty string", {
timeout: 5000,
}, () => {
const { app, registry } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([], { app, registry, pluginName: "" })
);
});
it("throws INVALID_INPUT when pluginName is not a string", {
timeout: 5000,
}, () => {
const { app, registry } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([], { app, registry, pluginName: 7 as never })
);
});
it("throws INVALID_INPUT when entries is not an array", {
timeout: 5000,
}, () => {
const { app, registry } = makeOk();
expectInvalidInput(() =>
definePluginRoutes("not-an-array" as never, {
app,
registry,
pluginName: "p",
})
);
});
it("throws INVALID_INPUT when an entry is null", {
timeout: 5000,
}, () => {
const { app, registry } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([null as never], {
app,
registry,
pluginName: "p",
})
);
});
it("throws INVALID_INPUT when entry.route is missing", {
timeout: 5000,
}, () => {
const { app, registry } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([{ handler: () => undefined } as never], {
app,
registry,
pluginName: "p",
})
);
});
it("throws INVALID_INPUT when entry.handler is not a function", {
timeout: 5000,
}, () => {
const { app, registry, route } = makeOk();
expectInvalidInput(() =>
definePluginRoutes([{ route, handler: "not-a-fn" as never }], {
app,
registry,
pluginName: "p",
})
);
});
});

View File

@@ -0,0 +1,132 @@
import type { OpenAPIHono, RouteConfig } from "@hono/zod-openapi";
import type { Context } from "hono";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import type { OpenAPIRegistry } from "../registry/registry.js";
import type { RegisteredRoute } from "../types/openapi.js";
/**
* Handler signature accepted by {@link definePluginRoutes}. Loose typing
* (Hono-typed `Context`) keeps the helper usable by every plugin without
* forcing each one to surface its `c.req.valid()` inference up to the
* caller — that inference happens at the call site of `app.openapi`.
*/
export type RouteHandler = (
c: Context
) => Response | Promise<Response> | unknown;
/**
* Entry passed to {@link definePluginRoutes} — a built route plus the
* handler that backs it.
*/
export interface PluginRouteEntry {
readonly route: RouteConfig;
readonly handler: RouteHandler;
}
/**
* Options accepted by {@link definePluginRoutes}.
*/
export interface DefinePluginRoutesOptions {
readonly app: OpenAPIHono;
readonly registry: OpenAPIRegistry;
readonly pluginName: string;
}
/**
* Register a batch of plugin routes against BOTH the Hono app and the
* OpenAPI registry. The two surfaces cannot drift — every successful
* registration touches both. Returns the list of {@link RegisteredRoute}
* entries added so the caller can inspect or further annotate them.
*/
export function definePluginRoutes(
entries: PluginRouteEntry[],
options: DefinePluginRoutesOptions
): RegisteredRoute[] {
if (!options || typeof options !== "object") {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes options is required"
);
}
const { app, registry, pluginName } = options;
if (!app) {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: options.app is required"
);
}
if (!registry) {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: options.registry is required"
);
}
if (typeof pluginName !== "string" || pluginName.length === 0) {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: options.pluginName must be a non-empty string"
);
}
if (!Array.isArray(entries)) {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: entries must be an array"
);
}
const registered: RegisteredRoute[] = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: every entry must be { route, handler }"
);
}
const { route, handler } = entry;
if (!route || typeof route !== "object") {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: entry.route is required"
);
}
if (typeof handler !== "function") {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_INPUT,
"definePluginRoutes: entry.handler must be a function"
);
}
// Cast: handler signature is intentionally generic. Hono's
// `app.openapi` infers via the RouteConfig at the call site —
// we accept the loose signature here.
app.openapi(
route as Parameters<OpenAPIHono["openapi"]>[0],
handler as Parameters<OpenAPIHono["openapi"]>[1]
);
const record: RegisteredRoute = {
method: route.method,
path: route.path,
tags: route.tags ? [...route.tags] : [],
pluginName,
};
if (route.operationId !== undefined) {
record.operationId = route.operationId;
}
if (route.summary !== undefined) {
record.summary = route.summary;
}
if (route.description !== undefined) {
record.description = route.description;
}
if (route.deprecated === true) {
record.deprecated = true;
}
if (route.security !== undefined) {
record.security = route.security as Array<Record<string, string[]>>;
}
registry.registerRoute(record);
registered.push(record);
}
return registered;
}

View File

@@ -0,0 +1,8 @@
export type { OpenAPIRegistryOptions } from "./registry.js";
export {
ApiKeyScheme,
BearerAuthScheme,
createOpenAPIRegistry,
DEFAULT_SPEC_PATH,
OpenAPIRegistry,
} from "./registry.js";

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,252 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ApiKeyScheme,
BearerAuthScheme,
createOpenAPIRegistry,
DEFAULT_SPEC_PATH,
OpenAPIRegistry,
} from "./registry.js";
describe("OpenAPIRegistry", () => {
it("constructs with info only", { timeout: 5000 }, () => {
const r = new OpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
const doc = r.buildDocument();
expect(doc.openapi).toBe("3.1.0");
expect(doc.info.title).toBe("T");
expect(doc.paths).toEqual({});
expect(doc.servers).toBeUndefined();
});
it("registers and lists servers", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
servers: [{ url: "https://a.example" }],
});
r.addServer({ url: "https://b.example" });
const doc = r.buildDocument();
expect(doc.servers).toEqual([
{ url: "https://a.example" },
{ url: "https://b.example" },
]);
});
it("registered routes appear in paths", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({
method: "get",
path: "/items",
tags: ["items"],
operationId: "listItems",
summary: "List",
description: "List items",
});
const doc = r.buildDocument();
expect(doc.paths["/items"]?.get?.operationId).toBe("listItems");
expect(doc.paths["/items"]?.get?.summary).toBe("List");
expect(doc.paths["/items"]?.get?.description).toBe("List items");
expect(doc.paths["/items"]?.get?.tags).toEqual(["items"]);
});
it("merges multiple methods on the same path", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({ method: "get", path: "/x", tags: [] });
r.registerRoute({ method: "post", path: "/x", tags: [] });
const item = r.buildDocument().paths["/x"];
expect(item?.get).toBeDefined();
expect(item?.post).toBeDefined();
});
it("propagates deprecated + security on operations", {
timeout: 5000,
}, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({
method: "delete",
path: "/x",
tags: [],
deprecated: true,
security: [{ Bearer: ["admin"] }],
});
const op = r.buildDocument().paths["/x"]?.delete;
expect(op?.deprecated).toBe(true);
expect(op?.security).toEqual([{ Bearer: ["admin"] }]);
});
it("auto-registers tags from routes", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({
method: "get",
path: "/a",
tags: ["alpha", "beta"],
});
const doc = r.buildDocument();
expect(doc.tags?.map((t) => t.name).sort()).toEqual(["alpha", "beta"]);
});
it("preserves explicit tag descriptions", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerTag("explicit", "an explicit tag");
r.registerTag("nodesc");
const tags = r.buildDocument().tags ?? [];
expect(tags.find((t) => t.name === "explicit")?.description).toBe(
"an explicit tag"
);
expect(
tags.find((t) => t.name === "nodesc")?.description
).toBeUndefined();
});
it("emits security schemes in components", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
security: [BearerAuthScheme, ApiKeyScheme],
globalSecurity: [{ Bearer: [] }],
});
const doc = r.buildDocument();
expect(
(doc.components?.securitySchemes as Record<string, unknown>)?.[
"Bearer"
]
).toMatchObject({
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
expect(
(doc.components?.securitySchemes as Record<string, unknown>)?.[
"ApiKey"
]
).toMatchObject({ type: "apiKey", in: "header" });
expect(doc.security).toEqual([{ Bearer: [] }]);
});
it("registerSecurityScheme adds after construction", {
timeout: 5000,
}, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerSecurityScheme({
name: "Custom",
type: "apiKey",
in: "query",
});
const schemes = r.buildDocument().components?.securitySchemes as Record<
string,
unknown
>;
expect(schemes["Custom"]).toMatchObject({
type: "apiKey",
in: "query",
});
});
it("getRoutesByPlugin + getRoutesByTag filter correctly", {
timeout: 5000,
}, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({
method: "get",
path: "/u",
tags: ["users"],
pluginName: "users",
});
r.registerRoute({
method: "get",
path: "/p",
tags: ["projects"],
pluginName: "projects",
});
expect(r.getRoutesByPlugin("users").map((x) => x.path)).toEqual(["/u"]);
expect(r.getRoutesByTag("projects").map((x) => x.path)).toEqual(["/p"]);
expect(r.getRoutes()).toHaveLength(2);
});
it("updateInfo merges patches", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "Old", version: "0.0.1" },
});
r.updateInfo({ title: "New", description: "d" });
const info = r.buildDocument().info;
expect(info.title).toBe("New");
expect(info.version).toBe("0.0.1");
expect(info.description).toBe("d");
});
it("clearRoutes empties routes", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({ method: "get", path: "/x", tags: [] });
expect(r.getRoutes()).toHaveLength(1);
r.clearRoutes();
expect(r.getRoutes()).toHaveLength(0);
});
it("getRoutes returns a defensive copy", { timeout: 5000 }, () => {
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.registerRoute({ method: "get", path: "/x", tags: [] });
const list = r.getRoutes();
list.pop();
expect(r.getRoutes()).toHaveLength(1);
});
it("applyTo serves buildDocument output", { timeout: 5000 }, async () => {
const app = new OpenAPIHono();
const r = createOpenAPIRegistry({
info: { title: "Live", version: "1.2.3" },
});
r.registerRoute({
method: "get",
path: "/items",
tags: ["items"],
operationId: "listItems",
});
r.applyTo(app);
const res = await app.request(DEFAULT_SPEC_PATH);
expect(res.status).toBe(200);
const body = (await res.json()) as { paths: Record<string, unknown> };
expect(body).toEqual(r.buildDocument());
expect(body.paths["/items"]).toBeDefined();
});
it("applyTo accepts a custom spec path", { timeout: 5000 }, async () => {
const app = new OpenAPIHono();
const r = createOpenAPIRegistry({
info: { title: "T", version: "1.0.0" },
});
r.applyTo(app, "/spec.json");
const res = await app.request("/spec.json");
expect(res.status).toBe(200);
});
});
describe("BearerAuthScheme + ApiKeyScheme presets", () => {
it("Bearer is http+bearer with JWT format", { timeout: 5000 }, () => {
expect(BearerAuthScheme.type).toBe("http");
expect(BearerAuthScheme.scheme).toBe("bearer");
expect(BearerAuthScheme.bearerFormat).toBe("JWT");
});
it("ApiKey is header-based apiKey", { timeout: 5000 }, () => {
expect(ApiKeyScheme.type).toBe("apiKey");
expect(ApiKeyScheme.in).toBe("header");
});
});

View File

@@ -0,0 +1,222 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import type {
OpenAPIDocument,
OpenAPIInfo,
OpenAPIServer,
Operation,
PathItem,
RegisteredRoute,
SecurityScheme,
} from "../types/openapi.js";
/**
* Options accepted by {@link OpenAPIRegistry}.
*/
export interface OpenAPIRegistryOptions {
readonly info: OpenAPIInfo;
readonly servers?: OpenAPIServer[];
readonly security?: SecurityScheme[];
readonly globalSecurity?: Array<Record<string, string[]>>;
}
/**
* Default URL path served by {@link OpenAPIRegistry.applyTo} when
* connecting the registry to a Hono `OpenAPIHono` app.
*/
export const DEFAULT_SPEC_PATH = "/openapi.json";
/**
* OpenAPI 3.1 registry — single source of truth across the package.
*
* Tracks document `info`, `servers`, `securitySchemes`, `tags`, and
* `routes` registered by plugins. `buildDocument()` emits the full
* `OpenAPIDocument`. `applyTo(app)` wires a Hono handler so the served
* spec exactly matches `buildDocument()` output — eliminating drift
* between the registry's introspection state and the served spec.
*/
export class OpenAPIRegistry {
private info: OpenAPIInfo;
private readonly servers: OpenAPIServer[];
private readonly securitySchemes: Map<string, SecurityScheme> = new Map();
private readonly routes: RegisteredRoute[] = [];
private readonly tags: Map<string, { description?: string }> = new Map();
private readonly globalSecurity: Array<Record<string, string[]>>;
public constructor(options: OpenAPIRegistryOptions) {
this.info = options.info;
this.servers = options.servers ? [...options.servers] : [];
this.globalSecurity = options.globalSecurity
? options.globalSecurity.map((entry) => ({ ...entry }))
: [];
if (options.security) {
for (const scheme of options.security) {
this.registerSecurityScheme(scheme);
}
}
}
public registerSecurityScheme(scheme: SecurityScheme): void {
this.securitySchemes.set(scheme.name, scheme);
}
public registerTag(name: string, description?: string): void {
this.tags.set(name, description !== undefined ? { description } : {});
}
public registerRoute(route: RegisteredRoute): void {
this.routes.push(route);
for (const tag of route.tags) {
if (!this.tags.has(tag)) {
this.tags.set(tag, {});
}
}
}
public getRoutes(): RegisteredRoute[] {
return [...this.routes];
}
public getRoutesByPlugin(pluginName: string): RegisteredRoute[] {
return this.routes.filter((r) => r.pluginName === pluginName);
}
public getRoutesByTag(tag: string): RegisteredRoute[] {
return this.routes.filter((r) => r.tags.includes(tag));
}
public updateInfo(info: Partial<OpenAPIInfo>): void {
this.info = { ...this.info, ...info };
}
public addServer(server: OpenAPIServer): void {
this.servers.push(server);
}
public clearRoutes(): void {
this.routes.length = 0;
}
/**
* Build the full OpenAPI 3.1 document — info, servers, paths
* (derived from registered routes), components (schemas remain
* empty here; security schemes carry through), tags, security.
*/
public buildDocument(): OpenAPIDocument {
const paths: Record<string, PathItem> = {};
for (const route of this.routes) {
const method = route.method.toLowerCase() as keyof PathItem;
const op: Operation = { responses: {} };
if (route.operationId !== undefined) {
op.operationId = route.operationId;
}
if (route.summary !== undefined) op.summary = route.summary;
if (route.description !== undefined) {
op.description = route.description;
}
if (route.tags.length > 0) op.tags = [...route.tags];
if (route.deprecated === true) op.deprecated = true;
if (route.security !== undefined) {
op.security = route.security.map((entry) => ({ ...entry }));
}
const existing = paths[route.path] ?? {};
existing[method] = op;
paths[route.path] = existing;
}
const document: OpenAPIDocument = {
openapi: "3.1.0",
info: this.info,
paths,
};
if (this.servers.length > 0) {
document.servers = [...this.servers];
}
const tagEntries = Array.from(this.tags.entries()).map(
([name, config]) =>
config.description !== undefined
? { name, description: config.description }
: { name }
);
if (tagEntries.length > 0) {
document.tags = tagEntries;
}
const securitySchemes = this.serializeSecuritySchemes();
if (Object.keys(securitySchemes).length > 0) {
document.components = { securitySchemes };
}
if (this.globalSecurity.length > 0) {
document.security = this.globalSecurity.map((entry) => ({
...entry,
}));
}
return document;
}
/**
* Wire `app.doc(specPath, …)` so a GET against `specPath` returns
* `buildDocument()` output. The registry remains the single source
* of truth — Hono only serves what the registry computes.
*/
public applyTo(
app: OpenAPIHono,
specPath: string = DEFAULT_SPEC_PATH
): void {
const build = (): OpenAPIDocument => this.buildDocument();
app.get(specPath, (c) => c.json(build()));
}
private serializeSecuritySchemes(): Record<string, unknown> {
const schemes: Record<string, unknown> = {};
for (const [name, scheme] of this.securitySchemes) {
const out: Record<string, unknown> = { type: scheme.type };
if (scheme.scheme !== undefined) out["scheme"] = scheme.scheme;
if (scheme.bearerFormat !== undefined) {
out["bearerFormat"] = scheme.bearerFormat;
}
if (scheme.in !== undefined) out["in"] = scheme.in;
if (scheme.description !== undefined) {
out["description"] = scheme.description;
}
schemes[name] = out;
}
return schemes;
}
}
/**
* Factory for {@link OpenAPIRegistry}.
*/
export function createOpenAPIRegistry(
options: OpenAPIRegistryOptions
): OpenAPIRegistry {
return new OpenAPIRegistry(options);
}
/**
* Default Bearer (JWT) security scheme.
*/
export const BearerAuthScheme: SecurityScheme = {
name: "Bearer",
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "JWT Bearer token authentication",
};
/**
* Default header-based API key security scheme.
*/
export const ApiKeyScheme: SecurityScheme = {
name: "ApiKey",
type: "apiKey",
in: "header",
description: "API Key authentication",
};

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ErrorResponseSchema } from "./route-factory.js";
describe("ErrorResponseSchema", () => {
it("accepts SuaveplanError.toJSON() output", { timeout: 5000 }, () => {
const payload = {
success: false,
error: {
code: "X.Y",
message: "boom",
timestamp: new Date().toISOString(),
},
};
expect(ErrorResponseSchema.safeParse(payload).success).toBe(true);
});
it("rejects success:true payload", { timeout: 5000 }, () => {
const payload = {
success: true,
error: {
code: "X.Y",
message: "boom",
timestamp: new Date().toISOString(),
},
};
expect(ErrorResponseSchema.safeParse(payload).success).toBe(false);
});
});

View File

@@ -0,0 +1,20 @@
export type {
ErrorResponse,
RouteMethod,
RouteOptions,
TypedRouteConfig,
} from "./route-factory.js";
export {
DELETE,
ErrorResponseSchema,
errorResponse,
GET,
PATCH,
POST,
PUT,
paginatedResponse,
RouteBuilder,
route,
StandardResponses,
successResponse,
} from "./route-factory.js";

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,52 @@
import type { RouteConfig } from "@hono/zod-openapi";
import { OpenAPIHono, z } from "@hono/zod-openapi";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { POST, successResponse } from "./route-factory.js";
describe("RouteBuilder threads types end-to-end", () => {
it("infers c.req.valid('json') to the body Zod type", {
timeout: 5000,
}, () => {
const Body = z.object({ name: z.string() });
const Resp = z.object({ id: z.string() });
const r = POST("/users")
.body(Body)
.response(successResponse(Resp))
.build();
// Type-level assertion: TypedRouteConfig is assignable to RouteConfig.
expectTypeOf(r).toMatchTypeOf<RouteConfig>();
const app = new OpenAPIHono();
app.openapi(r, (c) => {
const body = c.req.valid("json");
// Compile-time: body must be inferred as { name: string }.
expectTypeOf(body).toEqualTypeOf<{ name: string }>();
return c.json({ success: true as const, data: { id: "1" } }, 200);
});
expect(app.routes.length).toBeGreaterThan(0);
});
it("infers c.req.valid('param') to the params Zod type", {
timeout: 5000,
}, () => {
const Params = z.object({ id: z.string() });
const Resp = z.object({ ok: z.boolean() });
const r = POST("/items/:id")
.params(Params)
.response(successResponse(Resp))
.build();
const app = new OpenAPIHono();
app.openapi(r, (c) => {
const params = c.req.valid("param");
expectTypeOf(params).toEqualTypeOf<{ id: string }>();
return c.json({ success: true as const, data: { ok: true } }, 200);
});
expect(app.routes.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,210 @@
import { OpenAPIHono, z } from "@hono/zod-openapi";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DELETE,
ErrorResponseSchema,
errorResponse,
GET,
PATCH,
POST,
PUT,
paginatedResponse,
RouteBuilder,
route,
StandardResponses,
successResponse,
} from "./route-factory.js";
describe("RouteBuilder", () => {
it("builds a minimal GET with response", { timeout: 5000 }, () => {
const r = GET("/x")
.response(z.object({ ok: z.boolean() }))
.build();
expect(r.method).toBe("get");
expect(r.path).toBe("/x");
expect(r.responses).toBeDefined();
});
it("threads params/query/body through .build()", { timeout: 5000 }, () => {
const Params = z.object({ id: z.string() });
const Query = z.object({ q: z.string() });
const Body = z.object({ v: z.number() });
const r = POST("/y")
.params(Params)
.query(Query)
.body(Body)
.response(z.object({ ok: z.boolean() }))
.build();
expect(r.request).toBeDefined();
});
it("propagates tags / summary / description / operationId", {
timeout: 5000,
}, () => {
const r = PUT("/z")
.tags("a", "b")
.summary("S")
.description("D")
.operationId("op")
.response(z.object({}))
.build();
expect(r.tags).toEqual(["a", "b"]);
expect(r.summary).toBe("S");
expect(r.description).toBe("D");
expect(r.operationId).toBe("op");
});
it("marks deprecated when called", { timeout: 5000 }, () => {
const r = DELETE("/d").deprecated().response(z.object({})).build();
expect(r.deprecated).toBe(true);
});
it("passes security through", { timeout: 5000 }, () => {
const r = PATCH("/p")
.security([{ Bearer: ["admin"] }])
.response(z.object({}))
.build();
expect(r.security).toEqual([{ Bearer: ["admin"] }]);
});
it("build() without .response() falls back to a permissive schema", {
timeout: 5000,
}, () => {
const r = GET("/no-response").build();
expect(r.responses).toBeDefined();
const ok = (r.responses as Record<string, unknown>)["200"];
expect(ok).toBeDefined();
});
it("route() factory equals direct constructor", { timeout: 5000 }, () => {
const a = route("get", "/eq").response(z.object({})).build();
const b = new RouteBuilder("get", "/eq").response(z.object({})).build();
expect(a.method).toBe(b.method);
expect(a.path).toBe(b.path);
});
it("build produces TypedRouteConfig assignable to RouteConfig", {
timeout: 5000,
}, () => {
const Body = z.object({ name: z.string() });
const Response = z.object({ id: z.string() });
const r = POST("/users")
.body(Body)
.response(successResponse(Response))
.build();
// Real Hono app must accept the route without compile-time complaint.
const app = new OpenAPIHono();
app.openapi(r, (c) =>
c.json({ success: true as const, data: { id: "1" } }, 200)
);
expect(app.routes.some((rt) => rt.path === "/users")).toBe(true);
});
});
describe("ErrorResponseSchema", () => {
it("accepts SuaveplanError.toJSON() output", { timeout: 5000 }, () => {
const json = {
success: false,
error: {
code: "USER.NOT_FOUND",
message: "missing",
timestamp: new Date().toISOString(),
},
};
expect(ErrorResponseSchema.safeParse(json).success).toBe(true);
});
it("accepts cause + details + requestId", { timeout: 5000 }, () => {
const json = {
success: false,
error: {
code: "X.Y",
message: "msg",
details: { field: "foo" },
requestId: "req-1",
timestamp: "2024-01-01T00:00:00.000Z",
cause: { code: "Z.A", message: "inner" },
},
};
expect(ErrorResponseSchema.safeParse(json).success).toBe(true);
});
it("rejects success:true payload", { timeout: 5000 }, () => {
const json = {
success: true,
error: {
code: "X",
message: "m",
timestamp: new Date().toISOString(),
},
};
expect(ErrorResponseSchema.safeParse(json).success).toBe(false);
});
it("requires timestamp", { timeout: 5000 }, () => {
const json = { success: false, error: { code: "X", message: "m" } };
expect(ErrorResponseSchema.safeParse(json).success).toBe(false);
});
});
describe("successResponse / paginatedResponse / errorResponse", () => {
it("successResponse wraps the data schema", { timeout: 5000 }, () => {
const env = successResponse(z.object({ id: z.string() }));
const ok = env.safeParse({ success: true, data: { id: "1" } });
expect(ok.success).toBe(true);
});
it("paginatedResponse adds array data + meta", { timeout: 5000 }, () => {
const env = paginatedResponse(z.object({ id: z.string() }));
const ok = env.safeParse({
success: true,
data: [{ id: "a" }],
meta: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
hasNext: false,
hasPrevious: false,
},
});
expect(ok.success).toBe(true);
});
it("errorResponse returns the standard response triple", {
timeout: 5000,
}, () => {
const e = errorResponse("Custom");
expect(e.description).toBe("Custom");
expect(e.content["application/json"].schema).toBe(ErrorResponseSchema);
});
});
describe("StandardResponses", () => {
it("covers 422/429/503/504", { timeout: 5000 }, () => {
expect(StandardResponses[422]).toBeDefined();
expect(StandardResponses[429]).toBeDefined();
expect(StandardResponses[503]).toBeDefined();
expect(StandardResponses[504]).toBeDefined();
});
it("every entry references ErrorResponseSchema", { timeout: 5000 }, () => {
for (const status of [
400, 401, 403, 404, 409, 422, 429, 500, 503, 504,
] as const) {
expect(
StandardResponses[status].content["application/json"].schema
).toBe(ErrorResponseSchema);
}
});
it("every entry carries a description", { timeout: 5000 }, () => {
for (const status of [
400, 401, 403, 404, 409, 422, 429, 500, 503, 504,
] as const) {
expect(
StandardResponses[status].description.length
).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,335 @@
import type { RouteConfig } from "@hono/zod-openapi";
import { createRoute, z } from "@hono/zod-openapi";
/**
* Discriminated HTTP method literal accepted by {@link route} and the
* shorthand factories (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`).
*/
export type RouteMethod = "get" | "post" | "put" | "patch" | "delete";
/**
* Route definition options accumulated by {@link RouteBuilder} before
* `.build()` produces a {@link TypedRouteConfig}.
*/
export interface RouteOptions<
TParams extends z.ZodType = z.ZodUndefined,
TQuery extends z.ZodType = z.ZodUndefined,
TBody extends z.ZodType = z.ZodUndefined,
TResponse extends z.ZodType = z.ZodUndefined,
> {
method: RouteMethod;
path: string;
tags: string[];
summary?: string;
description?: string;
operationId?: string;
deprecated?: boolean;
security?: Array<Record<string, string[]>>;
params?: TParams;
query?: TQuery;
body?: TBody;
response: TResponse;
}
/**
* Wire shape of every error response — 1:1 match for
* `SuaveplanError.toJSON()`. The `cause` field is included so consumers
* can surface chained errors when present.
*/
export const ErrorResponseSchema = z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
requestId: z.string().optional(),
timestamp: z.string(),
cause: z.unknown().optional(),
}),
});
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
/**
* Wrap an arbitrary data schema in the standard success envelope.
*/
export function successResponse<T extends z.ZodType>(dataSchema: T) {
return z.object({
success: z.literal(true),
data: dataSchema,
});
}
/**
* Build the `{ description, content: { "application/json": { schema } } }`
* triple expected by Hono/OpenAPI for an error response.
*/
export function errorResponse(description: string) {
return {
description,
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
};
}
/**
* Wrap an item schema in a paginated envelope (`data` is an array and
* `meta` carries page/limit/total/totalPages/hasNext/hasPrevious).
*/
export function paginatedResponse<T extends z.ZodType>(itemSchema: T) {
return z.object({
success: z.literal(true),
data: z.array(itemSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
totalPages: z.number(),
hasNext: z.boolean(),
hasPrevious: z.boolean(),
}),
});
}
/**
* Standard error responses covering the full SuaveplanError statusCode
* range. Widened from upstream's 400/401/403/404/500 set to include
* 409, 422, 429, 503, 504 — every status that genesis SuaveplanError
* can emit.
*/
export const StandardResponses = {
400: {
description: "Bad Request",
content: { "application/json": { schema: ErrorResponseSchema } },
},
401: {
description: "Unauthorized",
content: { "application/json": { schema: ErrorResponseSchema } },
},
403: {
description: "Forbidden",
content: { "application/json": { schema: ErrorResponseSchema } },
},
404: {
description: "Not Found",
content: { "application/json": { schema: ErrorResponseSchema } },
},
409: {
description: "Conflict",
content: { "application/json": { schema: ErrorResponseSchema } },
},
422: {
description: "Unprocessable Entity",
content: { "application/json": { schema: ErrorResponseSchema } },
},
429: {
description: "Too Many Requests",
content: { "application/json": { schema: ErrorResponseSchema } },
},
500: {
description: "Internal Server Error",
content: { "application/json": { schema: ErrorResponseSchema } },
},
503: {
description: "Service Unavailable",
content: { "application/json": { schema: ErrorResponseSchema } },
},
504: {
description: "Gateway Timeout",
content: { "application/json": { schema: ErrorResponseSchema } },
},
} as const;
/**
* Typed route config that preserves schema generics through the builder
* chain. Assignable to `@hono/zod-openapi`'s `RouteConfig` so
* `app.openapi(route, handler)` infers `c.req.valid("…")` types.
*
* Uses `Omit + intersection` to ensure `request` is non-optional —
* required for Hono's `InputType*` conditional-type matching.
*/
export type TypedRouteConfig<
TParams extends z.ZodType = z.ZodUndefined,
TQuery extends z.ZodType = z.ZodUndefined,
TBody extends z.ZodType = z.ZodUndefined,
TResponse extends z.ZodType = z.ZodUndefined,
> = Omit<RouteConfig, "request"> & {
request: (TParams extends z.ZodUndefined ? object : { params: TParams }) &
(TQuery extends z.ZodUndefined ? object : { query: TQuery }) &
(TBody extends z.ZodUndefined
? object
: {
body: {
content: { "application/json": { schema: TBody } };
};
});
_resp: TResponse;
};
/**
* Fluent route builder. Each `.params/.query/.body/.response` call
* returns a new generic instantiation so the final `.build()` result
* carries all four schema types.
*/
export class RouteBuilder<
TParams extends z.ZodType = z.ZodUndefined,
TQuery extends z.ZodType = z.ZodUndefined,
TBody extends z.ZodType = z.ZodUndefined,
TResponse extends z.ZodType = z.ZodUndefined,
> {
private readonly state: {
method: RouteMethod;
path: string;
tags?: string[];
summary?: string;
description?: string;
operationId?: string;
deprecated?: boolean;
security?: Array<Record<string, string[]>>;
params?: z.ZodType;
query?: z.ZodType;
body?: z.ZodType;
response?: z.ZodType;
};
public constructor(method: RouteMethod, path: string) {
this.state = { method, path };
}
public tags(...tags: string[]): this {
this.state.tags = tags;
return this;
}
public summary(summary: string): this {
this.state.summary = summary;
return this;
}
public description(description: string): this {
this.state.description = description;
return this;
}
public operationId(id: string): this {
this.state.operationId = id;
return this;
}
public deprecated(): this {
this.state.deprecated = true;
return this;
}
public security(security: Array<Record<string, string[]>>): this {
this.state.security = security;
return this;
}
public params<T extends z.ZodType>(
schema: T
): RouteBuilder<T, TQuery, TBody, TResponse> {
this.state.params = schema;
return this as unknown as RouteBuilder<T, TQuery, TBody, TResponse>;
}
public query<T extends z.ZodType>(
schema: T
): RouteBuilder<TParams, T, TBody, TResponse> {
this.state.query = schema;
return this as unknown as RouteBuilder<TParams, T, TBody, TResponse>;
}
public body<T extends z.ZodType>(
schema: T
): RouteBuilder<TParams, TQuery, T, TResponse> {
this.state.body = schema;
return this as unknown as RouteBuilder<TParams, TQuery, T, TResponse>;
}
public response<T extends z.ZodType>(
schema: T
): RouteBuilder<TParams, TQuery, TBody, T> {
this.state.response = schema;
return this as unknown as RouteBuilder<TParams, TQuery, TBody, T>;
}
public build(): TypedRouteConfig<TParams, TQuery, TBody, TResponse> {
const request: Record<string, unknown> = {};
if (this.state.params) request["params"] = this.state.params;
if (this.state.query) request["query"] = this.state.query;
if (this.state.body) {
request["body"] = {
content: {
"application/json": { schema: this.state.body },
},
};
}
const responseSchema = this.state.response ?? z.unknown();
const config = {
method: this.state.method,
path: this.state.path,
...(this.state.tags !== undefined ? { tags: this.state.tags } : {}),
...(this.state.summary !== undefined
? { summary: this.state.summary }
: {}),
...(this.state.description !== undefined
? { description: this.state.description }
: {}),
...(this.state.operationId !== undefined
? { operationId: this.state.operationId }
: {}),
...(this.state.deprecated !== undefined
? { deprecated: this.state.deprecated }
: {}),
...(this.state.security !== undefined
? { security: this.state.security }
: {}),
...(Object.keys(request).length > 0 ? { request } : {}),
responses: {
200: {
description: "Success",
content: {
"application/json": { schema: responseSchema },
},
},
...StandardResponses,
},
} as Parameters<typeof createRoute>[0];
return createRoute(config) as unknown as TypedRouteConfig<
TParams,
TQuery,
TBody,
TResponse
>;
}
}
/**
* Create a {@link RouteBuilder} for the given method/path.
*/
export function route(method: RouteMethod, path: string): RouteBuilder {
return new RouteBuilder(method, path);
}
/** Shorthand for `route("get", path)`. */
export const GET = (path: string): RouteBuilder =>
new RouteBuilder("get", path);
/** Shorthand for `route("post", path)`. */
export const POST = (path: string): RouteBuilder =>
new RouteBuilder("post", path);
/** Shorthand for `route("put", path)`. */
export const PUT = (path: string): RouteBuilder =>
new RouteBuilder("put", path);
/** Shorthand for `route("patch", path)`. */
export const PATCH = (path: string): RouteBuilder =>
new RouteBuilder("patch", path);
/** Shorthand for `route("delete", path)`. */
export const DELETE = (path: string): RouteBuilder =>
new RouteBuilder("delete", path);

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ErrorResponseSchema, StandardResponses } from "./route-factory.js";
describe("StandardResponses widened set", () => {
it("covers 422/429/503/504", { timeout: 5000 }, () => {
const keys = Object.keys(StandardResponses);
expect(keys).toContain("422");
expect(keys).toContain("429");
expect(keys).toContain("503");
expect(keys).toContain("504");
});
it("every entry references ErrorResponseSchema", { timeout: 5000 }, () => {
for (const [, entry] of Object.entries(StandardResponses)) {
expect(entry.content["application/json"].schema).toBe(
ErrorResponseSchema
);
}
});
});

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,53 @@
import { z } from "@hono/zod-openapi";
/**
* Common reusable Zod schemas decorated with OpenAPI metadata (examples).
*
* These are not registered against any schema registry by default —
* consumers compose them into their own registries as needed. The
* shapes match the most common HTTP query / path / pagination
* primitives so plugins do not need to reinvent the constraints.
*/
export const CommonSchemas = {
/** RFC 4122 UUID string. */
uuid: z
.string()
.uuid()
.openapi({ example: "123e4567-e89b-12d3-a456-426614174000" }),
/** RFC 5322 email string. */
email: z.string().email().openapi({ example: "user@example.com" }),
/** Absolute URL string. */
url: z.string().url().openapi({ example: "https://example.com" }),
/** ISO-8601 datetime string. */
datetime: z
.string()
.datetime()
.openapi({ example: "2024-01-15T10:30:00Z" }),
/** Positive integer (> 0). */
positiveInt: z.number().int().positive(),
/** Non-negative integer (≥ 0). */
nonNegativeInt: z.number().int().nonnegative(),
/** Pagination page number (1-based). Coerces from query strings. */
page: z.coerce.number().int().min(1).default(1).openapi({ example: 1 }),
/** Pagination limit (1–100, default 20). Coerces from query strings. */
limit: z.coerce
.number()
.int()
.min(1)
.max(100)
.default(20)
.openapi({ example: 20 }),
/** Sort order — ascending or descending, default descending. */
sortOrder: z
.enum(["asc", "desc"])
.default("desc")
.openapi({ example: "desc" }),
} as const;

View File

@@ -0,0 +1,6 @@
export { CommonSchemas } from "./common-schemas.js";
export {
createSchemaRegistry,
defineSchema,
SchemaRegistry,
} from "./schema-registry.js";

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,47 @@
import { readFileSync } from "node:fs";
import { z } from "@hono/zod-openapi";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import * as Barrel from "../index.js";
import {
createSchemaRegistry,
defineSchema,
type SchemaRegistry,
} from "./schema-registry.js";
describe("SchemaRegistry — no global singleton", () => {
it("no global singleton exported", { timeout: 5000 }, () => {
// The barrel must not surface a `getSchemaRegistry` accessor or
// any `globalSchemaRegistry` symbol.
const exportedKeys = Object.keys(Barrel);
expect(exportedKeys).not.toContain("getSchemaRegistry");
expect(exportedKeys).not.toContain("globalSchemaRegistry");
});
it("two registries are independent", { timeout: 5000 }, () => {
const a = createSchemaRegistry();
const b = createSchemaRegistry();
a.register("X", z.string());
expect(a.has("X")).toBe(true);
expect(b.has("X")).toBe(false);
});
it("source file does not declare a singleton", { timeout: 5000 }, () => {
const src = readFileSync(
new URL("./schema-registry.ts", import.meta.url),
"utf8"
);
expect(src.includes("globalSchemaRegistry")).toBe(false);
expect(src.includes("getSchemaRegistry")).toBe(false);
});
it("defineSchema requires explicit registry argument", {
timeout: 5000,
}, () => {
const registry = createSchemaRegistry();
// Type-level: the third parameter is required (not optional).
expectTypeOf(defineSchema).parameter(2).toEqualTypeOf<SchemaRegistry>();
const out = defineSchema(z.string(), { name: "X" }, registry);
expect(registry.get("X")).toBe(out);
});
});

View File

@@ -0,0 +1,233 @@
import { z } from "@hono/zod-openapi";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import { CommonSchemas } from "./common-schemas.js";
import {
createSchemaRegistry,
defineSchema,
SchemaRegistry,
} from "./schema-registry.js";
describe("SchemaRegistry — basic CRUD", () => {
it("register + get round-trips a schema", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
const s = z.object({ id: z.string() });
r.register("User", s, "a user");
expect(r.get("User")).toBe(s);
expect(r.getDescription("User")).toBe("a user");
expect(r.has("User")).toBe(true);
});
it("getNames + getAll reflect registrations", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("A", z.string());
r.register("B", z.number());
expect(r.getNames().sort()).toEqual(["A", "B"]);
const all = r.getAll();
expect(all.size).toBe(2);
all.delete("A"); // confirm defensive copy
expect(r.getNames().sort()).toEqual(["A", "B"]);
});
it("remove drops both schema + description", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("X", z.string(), "d");
expect(r.remove("X")).toBe(true);
expect(r.has("X")).toBe(false);
expect(r.getDescription("X")).toBeUndefined();
expect(r.remove("X")).toBe(false);
});
it("clear empties the registry", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("A", z.string());
r.register("B", z.number(), "b");
r.clear();
expect(r.getNames()).toEqual([]);
expect(r.getDescription("B")).toBeUndefined();
});
it("register without description leaves description map empty", {
timeout: 5000,
}, () => {
const r = createSchemaRegistry();
r.register("A", z.string());
expect(r.getDescription("A")).toBeUndefined();
});
});
describe("SchemaRegistry — derivation helpers", () => {
const buildBase = () =>
z.object({
id: z.string().uuid(),
email: z.string().email(),
password: z.string(),
});
it("extend derives + registers", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("User", buildBase());
const out = r.extend("User", "UserAudit", {
lastSeenAt: z.string().datetime(),
});
expect(out.shape["lastSeenAt"]).toBeDefined();
expect(r.get("UserAudit")).toBe(out);
});
it("partial derives + registers", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("User", buildBase());
const out = r.partial("User", "UserPatch");
expect(out.shape["id"]).toBeDefined();
const result = out.safeParse({});
expect(result.success).toBe(true);
});
it("pick derives + registers", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("User", buildBase());
const out = r.pick("User", "UserPublic", ["id", "email"]);
expect(Object.keys(out.shape).sort()).toEqual(["email", "id"]);
});
it("omit derives + registers", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("User", buildBase());
const out = r.omit("User", "UserPublic", ["password"]);
expect(Object.keys(out.shape).sort()).toEqual(["email", "id"]);
});
it("extend non-object raises OPENAPI_INVALID_BASE_SCHEMA", {
timeout: 5000,
}, () => {
const r = createSchemaRegistry();
r.register("primitives.uuid", z.string().uuid());
let err: unknown;
try {
r.extend("primitives.uuid", "X", { extra: z.string() });
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(OpenAPIError);
expect((err as OpenAPIError).code).toBe(
OPENAPI_CODES.INVALID_BASE_SCHEMA
);
});
it("partial non-object raises INVALID_BASE_SCHEMA", {
timeout: 5000,
}, () => {
const r = createSchemaRegistry();
r.register("p", z.string());
expect(() => r.partial("p", "x")).toThrow(OpenAPIError);
});
it("pick non-object raises INVALID_BASE_SCHEMA", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("p", z.string());
expect(() => r.pick("p", "x", ["k"])).toThrow(OpenAPIError);
});
it("omit non-object raises INVALID_BASE_SCHEMA", { timeout: 5000 }, () => {
const r = createSchemaRegistry();
r.register("p", z.string());
expect(() => r.omit("p", "x", ["k"])).toThrow(OpenAPIError);
});
it("derivation on missing base raises SCHEMA_NOT_FOUND", {
timeout: 5000,
}, () => {
const r = createSchemaRegistry();
let err: unknown;
try {
r.extend("ghost", "x", { k: z.string() });
} catch (e) {
err = e;
}
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.SCHEMA_NOT_FOUND);
});
});
describe("createSchemaRegistry + defineSchema", () => {
it("createSchemaRegistry returns SchemaRegistry instance", {
timeout: 5000,
}, () => {
expect(createSchemaRegistry()).toBeInstanceOf(SchemaRegistry);
});
it("defineSchema registers against the explicit registry", {
timeout: 5000,
}, () => {
const r = createSchemaRegistry();
const s = defineSchema(
z.object({ id: z.string() }),
{ name: "X", description: "x" },
r
);
expect(r.get("X")).toBe(s);
expect(r.getDescription("X")).toBe("x");
});
it("defineSchema returns the original schema instance", {
timeout: 5000,
}, () => {
const r = createSchemaRegistry();
const original = z.string();
const out = defineSchema(original, { name: "S" }, r);
expect(out).toBe(original);
});
});
describe("CommonSchemas", () => {
it("uuid validates RFC 4122 strings", { timeout: 5000 }, () => {
expect(
CommonSchemas.uuid.safeParse("123e4567-e89b-12d3-a456-426614174000")
.success
).toBe(true);
expect(CommonSchemas.uuid.safeParse("nope").success).toBe(false);
});
it("email rejects malformed inputs", { timeout: 5000 }, () => {
expect(CommonSchemas.email.safeParse("a@b.co").success).toBe(true);
expect(CommonSchemas.email.safeParse("notanemail").success).toBe(false);
});
it("url validates absolute URLs", { timeout: 5000 }, () => {
expect(CommonSchemas.url.safeParse("https://e.com").success).toBe(true);
expect(CommonSchemas.url.safeParse("nope").success).toBe(false);
});
it("datetime requires ISO-8601", { timeout: 5000 }, () => {
expect(
CommonSchemas.datetime.safeParse("2024-01-15T10:30:00Z").success
).toBe(true);
expect(CommonSchemas.datetime.safeParse("not iso").success).toBe(false);
});
it("positiveInt rejects zero and negatives", { timeout: 5000 }, () => {
expect(CommonSchemas.positiveInt.safeParse(1).success).toBe(true);
expect(CommonSchemas.positiveInt.safeParse(0).success).toBe(false);
expect(CommonSchemas.positiveInt.safeParse(-1).success).toBe(false);
});
it("nonNegativeInt accepts zero", { timeout: 5000 }, () => {
expect(CommonSchemas.nonNegativeInt.safeParse(0).success).toBe(true);
expect(CommonSchemas.nonNegativeInt.safeParse(-1).success).toBe(false);
});
it("page coerces strings + defaults to 1", { timeout: 5000 }, () => {
expect(CommonSchemas.page.parse("3")).toBe(3);
expect(CommonSchemas.page.parse(undefined)).toBe(1);
});
it("limit clamps to 1..100 with default 20", { timeout: 5000 }, () => {
expect(CommonSchemas.limit.parse(undefined)).toBe(20);
expect(CommonSchemas.limit.safeParse(101).success).toBe(false);
expect(CommonSchemas.limit.safeParse(0).success).toBe(false);
});
it("sortOrder enum + default desc", { timeout: 5000 }, () => {
expect(CommonSchemas.sortOrder.parse(undefined)).toBe("desc");
expect(CommonSchemas.sortOrder.safeParse("nope").success).toBe(false);
});
});

View File

@@ -0,0 +1,141 @@
import { z } from "@hono/zod-openapi";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
/**
* Schema registry for reusable Zod schemas. Instance-based — there is
* no module-level singleton, which would conflict with parallel tests
* and multi-app deployments.
*/
export class SchemaRegistry {
private readonly schemas: Map<string, z.ZodType> = new Map();
private readonly descriptions: Map<string, string> = new Map();
public register<T extends z.ZodType>(
name: string,
schema: T,
description?: string
): T {
this.schemas.set(name, schema);
if (description !== undefined) {
this.descriptions.set(name, description);
}
return schema;
}
public get<T extends z.ZodType = z.ZodType>(name: string): T | undefined {
return this.schemas.get(name) as T | undefined;
}
public has(name: string): boolean {
return this.schemas.has(name);
}
public getDescription(name: string): string | undefined {
return this.descriptions.get(name);
}
public getNames(): string[] {
return Array.from(this.schemas.keys());
}
public getAll(): Map<string, z.ZodType> {
return new Map(this.schemas);
}
public remove(name: string): boolean {
this.descriptions.delete(name);
return this.schemas.delete(name);
}
public clear(): void {
this.schemas.clear();
this.descriptions.clear();
}
public extend(
baseName: string,
name: string,
extension: z.ZodRawShape
): z.ZodObject<z.ZodRawShape> {
const base = this.requireObjectBase(baseName);
const extended = base.extend(extension);
this.register(name, extended);
return extended;
}
public partial(baseName: string, name: string): z.ZodObject<z.ZodRawShape> {
const base = this.requireObjectBase(baseName);
const partial = base.partial();
this.register(name, partial);
return partial;
}
public pick(
baseName: string,
name: string,
keys: string[]
): z.ZodObject<z.ZodRawShape> {
const base = this.requireObjectBase(baseName);
const mask = Object.fromEntries(keys.map((k) => [k, true])) as {
[K in keyof z.ZodRawShape]: true;
};
const picked = base.pick(mask);
this.register(name, picked);
return picked;
}
public omit(
baseName: string,
name: string,
keys: string[]
): z.ZodObject<z.ZodRawShape> {
const base = this.requireObjectBase(baseName);
const mask = Object.fromEntries(keys.map((k) => [k, true])) as {
[K in keyof z.ZodRawShape]: true;
};
const omitted = base.omit(mask);
this.register(name, omitted);
return omitted;
}
private requireObjectBase(baseName: string): z.ZodObject<z.ZodRawShape> {
const base = this.schemas.get(baseName);
if (!base) {
throw new OpenAPIError(
OPENAPI_CODES.SCHEMA_NOT_FOUND,
`schema "${baseName}" is not registered`,
{ context: { baseName } }
);
}
if (!(base instanceof z.ZodObject)) {
throw new OpenAPIError(
OPENAPI_CODES.INVALID_BASE_SCHEMA,
`schema "${baseName}" is not a ZodObject — extend/partial/pick/omit require an object base`,
{ context: { baseName } }
);
}
return base as z.ZodObject<z.ZodRawShape>;
}
}
/**
* Factory — the only way to obtain a {@link SchemaRegistry}. There is
* deliberately no global accessor.
*/
export function createSchemaRegistry(): SchemaRegistry {
return new SchemaRegistry();
}
/**
* Register `schema` under `meta.name` in the supplied registry instance.
* The `registry` argument is required so the call site is explicit
* about which registry receives the schema — eliminates the "which
* global got it?" footgun of upstream's `defineSchema`.
*/
export function defineSchema<T extends z.ZodType>(
schema: T,
meta: { name: string; description?: string },
registry: SchemaRegistry
): T {
return registry.register(meta.name, schema, meta.description);
}

View File

@@ -0,0 +1,13 @@
export {
__resetMissingSchemaCounter,
getMissingSchemaCounter,
type HostEnvironment,
MISSING_SCHEMA_COUNTER_NAME,
type MissingSchemaSlot,
OPENAPI_METER_NAME,
type ReportMissingSchemaInput,
type ResolveStrictnessInput,
reportMissingSchema,
resolveStrictness,
type StrictnessMode,
} from "./strictness.js";

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,187 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
import {
__resetMissingSchemaCounter,
getMissingSchemaCounter,
MISSING_SCHEMA_COUNTER_NAME,
OPENAPI_METER_NAME,
reportMissingSchema,
resolveStrictness,
} from "./strictness.js";
function captureLogger(): {
logger: LoggerLike;
warns: Array<{ msg: string; ctx?: Readonly<Record<string, unknown>> }>;
} {
const warns: Array<{
msg: string;
ctx?: Readonly<Record<string, unknown>>;
}> = [];
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: (msg, ctx) => {
warns.push({ msg, ctx });
},
error: () => undefined,
};
return { logger, warns };
}
describe("resolveStrictness", () => {
it("explicit config wins", { timeout: 5000 }, () => {
expect(
resolveStrictness({
configured: "warn",
environment: "production",
})
).toBe("warn");
expect(
resolveStrictness({
configured: "refuse-install",
environment: "development",
})
).toBe("refuse-install");
});
it("production environment defaults to refuse-install", {
timeout: 5000,
}, () => {
expect(resolveStrictness({ environment: "production" })).toBe(
"refuse-install"
);
});
it("development environment defaults to warn", { timeout: 5000 }, () => {
expect(resolveStrictness({ environment: "development" })).toBe("warn");
});
it("unspecified environment defaults to warn", { timeout: 5000 }, () => {
expect(resolveStrictness({})).toBe("warn");
});
});
describe("reportMissingSchema", () => {
it("refuse-install raises OPENAPI_MISSING_SCHEMA", {
timeout: 5000,
}, () => {
let err: unknown;
try {
reportMissingSchema({
mode: "refuse-install",
plugin: "billing",
method: "get",
path: "/x",
slot: "response",
});
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(OpenAPIError);
expect((err as OpenAPIError).code).toBe(OPENAPI_CODES.MISSING_SCHEMA);
const ctx = (err as OpenAPIError).context as { plugin?: string };
expect(ctx.plugin).toBe("billing");
});
it("warn mode logs a warning and increments injected counter", {
timeout: 5000,
}, () => {
const { logger, warns } = captureLogger();
const incs: Array<{ value: number; attrs?: object }> = [];
const fakeCounter = {
add(value: number, attrs?: object) {
incs.push({ value, attrs });
},
} as unknown as ReturnType<typeof getMissingSchemaCounter>;
reportMissingSchema({
mode: "warn",
plugin: "billing",
method: "get",
path: "/x",
slot: "response",
logger,
counter: fakeCounter,
});
expect(warns).toHaveLength(1);
expect(warns[0]?.msg).toContain("missing");
expect(incs).toEqual([
{
value: 1,
attrs: { plugin: "billing", route: "GET /x", slot: "response" },
},
]);
});
it("warn mode works without a logger", { timeout: 5000 }, () => {
const incs: Array<{ value: number; attrs?: object }> = [];
const fakeCounter = {
add(value: number, attrs?: object) {
incs.push({ value, attrs });
},
} as unknown as ReturnType<typeof getMissingSchemaCounter>;
reportMissingSchema({
mode: "warn",
plugin: "users",
method: "POST",
path: "/users",
slot: "request",
counter: fakeCounter,
});
expect(incs).toHaveLength(1);
});
});
describe("getMissingSchemaCounter / __resetMissingSchemaCounter — OTel integration", () => {
it("emits to a real MeterProvider", { timeout: 5000 }, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.disable();
metrics.setGlobalMeterProvider(provider);
__resetMissingSchemaCounter();
const counter = getMissingSchemaCounter();
// Returns the cached counter on the second call.
expect(getMissingSchemaCounter()).toBe(counter);
reportMissingSchema({
mode: "warn",
plugin: "billing",
method: "GET",
path: "/invoices/{id}",
slot: "response",
counter,
});
await reader.forceFlush();
const collected = exporter.getMetrics();
const counterPoint = collected
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === MISSING_SCHEMA_COUNTER_NAME);
expect(counterPoint).toBeDefined();
expect(counterPoint?.dataPoints[0]?.value).toBe(1);
const scope = collected
.flatMap((rm) => rm.scopeMetrics)
.map((sm) => sm.scope.name);
expect(scope).toContain(OPENAPI_METER_NAME);
await reader.shutdown();
metrics.disable();
__resetMissingSchemaCounter();
});
});

View File

@@ -0,0 +1,125 @@
import { type Counter, metrics } from "@opentelemetry/api";
import type { LoggerLike } from "@suaveplan/types";
import { OPENAPI_CODES, OpenAPIError } from "../error-codes/error-codes.js";
/**
* Strictness modes accepted by the OpenAPI emitter. `warn` logs a
* warning and emits a placeholder; `refuse-install` raises before the
* plugin reaches `installed`.
*/
export type StrictnessMode = "warn" | "refuse-install";
/**
* Effective host environment. Used by {@link resolveStrictness} to pick
* the production-safe default when the host config leaves the value
* unset.
*/
export type HostEnvironment = "development" | "production";
/**
* Input accepted by {@link resolveStrictness}.
*/
export interface ResolveStrictnessInput {
readonly configured?: StrictnessMode;
readonly environment?: HostEnvironment;
}
/**
* Resolve the effective strictness setting.
*
* Precedence:
*
* 1. Explicit `host.openapi.strictness` from the host config.
* 2. `"refuse-install"` when the environment is `"production"`.
* 3. `"warn"` otherwise (including unknown environments — failing
* loud in dev is friendlier than refusing to install).
*/
export function resolveStrictness(
input: ResolveStrictnessInput
): StrictnessMode {
if (input.configured !== undefined) return input.configured;
if (input.environment === "production") return "refuse-install";
return "warn";
}
/** Counter name surfaced when {@link reportMissingSchema} logs a warning. */
export const MISSING_SCHEMA_COUNTER_NAME = "api.openapi.missing_schema_total";
/** Meter name used to acquire {@link MISSING_SCHEMA_COUNTER_NAME}. */
export const OPENAPI_METER_NAME = "@suaveplan/api-openapi";
/**
* Schema slot a route might be missing — used to attribute the counter
* for downstream alerting (request vs response is wildly different in
* severity).
*/
export type MissingSchemaSlot = "request" | "response";
/**
* Input to {@link reportMissingSchema}.
*/
export interface ReportMissingSchemaInput {
readonly mode: StrictnessMode;
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly slot: MissingSchemaSlot;
readonly logger?: LoggerLike;
readonly counter?: Counter;
}
let cachedCounter: Counter | undefined;
/**
* Lazily acquire the shared `api.openapi.missing_schema_total` counter
* from the global meter provider. Tests inject their own counter via
* {@link ReportMissingSchemaInput.counter}; production code shares the
* cached one.
*/
export function getMissingSchemaCounter(): Counter {
if (cachedCounter === undefined) {
const meter = metrics.getMeter(OPENAPI_METER_NAME);
cachedCounter = meter.createCounter(MISSING_SCHEMA_COUNTER_NAME, {
description:
"Number of times a registered route was missing a request or response schema during OpenAPI emission",
});
}
return cachedCounter;
}
/**
* Reset the cached counter — used by tests that inject a fresh
* `MeterProvider` between cases so the next call re-resolves through the
* new provider. NOT for production use.
*/
export function __resetMissingSchemaCounter(): void {
cachedCounter = undefined;
}
/**
* Apply the configured strictness policy when a route is missing the
* requested schema slot.
*
* - `warn` — increment the counter, emit a `logger.warn` message, and
* return so the emitter can substitute a placeholder.
* - `refuse-install` — raise {@link OPENAPI_CODES.MISSING_SCHEMA}.
*
* Returning a value is intentional: callers tee off the boolean to
* decide whether to fill in a `description: "schema-missing"` slot.
*/
export function reportMissingSchema(input: ReportMissingSchemaInput): void {
const { mode, plugin, method, path, slot, logger, counter } = input;
if (mode === "refuse-install") {
throw new OpenAPIError(
OPENAPI_CODES.MISSING_SCHEMA,
`route ${method.toUpperCase()} ${path} (plugin "${plugin}") is missing its ${slot} schema and strictness is "refuse-install"`,
{ context: { plugin, method, path, slot } }
);
}
const c = counter ?? getMissingSchemaCounter();
c.add(1, { plugin, route: `${method.toUpperCase()} ${path}`, slot });
logger?.warn(
`[api-openapi] route ${method.toUpperCase()} ${path} (plugin "${plugin}") is missing its ${slot} schema; emitting placeholder`,
{ plugin, method, path, slot }
);
}

View File

@@ -0,0 +1,11 @@
export {
HTTP_METHODS,
type HttpMethod,
type OpenAPIDocument,
type OpenAPIInfo,
type OpenAPIServer,
type Operation,
type PathItem,
type RegisteredRoute,
type SecurityScheme,
} from "./openapi.js";

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.

View File

@@ -0,0 +1,147 @@
/**
* Structural types for OpenAPI 3.1 documents and registry inputs.
*
* Mirrors the subset of the spec the package emits and merges.
* Vendor extensions are typed as `x-${string}` index signatures so
* arbitrary `x-` keys are accepted without weakening the rest of the
* shape.
*/
/**
* OpenAPI `info` object — title and version are required by the spec.
*/
export interface OpenAPIInfo {
title: string;
version: string;
description?: string;
termsOfService?: string;
contact?: {
name?: string;
url?: string;
email?: string;
};
license?: {
name: string;
url?: string;
};
[key: `x-${string}`]: unknown;
}
/**
* OpenAPI `server` object.
*/
export interface OpenAPIServer {
url: string;
description?: string;
variables?: Record<
string,
{
default: string;
enum?: string[];
description?: string;
}
>;
[key: `x-${string}`]: unknown;
}
/**
* Security scheme definition — covers HTTP, API key, OAuth2, OIDC.
*/
export interface SecurityScheme {
name: string;
type: "http" | "apiKey" | "oauth2" | "openIdConnect";
scheme?: "bearer" | "basic";
bearerFormat?: string;
in?: "header" | "query" | "cookie";
description?: string;
}
/**
* Recorded route metadata — emitted by registry transforms and used
* to derive `paths` entries in {@link OpenAPIDocument}.
*/
export interface RegisteredRoute {
method: string;
path: string;
tags: string[];
operationId?: string;
pluginName?: string;
summary?: string;
description?: string;
deprecated?: boolean;
security?: Array<Record<string, string[]>>;
}
/**
* OpenAPI operation — the GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS entries
* inside a `PathItem`.
*/
export interface Operation {
operationId?: string;
summary?: string;
description?: string;
tags?: string[];
parameters?: unknown[];
requestBody?: unknown;
responses: Record<string, unknown>;
security?: Array<Record<string, string[]>>;
deprecated?: boolean;
[key: `x-${string}`]: unknown;
}
/**
* OpenAPI path item — keyed by HTTP method.
*/
export interface PathItem {
get?: Operation;
post?: Operation;
put?: Operation;
patch?: Operation;
delete?: Operation;
options?: Operation;
head?: Operation;
}
/**
* OpenAPI 3.1 document shape consumed by the merger and emitted by
* `OpenAPIRegistry.buildDocument()`.
*/
export interface OpenAPIDocument {
openapi: string;
info: OpenAPIInfo;
servers?: OpenAPIServer[];
paths: Record<string, PathItem>;
components?: {
schemas?: Record<string, unknown>;
securitySchemes?: Record<string, unknown>;
parameters?: Record<string, unknown>;
responses?: Record<string, unknown>;
};
tags?: Array<{
name: string;
description?: string;
}>;
security?: Array<Record<string, string[]>>;
}
/**
* HTTP method literal accepted by {@link Operation}.
*/
export type HttpMethod =
| "get"
| "post"
| "put"
| "patch"
| "delete"
| "options"
| "head";
export const HTTP_METHODS: readonly HttpMethod[] = [
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
] as const;