Initial Commit
This commit is contained in:
223
packages/api-stack/api/api-openapi/README.md
Normal file
223
packages/api-stack/api/api-openapi/README.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# @suaveplan/api-openapi - OpenAPI 3.1 registry, typed RouteBuilder, schema registry, and document merger for the Suaveplan API. Single source of truth that wires into Hono via OpenAPIRegistry.applyTo(app) — eliminating drift between the introspection registry and the served /openapi.json. Ships StandardResponses spanning the full SuaveplanError statusCode range (400/401/403/404/409/422/429/500/503/504), an ErrorResponseSchema carrying the cause field of SuaveplanError.toJSON(), an instance-only SchemaRegistry (no global singleton) with extend/partial/pick/omit guarded by OPENAPI_INVALID_BASE_SCHEMA, mergeOpenAPIDocuments / prefixPaths / filterPathsByTag / removeInternalRoutes transforms, and a definePluginRoutes helper that registers atomically against both Hono and the registry.
|
||||
|
||||
OpenAPI 3.1 registry, typed `RouteBuilder`, schema registry, and
|
||||
document merger for the Suaveplan API. Single source of truth that
|
||||
wires into Hono via `OpenAPIRegistry.applyTo(app)`, eliminating drift
|
||||
between registry introspection and the served `/openapi.json`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [emitter](./src/emitter/emitter.md) — `createOpenapiEmitter` is the aggregator the spec calls for: it ingests every plugin's manifest, every registered route…
|
||||
- [emitter](./src/emitter/emitter.md) — `createOpenapiEmitter` is the aggregator the spec calls for: it ingests every plugin's manifest, every registered route…
|
||||
- [endpoints](./src/endpoints/endpoints.md) — `endpoints` mounts the four HTTP routes the design doc reserves for the OpenAPI surface — `/openapi.json`, `/openapi.ya…
|
||||
- [endpoints](./src/endpoints/endpoints.md) — `endpoints` mounts the four HTTP routes the design doc reserves for the OpenAPI surface — `/openapi.json`, `/openapi.ya…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for `@suaveplan/api-openapi`. Every public failure path in…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for `@suaveplan/api-openapi`. Every public failure path in…
|
||||
- [merger](./src/merger/merger.md) — OpenAPI document transforms — merge, prefix, filter, redact internal routes. Multi-plugin servers compose the final pub…
|
||||
- [merger](./src/merger/merger.md) — OpenAPI document transforms — merge, prefix, filter, redact internal routes. Multi-plugin servers compose the final pub…
|
||||
- [namespacing](./src/namespacing/namespacing.md) — The `namespacing` module turns a plugin's structural identity (`name`, `displayName`) plus a registered route's `(metho…
|
||||
- [namespacing](./src/namespacing/namespacing.md) — The `namespacing` module turns a plugin's structural identity (`name`, `displayName`) plus a registered route's `(metho…
|
||||
- [plugin-integration](./src/plugin-integration/plugin-integration.md) — `definePluginRoutes` is the single helper plugins call from their `onInit` to register routes. Every call atomically up…
|
||||
- [plugin-integration](./src/plugin-integration/plugin-integration.md) — `definePluginRoutes` is the single helper plugins call from their `onInit` to register routes. Every call atomically up…
|
||||
- [registry](./src/registry/registry.md) — Centralised OpenAPI 3.1 registry that holds `info`, `servers`, `securitySchemes`, `tags`, and `routes`. Acts as the sin…
|
||||
- [registry](./src/registry/registry.md) — Centralised OpenAPI 3.1 registry that holds `info`, `servers`, `securitySchemes`, `tags`, and `routes`. Acts as the sin…
|
||||
- [route-factory](./src/route-factory/route-factory.md) — Typed route construction utilities for `@hono/zod-openapi`. The `RouteBuilder` is a fluent generic that threads `TParam…
|
||||
- [route-factory](./src/route-factory/route-factory.md) — Typed route construction utilities for `@hono/zod-openapi`. The `RouteBuilder` is a fluent generic that threads `TParam…
|
||||
- [schema-registry](./src/schema-registry/schema-registry.md) — Instance-based registry for reusable Zod schemas. Each schema is bound to a stable name and may be derived into related…
|
||||
- [common-schemas](./src/schema-registry/common-schemas.md) — Library of common Zod schemas decorated with OpenAPI `example` metadata. Covers the universal HTTP query / path / pagin…
|
||||
- [schema-registry](./src/schema-registry/schema-registry.md) — Instance-based registry for reusable Zod schemas. Each schema is bound to a stable name and may be derived into related…
|
||||
- [strictness](./src/strictness/strictness.md) — `strictness` resolves the host's missing-schema policy for the OpenAPI emitter and applies it consistently across every…
|
||||
- [strictness](./src/strictness/strictness.md) — `strictness` resolves the host's missing-schema policy for the OpenAPI emitter and applies it consistently across every…
|
||||
- [types](./src/types/openapi.md) — Structural TypeScript types for OpenAPI 3.1 documents, supporting shapes (info / server / security scheme), registered-…
|
||||
- [openapi](./src/types/openapi.md) — Structural TypeScript types for OpenAPI 3.1 documents, supporting shapes (info / server / security scheme), registered-…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Features
|
||||
|
||||
- **`OpenAPIRegistry`** — tracks `info`, `servers`, `securitySchemes`,
|
||||
`tags`, `routes`. `buildDocument()` emits a full 3.1 document.
|
||||
`applyTo(app)` wires a Hono GET handler that serves `buildDocument()`
|
||||
output — no drift possible.
|
||||
- **`RouteBuilder`** — fluent generic that threads
|
||||
`TParams`/`TQuery`/`TBody`/`TResponse` through to the final
|
||||
`TypedRouteConfig`. Hono's `app.openapi(route, handler)` then infers
|
||||
`c.req.valid()` to the exact Zod type.
|
||||
- **`StandardResponses`** — covers the full SuaveplanError statusCode
|
||||
range (400/401/403/404/409/422/429/500/503/504).
|
||||
- **`ErrorResponseSchema`** — 1:1 match for `SuaveplanError.toJSON()`
|
||||
including the `cause` field.
|
||||
- **`SchemaRegistry`** — instance-based (no singleton),
|
||||
`extend/partial/pick/omit` derivation helpers that raise typed
|
||||
`OPENAPI.SCHEMA_NOT_FOUND` / `OPENAPI.INVALID_BASE_SCHEMA` errors.
|
||||
- **`CommonSchemas`** — `uuid`, `email`, `url`, `datetime`,
|
||||
`positiveInt`, `nonNegativeInt`, `page`, `limit`, `sortOrder`.
|
||||
- **`mergeOpenAPIDocuments` / `prefixPaths` / `filterPathsByTag` /
|
||||
`removeInternalRoutes`** — pure document transforms.
|
||||
- **`definePluginRoutes(routes, { app, registry, pluginName })`** —
|
||||
atomic dual-surface registration; the only way a plugin should add
|
||||
routes.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Build a typed route
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { GET, successResponse } from "@suaveplan/api-openapi/route-factory";
|
||||
|
||||
const ListUsers = GET("/users")
|
||||
.tags("users")
|
||||
.operationId("listUsers")
|
||||
.response(successResponse(z.array(z.object({ id: z.string() }))))
|
||||
.build();
|
||||
```
|
||||
|
||||
### With auth + body validation
|
||||
|
||||
```ts
|
||||
import { POST, successResponse } from "@suaveplan/api-openapi/route-factory";
|
||||
|
||||
const CreateUser = POST("/users")
|
||||
.tags("users")
|
||||
.operationId("createUser")
|
||||
.security([{ Bearer: [] }])
|
||||
.body(z.object({ email: z.string().email() }))
|
||||
.response(successResponse(z.object({ id: z.string() })))
|
||||
.build();
|
||||
```
|
||||
|
||||
### Paginated response
|
||||
|
||||
```ts
|
||||
import { GET, paginatedResponse } from "@suaveplan/api-openapi/route-factory";
|
||||
|
||||
const ListUsersPaginated = GET("/users")
|
||||
.tags("users")
|
||||
.query(
|
||||
z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
)
|
||||
.response(paginatedResponse(z.object({ id: z.string() })))
|
||||
.build();
|
||||
```
|
||||
|
||||
### Schema registry derivation
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { createSchemaRegistry } from "@suaveplan/api-openapi/schema-registry";
|
||||
|
||||
const reg = createSchemaRegistry();
|
||||
reg.register(
|
||||
"User",
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const UserPublic = reg.omit("User", "UserPublic", ["password"]);
|
||||
const UserPatch = reg.partial("User", "UserPatch");
|
||||
const UserAudit = reg.extend("User", "UserAudit", {
|
||||
lastSeenAt: z.string().datetime(),
|
||||
});
|
||||
```
|
||||
|
||||
### Merge multiple plugin docs
|
||||
|
||||
```ts
|
||||
import {
|
||||
mergeOpenAPIDocuments,
|
||||
prefixPaths,
|
||||
removeInternalRoutes,
|
||||
} from "@suaveplan/api-openapi/merger";
|
||||
|
||||
const final = removeInternalRoutes(
|
||||
mergeOpenAPIDocuments(
|
||||
baseDoc,
|
||||
prefixPaths(usersDoc, "/v1"),
|
||||
prefixPaths(projectsDoc, "/v1")
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Plugin pattern with `definePluginRoutes`
|
||||
|
||||
```ts
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi/registry";
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi/plugin-integration";
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "Public API", version: "1.0.0" },
|
||||
});
|
||||
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: ListUsers,
|
||||
handler: (c) => c.json({ success: true as const, data: [] }, 200),
|
||||
},
|
||||
{
|
||||
route: CreateUser,
|
||||
handler: (c) =>
|
||||
c.json(
|
||||
{ success: true as const, data: { id: "1" } },
|
||||
200
|
||||
),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "users" }
|
||||
);
|
||||
|
||||
// Single line connects registry → Hono. No drift possible.
|
||||
registry.applyTo(app);
|
||||
```
|
||||
|
||||
## Tier
|
||||
|
||||
Server-tier (Hono-typed). Runs in Bun + Node.
|
||||
|
||||
## Modules
|
||||
|
||||
- **`./error-codes`** — stable `OPENAPI.*` registry (7 codes) +
|
||||
`OpenAPIError` class.
|
||||
- **`./types`** — structural `OpenAPIDocument`, `Operation`, `PathItem`,
|
||||
`RegisteredRoute`, security-scheme types, `HTTP_METHODS`.
|
||||
- **`./registry`** — `OpenAPIRegistry`, `createOpenAPIRegistry`,
|
||||
`applyTo(app)` (Hono GET handler), default `BearerAuthScheme` /
|
||||
`ApiKeyScheme`.
|
||||
- **`./route-factory`** — fluent `RouteBuilder` (`GET`/`POST`/`PUT`/
|
||||
`PATCH`/`DELETE`), `successResponse` / `errorResponse` /
|
||||
`paginatedResponse`, `StandardResponses` (400-504),
|
||||
`ErrorResponseSchema` matching `SuaveplanError.toJSON()`.
|
||||
- **`./schema-registry`** — instance-based `SchemaRegistry` with
|
||||
`extend`/`partial`/`pick`/`omit` derivation + `CommonSchemas`.
|
||||
- **`./merger`** — `mergeOpenAPIDocuments`, `prefixPaths`,
|
||||
`filterPathsByTag`, `removeInternalRoutes`, `extractOpenAPIDoc`.
|
||||
- **`./plugin-integration`** — `definePluginRoutes` (atomic
|
||||
dual-surface registration; the only way a plugin should add routes).
|
||||
- **`./namespacing`** — `assignOperationIds`, `tagFor`,
|
||||
`componentPrefix`, `normaliseRoutePath`. Deterministic operationId
|
||||
(`<plugin>.<method>.<oasPath>`) with `OPENAPI.OPERATION_ID_COLLISION`
|
||||
on duplicates.
|
||||
- **`./strictness`** — `resolveStrictness`, `reportMissingSchema`,
|
||||
`getMissingSchemaCounter`. `warn` vs `refuse-install` policy +
|
||||
`api.openapi.missing_schema_total` OTel counter.
|
||||
- **`./emitter`** — `createOpenapiEmitter({ manifests, routes,
|
||||
authAdapter, hostConfig })`. The Stage-8.6 aggregator: namespacing +
|
||||
strictness + security inheritance + memoised JSON/YAML/filtered
|
||||
output. Emits `api.openapi.emission_total` and
|
||||
`api.openapi.conversion_error_total` OTel counters.
|
||||
- **`./endpoints`** — `createOpenapiRouter` / `mountOpenapiEndpoints`.
|
||||
Mounts `GET /openapi.json`, `/openapi.yaml`, `/docs`, `/docs/:plugin`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
131
packages/api-stack/api/api-openapi/package.json
Normal file
131
packages/api-stack/api/api-openapi/package.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "@suaveplan/api-openapi",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "OpenAPI 3.1 registry, typed RouteBuilder, schema registry, and document merger for the Suaveplan API. Single source of truth that wires into Hono via OpenAPIRegistry.applyTo(app) — eliminating drift between the introspection registry and the served /openapi.json. Ships StandardResponses spanning the full SuaveplanError statusCode range (400/401/403/404/409/422/429/500/503/504), an ErrorResponseSchema carrying the cause field of SuaveplanError.toJSON(), an instance-only SchemaRegistry (no global singleton) with extend/partial/pick/omit guarded by OPENAPI_INVALID_BASE_SCHEMA, mergeOpenAPIDocuments / prefixPaths / filterPathsByTag / removeInternalRoutes transforms, and a definePluginRoutes helper that registers atomically against both Hono and the registry.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/openapi.d.ts",
|
||||
"default": "./dist/types/openapi.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/registry.d.ts",
|
||||
"default": "./dist/registry/registry.js"
|
||||
}
|
||||
},
|
||||
"./route-factory": {
|
||||
"import": {
|
||||
"types": "./dist/route-factory/route-factory.d.ts",
|
||||
"default": "./dist/route-factory/route-factory.js"
|
||||
}
|
||||
},
|
||||
"./schema-registry": {
|
||||
"import": {
|
||||
"types": "./dist/schema-registry/schema-registry.d.ts",
|
||||
"default": "./dist/schema-registry/schema-registry.js"
|
||||
}
|
||||
},
|
||||
"./merger": {
|
||||
"import": {
|
||||
"types": "./dist/merger/merger.d.ts",
|
||||
"default": "./dist/merger/merger.js"
|
||||
}
|
||||
},
|
||||
"./plugin-integration": {
|
||||
"import": {
|
||||
"types": "./dist/plugin-integration/plugin-integration.d.ts",
|
||||
"default": "./dist/plugin-integration/plugin-integration.js"
|
||||
}
|
||||
},
|
||||
"./emitter": {
|
||||
"import": {
|
||||
"types": "./dist/emitter/emitter.d.ts",
|
||||
"default": "./dist/emitter/emitter.js"
|
||||
}
|
||||
},
|
||||
"./namespacing": {
|
||||
"import": {
|
||||
"types": "./dist/namespacing/namespacing.d.ts",
|
||||
"default": "./dist/namespacing/namespacing.js"
|
||||
}
|
||||
},
|
||||
"./strictness": {
|
||||
"import": {
|
||||
"types": "./dist/strictness/strictness.d.ts",
|
||||
"default": "./dist/strictness/strictness.js"
|
||||
}
|
||||
},
|
||||
"./endpoints": {
|
||||
"import": {
|
||||
"types": "./dist/endpoints/endpoints.d.ts",
|
||||
"default": "./dist/endpoints/endpoints.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-schemas": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"yaml": "catalog:",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -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 } } };
|
||||
}>();
|
||||
});
|
||||
});
|
||||
129
packages/api-stack/api/api-openapi/src/emitter/emitter.md
Normal file
129
packages/api-stack/api/api-openapi/src/emitter/emitter.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# emitter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createOpenapiEmitter` is the aggregator the spec calls for: it ingests
|
||||
every plugin's manifest, every registered route (with its Zod-converted
|
||||
schemas), the active auth adapter's security scheme, and the host
|
||||
config, then produces a single OpenAPI 3.1 document covering the entire
|
||||
mounted plugin set. The emitter is the single source of truth served at
|
||||
`/openapi.json` and consumed by the docs UI sibling packages.
|
||||
|
||||
By centralising emission, the host eliminates drift: every plugin's
|
||||
routes flow through the same namespacing rules, the same strictness
|
||||
policy, and the same security inheritance. Plugins cannot hand-author
|
||||
OpenAPI fragments that disagree with what their Hono runtime actually
|
||||
serves.
|
||||
|
||||
## Features
|
||||
|
||||
- **Manifest-driven tags + components** — `displayName` becomes the tag,
|
||||
`<pluginName>.<TypeName>` becomes the component schema prefix.
|
||||
- **Deterministic operationIds** — every route receives
|
||||
`<plugin>.<method>.<oasPath>`. Collisions raise the moment they
|
||||
appear.
|
||||
- **Security inheritance** — every operation lists the active auth
|
||||
adapter's scheme except for routes listed in any plugin's
|
||||
`publicRoutes`, which receive `security: []`.
|
||||
- **Strictness gate** — routes missing request + response schemas warn
|
||||
(with an OTel counter) or refuse install, per `host.openapi.strictness`.
|
||||
- **Per-plugin filtered view** — `getDocumentForPlugin(name)` returns
|
||||
only operations tagged with that plugin; misses raise
|
||||
`OPENAPI.UNKNOWN_PLUGIN_IN_FILTER`.
|
||||
- **JSON + YAML serialisation** — `getJson()` / `getYaml()` produce
|
||||
serialised bodies for the HTTP endpoints.
|
||||
- **OTel emission + conversion-error counters** — visibility on
|
||||
document production and Zod→OAS conversion failures.
|
||||
- **Lifecycle dedup** — the constructed document is memoised; repeated
|
||||
`getDocument()` calls return the same reference.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createOpenapiEmitter } from "@suaveplan/api-openapi/emitter";
|
||||
|
||||
const emitter = createOpenapiEmitter({
|
||||
manifests: [
|
||||
{ name: "billing", displayName: "Billing", description: "Invoices" },
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
pluginName: "billing",
|
||||
route: { method: "GET", path: "/invoices/:id", tags: [] },
|
||||
responseSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
authAdapter: {
|
||||
name: "Bearer",
|
||||
scheme: { type: "http", scheme: "bearer" },
|
||||
},
|
||||
hostConfig: {
|
||||
info: { title: "host", version: "1.0.0" },
|
||||
},
|
||||
});
|
||||
emitter.getJson();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createOpenapiEmitter } from "@suaveplan/api-openapi/emitter";
|
||||
import { z } from "zod";
|
||||
|
||||
const Invoice = z.object({ id: z.string() });
|
||||
const emitter = createOpenapiEmitter({
|
||||
manifests: [{ name: "billing", displayName: "Billing" }],
|
||||
routes: [
|
||||
{
|
||||
pluginName: "billing",
|
||||
route: { method: "GET", path: "/invoices/:id", tags: [] },
|
||||
// Thunk → emitter wraps a thrown converter error in
|
||||
// OPENAPI.SCHEMA_CONVERSION_FAILED and increments the
|
||||
// conversion counter.
|
||||
responseSchema: () => ({ $ref: "#/components/schemas/billing.Invoice" }),
|
||||
components: { Invoice: { type: "object" } },
|
||||
},
|
||||
],
|
||||
hostConfig: {
|
||||
info: { title: "host", version: "1.0.0" },
|
||||
environment: "production",
|
||||
openapi: { strictness: "refuse-install" },
|
||||
},
|
||||
});
|
||||
emitter.getDocumentForPlugin("billing");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createOpenapiEmitter(input) → OpenapiEmitter`.
|
||||
- `OpenapiEmitter.getDocument() → OpenAPIDocument` — memoised.
|
||||
- `OpenapiEmitter.getJson() / getYaml() → string`.
|
||||
- `OpenapiEmitter.getDocumentForPlugin(name) → OpenAPIDocument`.
|
||||
- `OpenapiEmitter.getPluginNames() → readonly string[]`.
|
||||
- `OpenapiEmitter.getStrictness() → StrictnessMode`.
|
||||
- `EmitterPluginManifest`, `EmitterRouteInput`, `EmitterAuthAdapter`,
|
||||
`EmitterHostConfig` — input bag types.
|
||||
- `__resetEmitterCounters()` — test-only seam.
|
||||
- Constants: `EMISSION_COUNTER_NAME`, `CONVERSION_ERROR_COUNTER_NAME`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Schemas may be supplied as a pre-converted object or as a thunk; the
|
||||
thunk form gives the emitter a chance to catch + wrap conversion
|
||||
errors with `OPENAPI.SCHEMA_CONVERSION_FAILED`. Pre-converted objects
|
||||
bypass the catch (the caller has already succeeded).
|
||||
- Strictness application is per route and bound to whichever slot is
|
||||
primary for the method — `request` for non-GET, `response` for GET —
|
||||
so the failure mode mirrors what API clients actually rely on.
|
||||
- Per-plugin filtered views are cached on first request. The filter
|
||||
matches by the resolved tag (`displayName` or `name`), not by the raw
|
||||
plugin name, so two plugins sharing a `displayName` are merged into a
|
||||
single filtered view (and the host's manifest validator should reject
|
||||
such collisions upstream).
|
||||
- Memoisation: `getDocument()` returns the same object reference on
|
||||
every call; mutations to the returned document leak across callers
|
||||
by design — callers MUST `structuredClone` if they need to mutate.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
764
packages/api-stack/api/api-openapi/src/emitter/emitter.test.ts
Normal file
764
packages/api-stack/api/api-openapi/src/emitter/emitter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
468
packages/api-stack/api/api-openapi/src/emitter/emitter.ts
Normal file
468
packages/api-stack/api/api-openapi/src/emitter/emitter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
12
packages/api-stack/api/api-openapi/src/emitter/index.ts
Normal file
12
packages/api-stack/api/api-openapi/src/emitter/index.ts
Normal 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";
|
||||
@@ -0,0 +1,71 @@
|
||||
# endpoints
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`endpoints` mounts the four HTTP routes the design doc reserves for the
|
||||
OpenAPI surface — `/openapi.json`, `/openapi.yaml`, `/docs`, and
|
||||
`/docs/:plugin` — onto a Hono app. The handlers are minimal: they read
|
||||
the memoised emitter, set the appropriate `Content-Type`, and return
|
||||
the body. UI sibling packages (Scalar, Swagger, ReDoc) replace the
|
||||
default HTML stubs with their renderers.
|
||||
|
||||
Separating endpoints from the emitter keeps the host wiring simple: the
|
||||
host builds an emitter once at boot, then mounts the router at whatever
|
||||
path it prefers (commonly `/`).
|
||||
|
||||
## Features
|
||||
|
||||
- `createOpenapiRouter({ emitter })` — standalone Hono router with the
|
||||
four routes wired.
|
||||
- `mountOpenapiEndpoints(app, { emitter })` — convenience that attaches
|
||||
the router onto an existing Hono app.
|
||||
- 404 + typed body for unknown plugins via the filtered view (the
|
||||
emitter raises `OPENAPI.UNKNOWN_PLUGIN_IN_FILTER` and the handler
|
||||
catches it).
|
||||
- Content negotiation: JSON, YAML, HTML — each handler sets its
|
||||
`Content-Type` explicitly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { mountOpenapiEndpoints } from "@suaveplan/api-openapi/endpoints";
|
||||
|
||||
const app = new Hono();
|
||||
mountOpenapiEndpoints(app, { emitter });
|
||||
// app now serves GET /openapi.json, /openapi.yaml, /docs, /docs/:plugin
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { createOpenapiRouter } from "@suaveplan/api-openapi/endpoints";
|
||||
|
||||
const root = new Hono();
|
||||
const docs = createOpenapiRouter({ emitter });
|
||||
root.route("/admin/api", docs);
|
||||
// Routes mount at /admin/api/openapi.json, etc.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createOpenapiRouter(options) → Hono`.
|
||||
- `mountOpenapiEndpoints(app, options) → app` — returns the passed app.
|
||||
- `OpenapiEndpointsOptions = { emitter: OpenapiEmitter }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The HTML stubs are bare on purpose. UI sibling packages
|
||||
(`@suaveplan/api-openapi-ui-scalar`, etc.) own the docs experience;
|
||||
this package only ships the data-bearing endpoints.
|
||||
- Body returns: `c.body()` is used (not `c.json()` / `c.text()`) so the
|
||||
serialised string the emitter already produces is forwarded verbatim
|
||||
— no double-stringify for JSON, no JSON-encoding of YAML.
|
||||
- The unknown-plugin 404 returns the OpenAPI error code in the body so
|
||||
clients can disambiguate from "this plugin exists but has no routes"
|
||||
scenarios (where the filtered view returns an empty `paths` object).
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createOpenapiRouter,
|
||||
mountOpenapiEndpoints,
|
||||
type OpenapiEndpointsOptions,
|
||||
} from "./endpoints.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { OpenAPIErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
OPENAPI_CODES,
|
||||
OpenAPIError,
|
||||
} from "./error-codes.js";
|
||||
118
packages/api-stack/api/api-openapi/src/index.ts
Normal file
118
packages/api-stack/api/api-openapi/src/index.ts
Normal 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";
|
||||
8
packages/api-stack/api/api-openapi/src/merger/index.ts
Normal file
8
packages/api-stack/api/api-openapi/src/merger/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { MergeOptions } from "./merger.js";
|
||||
export {
|
||||
extractOpenAPIDoc,
|
||||
filterPathsByTag,
|
||||
mergeOpenAPIDocuments,
|
||||
prefixPaths,
|
||||
removeInternalRoutes,
|
||||
} from "./merger.js";
|
||||
123
packages/api-stack/api/api-openapi/src/merger/merger.md
Normal file
123
packages/api-stack/api/api-openapi/src/merger/merger.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# merger
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenAPI document transforms — merge, prefix, filter, redact internal
|
||||
routes. Multi-plugin servers compose the final published spec from
|
||||
multiple sources (the registry's `buildDocument()`, the Hono
|
||||
`getOpenAPI31Document()` output, hand-crafted addon docs), and the
|
||||
merger is the deterministic operation that combines them without
|
||||
losing information or producing collisions silently.
|
||||
|
||||
Every transform returns a new document — the inputs are never mutated —
|
||||
so consumers can pipeline `prefixPaths(removeInternalRoutes(merge(...)))`
|
||||
without worrying about aliasing.
|
||||
|
||||
## Features
|
||||
|
||||
- `mergeOpenAPIDocuments(base, ...docs)` — per-path-item path merge,
|
||||
spread-merge of components, dedup tags by name, concat servers.
|
||||
- `prefixPaths(doc, prefix)` — prepends a prefix to every path,
|
||||
collapsing duplicate slashes so `prefix="/api/"` + `path="/users"`
|
||||
yields `/api/users`.
|
||||
- `filterPathsByTag(doc, tags[])` — keeps only operations whose `tags`
|
||||
array intersects the supplied list.
|
||||
- `removeInternalRoutes(doc)` — drops operations marked `x-internal: true`.
|
||||
- `extractOpenAPIDoc(app, info)` — pulls a fully-rendered document out
|
||||
of a Hono `OpenAPIHono` for inspection or merging.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
mergeOpenAPIDocuments,
|
||||
} from "@suaveplan/api-openapi/merger";
|
||||
|
||||
const base: OpenAPIDocument = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Combined", version: "1.0.0" },
|
||||
paths: { "/a": { get: { responses: {} } } },
|
||||
};
|
||||
const addon: Partial<OpenAPIDocument> = {
|
||||
paths: { "/b": { post: { responses: {} } } },
|
||||
};
|
||||
const merged = mergeOpenAPIDocuments(base, addon);
|
||||
console.log(Object.keys(merged.paths)); // ["/a", "/b"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
extractOpenAPIDoc,
|
||||
filterPathsByTag,
|
||||
mergeOpenAPIDocuments,
|
||||
prefixPaths,
|
||||
removeInternalRoutes,
|
||||
} from "@suaveplan/api-openapi/merger";
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
|
||||
const usersApp = new OpenAPIHono();
|
||||
const projectsApp = new OpenAPIHono();
|
||||
// … register routes …
|
||||
|
||||
const usersDoc = extractOpenAPIDoc(usersApp, {
|
||||
title: "Users",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const projectsDoc = extractOpenAPIDoc(projectsApp, {
|
||||
title: "Projects",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
const merged = mergeOpenAPIDocuments(
|
||||
{ openapi: "3.1.0", info: { title: "Public API", version: "1.0.0" }, paths: {} },
|
||||
prefixPaths(usersDoc, "/v1"),
|
||||
prefixPaths(projectsDoc, "/v1")
|
||||
);
|
||||
|
||||
// Hide internal endpoints + restrict to public-facing tags.
|
||||
const publicDoc = filterPathsByTag(
|
||||
removeInternalRoutes(merged),
|
||||
["users", "projects"]
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function mergeOpenAPIDocuments(base, ...docs): OpenAPIDocument`
|
||||
- `function prefixPaths(doc, prefix): OpenAPIDocument`
|
||||
- `function extractOpenAPIDoc(app, info): OpenAPIDocument`
|
||||
- `function filterPathsByTag(doc, tags[]): OpenAPIDocument`
|
||||
- `function removeInternalRoutes(doc): OpenAPIDocument`
|
||||
- `interface MergeOptions { pathPrefix?: string; overridePaths?: boolean }` — reserved for future use.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`mergeOpenAPIDocuments` is left-associative — merging `(a, b, c)` is the
|
||||
same as `merge(merge(a, b), c)`. This property is asserted under
|
||||
fast-check at 200 runs in `tests/property.test.ts`, ensuring the merger
|
||||
remains a monoid even as future spread-rules land.
|
||||
|
||||
`mergeOpenAPIDocuments` does **not** detect cross-document collisions
|
||||
on the same `(method, path)`. The last value wins. That is the intended
|
||||
upstream behaviour and the only reasonable default — strict-detection
|
||||
mode is reserved for a follow-up.
|
||||
|
||||
`prefixPaths` uses `String.replace(/\/+/g, "/")` to collapse duplicate
|
||||
slashes. This is the only place double-slashes can be introduced (the
|
||||
prefix `/api/` plus path `/users` would yield `/api//users` without
|
||||
collapse). The transform is idempotent: applying it twice with the same
|
||||
prefix yields the same paths.
|
||||
|
||||
`filterPathsByTag` drops paths whose every operation is removed,
|
||||
preventing empty `PathItem` shells in the result. `removeInternalRoutes`
|
||||
applies the same rule for `x-internal`.
|
||||
|
||||
The transforms intentionally do not validate the input documents — they
|
||||
treat the input as best-effort structural data. Validation belongs to a
|
||||
separate spec-validator package (out of scope here).
|
||||
233
packages/api-stack/api/api-openapi/src/merger/merger.test.ts
Normal file
233
packages/api-stack/api/api-openapi/src/merger/merger.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
189
packages/api-stack/api/api-openapi/src/merger/merger.ts
Normal file
189
packages/api-stack/api/api-openapi/src/merger/merger.ts
Normal 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 };
|
||||
}
|
||||
10
packages/api-stack/api/api-openapi/src/namespacing/index.ts
Normal file
10
packages/api-stack/api/api-openapi/src/namespacing/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
assignOperationIds,
|
||||
componentPrefix,
|
||||
type NamespacedRoute,
|
||||
type NamespacedRouteInput,
|
||||
type NamespacePluginInfo,
|
||||
normaliseRoutePath,
|
||||
operationIdFor,
|
||||
tagFor,
|
||||
} from "./namespacing.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# namespacing
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `namespacing` module turns a plugin's structural identity (`name`,
|
||||
`displayName`) plus a registered route's `(method, path)` tuple into the
|
||||
deterministic OpenAPI surface fields — tag name, component-schema
|
||||
prefix, normalised OAS path, and `operationId`. It is the bridge between
|
||||
the plugin-namespaced runtime and the single merged OpenAPI 3.1
|
||||
document `@suaveplan/api-openapi` emits.
|
||||
|
||||
Determinism matters: clients regenerate SDKs against the document and
|
||||
expect stable `operationId`s across boots. Collisions — two plugins
|
||||
producing the same id — would silently overwrite the earlier route in
|
||||
client toolchains, so the module raises `OPENAPI.OPERATION_ID_COLLISION`
|
||||
the moment a duplicate appears.
|
||||
|
||||
## Features
|
||||
|
||||
- `tagFor(plugin)` — prefers `displayName`, falls back to `name`.
|
||||
- `componentPrefix(plugin)` — `<pluginName>` literal, used by the
|
||||
emitter as the prefix for `components.schemas` entries.
|
||||
- `normaliseRoutePath(path)` — rewrites Express-style `:id` parameters
|
||||
into the OAS `{id}` form. Idempotent.
|
||||
- `operationIdFor(plugin, method, path)` — full deterministic id:
|
||||
`<plugin>.<method>.<oasPath>`.
|
||||
- `assignOperationIds(inputs)` — bulk walk that respects pre-set
|
||||
`operationId` values and raises a typed error on collision.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
assignOperationIds,
|
||||
operationIdFor,
|
||||
tagFor,
|
||||
} from "@suaveplan/api-openapi/namespacing";
|
||||
|
||||
const plugin = { name: "billing", displayName: "Billing" };
|
||||
operationIdFor(plugin, "GET", "/invoices/:id");
|
||||
// => "billing.get./invoices/{id}"
|
||||
tagFor(plugin); // => "Billing"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { assignOperationIds } from "@suaveplan/api-openapi/namespacing";
|
||||
import type { RegisteredRoute } from "@suaveplan/api-openapi/types";
|
||||
|
||||
const plugins = [
|
||||
{ name: "billing", displayName: "Billing" },
|
||||
{ name: "users" },
|
||||
];
|
||||
const routes: ReadonlyArray<{
|
||||
plugin: (typeof plugins)[number];
|
||||
route: RegisteredRoute;
|
||||
}> = [
|
||||
{ plugin: plugins[0]!, route: { method: "GET", path: "/invoices/:id", tags: [] } },
|
||||
{ plugin: plugins[1]!, route: { method: "GET", path: "/users/:id", tags: [] } },
|
||||
];
|
||||
const namespaced = assignOperationIds(routes);
|
||||
// Throws OPENAPI.OPERATION_ID_COLLISION if any duplicate appears.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NamespacePluginInfo` — `{ name, displayName? }`.
|
||||
- `tagFor(plugin) → string`.
|
||||
- `componentPrefix(plugin) → string`.
|
||||
- `normaliseRoutePath(path: string) → string`.
|
||||
- `operationIdFor(plugin, method, path) → string`.
|
||||
- `assignOperationIds(inputs) → NamespacedRoute[]`.
|
||||
- `NamespacedRoute` — `{ pluginName, tag, operationId, method, path, route }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Path normalisation uses the regex `:([A-Za-z_][\w-]*)` so colons
|
||||
preceded by non-identifier characters (e.g. `:443` in a server URL)
|
||||
are not rewritten.
|
||||
- Method is lower-cased to match OAS path-item key casing.
|
||||
- Pre-set `operationId` overrides the derived one — useful for stable
|
||||
ids that pre-date the convention. Collisions still raise.
|
||||
- The module does not know about strictness; the emitter applies
|
||||
strictness to missing schemas separately.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
DefinePluginRoutesOptions,
|
||||
PluginRouteEntry,
|
||||
RouteHandler,
|
||||
} from "./plugin-integration.js";
|
||||
export { definePluginRoutes } from "./plugin-integration.js";
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
8
packages/api-stack/api/api-openapi/src/registry/index.ts
Normal file
8
packages/api-stack/api/api-openapi/src/registry/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { OpenAPIRegistryOptions } from "./registry.js";
|
||||
export {
|
||||
ApiKeyScheme,
|
||||
BearerAuthScheme,
|
||||
createOpenAPIRegistry,
|
||||
DEFAULT_SPEC_PATH,
|
||||
OpenAPIRegistry,
|
||||
} from "./registry.js";
|
||||
134
packages/api-stack/api/api-openapi/src/registry/registry.md
Normal file
134
packages/api-stack/api/api-openapi/src/registry/registry.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised OpenAPI 3.1 registry that holds `info`, `servers`,
|
||||
`securitySchemes`, `tags`, and `routes`. Acts as the single source of
|
||||
truth across the package: plugins register against it, the merger
|
||||
consumes its `buildDocument()` output, and the Hono integration wires
|
||||
it directly to `app.doc(...)` via `applyTo(app)`. Eliminates the
|
||||
upstream's silent drift between the introspection registry and the
|
||||
served `/openapi.json`.
|
||||
|
||||
The registry is instance-based — never a singleton — so multi-tenant
|
||||
servers, parallel tests, and ephemeral spec previews all run without
|
||||
shared state.
|
||||
|
||||
## Features
|
||||
|
||||
- Constructor takes `info`, optional `servers`, optional `security`
|
||||
(security-scheme records), and optional `globalSecurity` (default
|
||||
security requirements for every operation).
|
||||
- `registerSecurityScheme(scheme)` / `registerTag(name, description?)`
|
||||
/ `registerRoute(route)`.
|
||||
- `getRoutes()`, `getRoutesByPlugin(name)`, `getRoutesByTag(tag)`,
|
||||
`updateInfo(partial)`, `addServer(server)`, `clearRoutes()`.
|
||||
- `buildDocument()` returns the full `OpenAPIDocument` (3.1.0, info,
|
||||
servers, paths derived from registered routes, components.securitySchemes,
|
||||
tags, security).
|
||||
- `applyTo(app, specPath = "/openapi.json")` registers a GET handler on
|
||||
the Hono app that returns `buildDocument()` as JSON.
|
||||
- `BearerAuthScheme` + `ApiKeyScheme` presets.
|
||||
- Factory `createOpenAPIRegistry(options)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi/registry";
|
||||
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "Users API", version: "1.0.0" },
|
||||
});
|
||||
|
||||
registry.registerRoute({
|
||||
method: "get",
|
||||
path: "/users",
|
||||
tags: ["users"],
|
||||
operationId: "listUsers",
|
||||
summary: "List all users",
|
||||
});
|
||||
|
||||
const doc = registry.buildDocument();
|
||||
console.log(doc.paths["/users"]?.get?.operationId); // "listUsers"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
BearerAuthScheme,
|
||||
createOpenAPIRegistry,
|
||||
} from "@suaveplan/api-openapi/registry";
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "Multi-plugin API", version: "0.1.0" },
|
||||
servers: [{ url: "https://api.example.com" }],
|
||||
security: [BearerAuthScheme],
|
||||
globalSecurity: [{ Bearer: [] }],
|
||||
});
|
||||
|
||||
registry.registerRoute({
|
||||
method: "post",
|
||||
path: "/projects",
|
||||
tags: ["projects"],
|
||||
pluginName: "projects",
|
||||
operationId: "createProject",
|
||||
deprecated: false,
|
||||
security: [{ Bearer: [] }],
|
||||
});
|
||||
|
||||
// Single line wires the registry to Hono. The served `/openapi.json` is
|
||||
// always `buildDocument()` — no drift possible.
|
||||
registry.applyTo(app, "/openapi.json");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class OpenAPIRegistry`
|
||||
- `constructor(options: OpenAPIRegistryOptions)`
|
||||
- `registerSecurityScheme(scheme: SecurityScheme): void`
|
||||
- `registerTag(name: string, description?: string): void`
|
||||
- `registerRoute(route: RegisteredRoute): void`
|
||||
- `getRoutes(): RegisteredRoute[]` — defensive copy
|
||||
- `getRoutesByPlugin(pluginName: string): RegisteredRoute[]`
|
||||
- `getRoutesByTag(tag: string): RegisteredRoute[]`
|
||||
- `updateInfo(info: Partial<OpenAPIInfo>): void`
|
||||
- `addServer(server: OpenAPIServer): void`
|
||||
- `clearRoutes(): void`
|
||||
- `buildDocument(): OpenAPIDocument`
|
||||
- `applyTo(app: OpenAPIHono, specPath?: string): void`
|
||||
- `function createOpenAPIRegistry(options): OpenAPIRegistry`
|
||||
- `const BearerAuthScheme: SecurityScheme`
|
||||
- `const ApiKeyScheme: SecurityScheme`
|
||||
- `const DEFAULT_SPEC_PATH = "/openapi.json"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildDocument()` synthesises `paths` from the registered routes — each
|
||||
route becomes an `Operation` keyed by method under its `path`. Multiple
|
||||
methods on the same path collapse into one `PathItem` (registry collisions
|
||||
on the same `(method, path)` overwrite — last registration wins, mirroring
|
||||
Hono's `app.openapi` semantics).
|
||||
|
||||
The synthesised `Operation` body is intentionally lean — `operationId`,
|
||||
`summary`, `description`, `tags`, `deprecated`, `security`, and an empty
|
||||
`responses: {}`. Request/response Zod-derived schemas are added by the
|
||||
plugin integration when the registry is paired with `app.openapi(route, …)`
|
||||
and merged into the registry doc via `mergeOpenAPIDocuments`. Keeping
|
||||
the registry's emitted operations schema-free means the registry stays
|
||||
zero-cost to build (no Zod traversal) and reusable for non-Hono targets.
|
||||
|
||||
`applyTo` registers a plain `app.get(specPath, …)` handler rather than
|
||||
`app.doc(specPath, …)` so the served body is exactly `buildDocument()`.
|
||||
Hono's `app.doc` invokes its own document builder, which would defeat
|
||||
the purpose of using the registry as the source of truth.
|
||||
|
||||
All getters return defensive copies of arrays — internal mutation of
|
||||
`routes` / `servers` from caller code is impossible without going
|
||||
through the explicit `register*` / `clearRoutes` / `addServer` methods.
|
||||
252
packages/api-stack/api/api-openapi/src/registry/registry.test.ts
Normal file
252
packages/api-stack/api/api-openapi/src/registry/registry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
222
packages/api-stack/api/api-openapi/src/registry/registry.ts
Normal file
222
packages/api-stack/api/api-openapi/src/registry/registry.ts
Normal 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",
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,123 @@
|
||||
# route-factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Typed route construction utilities for `@hono/zod-openapi`. The
|
||||
`RouteBuilder` is a fluent generic that threads `TParams`, `TQuery`,
|
||||
`TBody`, and `TResponse` from `.params/.query/.body/.response` calls
|
||||
through to the final `.build()` output. The resulting `TypedRouteConfig`
|
||||
is assignable to `RouteConfig`, so `app.openapi(route, handler)` infers
|
||||
`c.req.valid("params" | "query" | "json")` to the exact Zod type
|
||||
without `unknown` widening.
|
||||
|
||||
Also exports the wire-shape `ErrorResponseSchema`, the wider
|
||||
`StandardResponses` (covering every status code a `SuaveplanError` can
|
||||
emit — 400/401/403/404/409/422/429/500/503/504), and the
|
||||
`successResponse` / `paginatedResponse` / `errorResponse` envelope
|
||||
helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- `RouteBuilder<TParams, TQuery, TBody, TResponse>` fluent generic.
|
||||
- `route(method, path)` factory + `GET`/`POST`/`PUT`/`PATCH`/`DELETE` shorthands.
|
||||
- `StandardResponses` widened to cover the full SuaveplanError status range.
|
||||
- `ErrorResponseSchema` carrying the `cause` field from `SuaveplanError.toJSON()`.
|
||||
- `successResponse(schema)`, `paginatedResponse(itemSchema)`, `errorResponse(description)` helpers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { GET, successResponse } from "@suaveplan/api-openapi/route-factory";
|
||||
|
||||
const ListUsers = GET("/users")
|
||||
.tags("users")
|
||||
.operationId("listUsers")
|
||||
.response(successResponse(z.array(z.object({ id: z.string() }))))
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import z from "zod";
|
||||
import {
|
||||
POST,
|
||||
paginatedResponse,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi/route-factory";
|
||||
|
||||
const UserParam = z.object({ id: z.string().uuid() });
|
||||
const UserCreate = z.object({ email: z.string().email() });
|
||||
const User = z.object({ id: z.string(), email: z.string() });
|
||||
|
||||
const createUser = POST("/users/:id")
|
||||
.tags("users")
|
||||
.operationId("createUser")
|
||||
.deprecated()
|
||||
.security([{ Bearer: [] }])
|
||||
.params(UserParam)
|
||||
.body(UserCreate)
|
||||
.response(successResponse(User))
|
||||
.build();
|
||||
|
||||
const listUsers = POST("/users/list")
|
||||
.tags("users")
|
||||
.response(paginatedResponse(User))
|
||||
.build();
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
app.openapi(createUser, async (c) => {
|
||||
const params = c.req.valid("param"); // typed as z.infer<typeof UserParam>
|
||||
const body = c.req.valid("json"); // typed as z.infer<typeof UserCreate>
|
||||
return c.json({ success: true, data: { id: params.id, email: body.email } });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type RouteMethod = "get"|"post"|"put"|"patch"|"delete"`
|
||||
- `interface RouteOptions<TParams, TQuery, TBody, TResponse>`
|
||||
- `const ErrorResponseSchema` + `type ErrorResponse`
|
||||
- `function successResponse<T>(dataSchema: T)`
|
||||
- `function errorResponse(description: string)`
|
||||
- `function paginatedResponse<T>(itemSchema: T)`
|
||||
- `const StandardResponses` — 400/401/403/404/409/422/429/500/503/504 each pointing at `ErrorResponseSchema`
|
||||
- `type TypedRouteConfig<TParams, TQuery, TBody, TResponse>`
|
||||
- `class RouteBuilder<TParams, TQuery, TBody, TResponse>`
|
||||
- `function route(method, path): RouteBuilder`
|
||||
- `const GET/POST/PUT/PATCH/DELETE`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TypedRouteConfig` uses `Omit<RouteConfig, "request"> & { request: … }`
|
||||
so the `request` property is non-optional. Hono's `InputType*`
|
||||
conditional types only match when `request` is present, so making it
|
||||
optional silently downgrades inference to `unknown`.
|
||||
|
||||
The builder records state in a private object rather than directly on
|
||||
`this` so the four `.params/.query/.body/.response` overloads can re-cast
|
||||
the receiver to a new generic instantiation without TypeScript losing
|
||||
track of the actual stored values.
|
||||
|
||||
`StandardResponses` is widened to cover every status `SuaveplanError`
|
||||
emits. Upstream omitted 422 (validation), 429 (rate limit), 503
|
||||
(service unavailable), and 504 (gateway timeout), forcing every plugin
|
||||
that wanted to document those responses to redefine them locally — a
|
||||
common source of doc drift. The widened set is now the single source.
|
||||
|
||||
`ErrorResponseSchema` adds the `cause` field on top of upstream's
|
||||
shape. SuaveplanError supports a cause chain, and serialising it gives
|
||||
clients enough context to diagnose without requesting logs. `cause` is
|
||||
typed `z.unknown()` because the chain may be heterogeneous —
|
||||
SuaveplanError descendants, native `Error`s, or serialised payloads.
|
||||
|
||||
`responses` always merges `200: Success` first and then spreads
|
||||
`StandardResponses` — so consumers can override `200` by passing
|
||||
`.response(…)` but cannot accidentally drop one of the standard error
|
||||
slots.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
# common-schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
Library of common Zod schemas decorated with OpenAPI `example` metadata.
|
||||
Covers the universal HTTP query / path / pagination primitives that
|
||||
every API plugin reaches for — UUID, email, URL, ISO datetime, signed
|
||||
and unsigned integers, page/limit/sort order. Pulling these into a
|
||||
single module ensures every plugin documents the same constraints,
|
||||
prevents drift, and makes the OpenAPI spec consistent across the entire
|
||||
multi-plugin server.
|
||||
|
||||
The schemas are exported as a single `CommonSchemas` const object so
|
||||
consumers can spread, destructure, or `.openapi(...)`-extend any subset.
|
||||
They are deliberately not pre-registered against any `SchemaRegistry` —
|
||||
each consumer chooses whether (and under which name) to register them.
|
||||
|
||||
## Features
|
||||
|
||||
- `uuid`, `email`, `url`, `datetime` — string formats with examples.
|
||||
- `positiveInt`, `nonNegativeInt` — integer constraints.
|
||||
- `page` / `limit` — coercing pagination inputs with sane defaults.
|
||||
- `sortOrder` — `"asc" | "desc"` enum, default `"desc"`.
|
||||
- Every entry carries an OpenAPI `example` so the generated spec is rich.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { CommonSchemas } from "@suaveplan/api-openapi/schema-registry";
|
||||
|
||||
const ListQuery = z.object({
|
||||
page: CommonSchemas.page,
|
||||
limit: CommonSchemas.limit,
|
||||
sort: CommonSchemas.sortOrder,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
CommonSchemas,
|
||||
createSchemaRegistry,
|
||||
} from "@suaveplan/api-openapi/schema-registry";
|
||||
|
||||
const registry = createSchemaRegistry();
|
||||
|
||||
// Register pagination primitives once under shared names so OpenAPI emits
|
||||
// `#/components/schemas/pagination.page` references rather than inlining
|
||||
// the same constraint in every operation.
|
||||
registry.register("pagination.page", CommonSchemas.page);
|
||||
registry.register("pagination.limit", CommonSchemas.limit);
|
||||
registry.register("pagination.sortOrder", CommonSchemas.sortOrder);
|
||||
|
||||
const ListUsersQuery = z.object({
|
||||
page: registry.get("pagination.page")!,
|
||||
limit: registry.get("pagination.limit")!,
|
||||
sort: registry.get("pagination.sortOrder")!,
|
||||
q: z.string().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CommonSchemas.uuid: ZodString` — UUID string with example.
|
||||
- `CommonSchemas.email: ZodString` — email string.
|
||||
- `CommonSchemas.url: ZodString` — URL string.
|
||||
- `CommonSchemas.datetime: ZodString` — ISO-8601 datetime.
|
||||
- `CommonSchemas.positiveInt: ZodNumber` — `> 0`, integer.
|
||||
- `CommonSchemas.nonNegativeInt: ZodNumber` — `≥ 0`, integer.
|
||||
- `CommonSchemas.page: ZodCoerce<number>` — `min 1`, default `1`.
|
||||
- `CommonSchemas.limit: ZodCoerce<number>` — `min 1, max 100`, default `20`.
|
||||
- `CommonSchemas.sortOrder: ZodEnum<"asc" | "desc">` — default `"desc"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`page` and `limit` use `z.coerce.number()` rather than `z.number()` so
|
||||
they accept query-string inputs (always strings on the wire). Without
|
||||
coercion every plugin would have to wrap them in `.transform(Number)`
|
||||
manually, defeating the point of a shared catalogue.
|
||||
|
||||
The `.openapi({ example: ... })` annotation surfaces in the generated
|
||||
spec without affecting validation. Consumers that want richer
|
||||
descriptions (`{ description: "..." }`) call `.openapi(...)` on a copy
|
||||
rather than mutating the shared schema.
|
||||
|
||||
`sortOrder` defaults to `"desc"` because the most common pagination
|
||||
pattern is "latest first". Plugins that want a different default override
|
||||
locally with `CommonSchemas.sortOrder.default("asc")`.
|
||||
|
||||
`CommonSchemas` is `as const` — TypeScript surfaces every field with its
|
||||
narrowest literal type. Consumers can `typeof CommonSchemas` to derive
|
||||
per-field types without importing each schema individually.
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { CommonSchemas } from "./common-schemas.js";
|
||||
export {
|
||||
createSchemaRegistry,
|
||||
defineSchema,
|
||||
SchemaRegistry,
|
||||
} from "./schema-registry.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
13
packages/api-stack/api/api-openapi/src/strictness/index.ts
Normal file
13
packages/api-stack/api/api-openapi/src/strictness/index.ts
Normal 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";
|
||||
101
packages/api-stack/api/api-openapi/src/strictness/strictness.md
Normal file
101
packages/api-stack/api/api-openapi/src/strictness/strictness.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# strictness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`strictness` resolves the host's missing-schema policy for the OpenAPI
|
||||
emitter and applies it consistently across every route. It encodes the
|
||||
two-mode contract from the spec: `warn` (emit + warn + count) and
|
||||
`refuse-install` (raise before the plugin is installed). The same
|
||||
helper is wired into both the emitter and the host's plugin-install
|
||||
gate so missing schemas behave identically wherever they're detected.
|
||||
|
||||
The module also owns the `api.openapi.missing_schema_total` OTel
|
||||
counter — without an OTel meter exporter wired into the host, missing
|
||||
schemas silently accumulate. Surfacing them as a per-plugin per-route
|
||||
labelled metric makes drift visible to anyone watching the
|
||||
observability stack.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveStrictness({ configured, environment })` — typed precedence
|
||||
resolver. Configured value wins; otherwise `"production"` → refuse,
|
||||
`"development"`/anything else → warn.
|
||||
- `reportMissingSchema(input)` — single chokepoint that either throws
|
||||
or logs+counts. Accepts an injectable logger and counter so test
|
||||
setups can assert exact behaviour with a real `MeterProvider`.
|
||||
- `getMissingSchemaCounter()` — lazy accessor over the package meter so
|
||||
production callers do not need to thread the counter through.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
reportMissingSchema,
|
||||
resolveStrictness,
|
||||
} from "@suaveplan/api-openapi/strictness";
|
||||
|
||||
const mode = resolveStrictness({ environment: "production" });
|
||||
// "refuse-install"
|
||||
|
||||
reportMissingSchema({
|
||||
mode,
|
||||
plugin: "billing",
|
||||
method: "GET",
|
||||
path: "/invoices/:id",
|
||||
slot: "response",
|
||||
});
|
||||
// throws OpenAPIError(OPENAPI.MISSING_SCHEMA)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
__resetMissingSchemaCounter,
|
||||
reportMissingSchema,
|
||||
} from "@suaveplan/api-openapi/strictness";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
__resetMissingSchemaCounter();
|
||||
|
||||
const logger = { warn: (msg: string) => console.warn(msg) } as const;
|
||||
reportMissingSchema({
|
||||
mode: "warn",
|
||||
plugin: "billing",
|
||||
method: "POST",
|
||||
path: "/invoices",
|
||||
slot: "request",
|
||||
logger,
|
||||
});
|
||||
// → counter increments, logger.warn called
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StrictnessMode` — `"warn" | "refuse-install"`.
|
||||
- `HostEnvironment` — `"development" | "production"`.
|
||||
- `resolveStrictness(input) → StrictnessMode`.
|
||||
- `reportMissingSchema(input) → void` (throws on `refuse-install`).
|
||||
- `getMissingSchemaCounter() → Counter`.
|
||||
- `__resetMissingSchemaCounter()` — test-only seam.
|
||||
- Constants: `MISSING_SCHEMA_COUNTER_NAME`, `OPENAPI_METER_NAME`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The counter is acquired lazily so registering the package side-effect
|
||||
does not require an OTel SDK at import time. Tests reset the cache
|
||||
via `__resetMissingSchemaCounter()` before binding a fresh
|
||||
`MeterProvider`.
|
||||
- The counter labels — `plugin`, `route`, `slot` — match the spec's
|
||||
scenario assertion (`{ plugin, route }`) and add `slot` so request
|
||||
vs response misses can be distinguished without a second metric.
|
||||
- `LoggerLike.warn` is optional in the contract (`@suaveplan/types`);
|
||||
the call is guarded with optional chaining so structured loggers that
|
||||
only ship `info` + `error` still work.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
125
packages/api-stack/api/api-openapi/src/strictness/strictness.ts
Normal file
125
packages/api-stack/api/api-openapi/src/strictness/strictness.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
11
packages/api-stack/api/api-openapi/src/types/index.ts
Normal file
11
packages/api-stack/api/api-openapi/src/types/index.ts
Normal 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";
|
||||
84
packages/api-stack/api/api-openapi/src/types/openapi.md
Normal file
84
packages/api-stack/api/api-openapi/src/types/openapi.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# types/openapi
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Structural TypeScript types for OpenAPI 3.1 documents, supporting shapes
|
||||
(info / server / security scheme), registered-route metadata, and the
|
||||
HTTP-method literal union. These are the contract surfaces exchanged
|
||||
between the registry, the merger, the route factory, and external
|
||||
consumers — keeping them in one file makes the public surface easy to
|
||||
audit and prevents shape drift between modules.
|
||||
|
||||
The types intentionally mirror just the slice of OpenAPI 3.1 that this
|
||||
package emits and merges. Full spec coverage (callbacks, links, runtime
|
||||
expressions) is out of scope — the package targets server-tier route
|
||||
documentation, not a general-purpose spec authoring tool.
|
||||
|
||||
## Features
|
||||
|
||||
- `OpenAPIInfo`, `OpenAPIServer`, `SecurityScheme` — top-level document atoms.
|
||||
- `RegisteredRoute` — registry-internal metadata for a single registered route.
|
||||
- `Operation`, `PathItem`, `OpenAPIDocument` — structural document shape used by the merger.
|
||||
- `HttpMethod` literal union + `HTTP_METHODS` readonly array.
|
||||
- Open-ended `x-${string}` index signatures so vendor extensions pass through without weakening field types.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { OpenAPIDocument } from "@suaveplan/api-openapi/types";
|
||||
|
||||
const doc: OpenAPIDocument = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "My API", version: "1.0.0" },
|
||||
paths: {},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { HTTP_METHODS } from "@suaveplan/api-openapi/types";
|
||||
import type { Operation, PathItem } from "@suaveplan/api-openapi/types";
|
||||
|
||||
function eachOp(item: PathItem, fn: (m: string, op: Operation) => void): void {
|
||||
for (const m of HTTP_METHODS) {
|
||||
const op = item[m];
|
||||
if (op) fn(m, op);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface OpenAPIInfo` — title, version, optional description / contact / license / x-extensions.
|
||||
- `interface OpenAPIServer` — url, description, variables, x-extensions.
|
||||
- `interface SecurityScheme` — name + type discriminator + scheme/bearerFormat/in/description.
|
||||
- `interface RegisteredRoute` — internal record: method, path, tags, operationId, pluginName, summary, description, deprecated, security.
|
||||
- `interface Operation` — OpenAPI operation, x-extension friendly.
|
||||
- `interface PathItem` — keyed by lowercase HTTP method.
|
||||
- `interface OpenAPIDocument` — info + servers + paths + components + tags + security.
|
||||
- `type HttpMethod` — `"get" | "post" | "put" | "patch" | "delete" | "options" | "head"`.
|
||||
- `const HTTP_METHODS` — readonly array of every literal.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All fields except `info.title`, `info.version`, `openapi`, and `paths`
|
||||
are optional, matching the OpenAPI 3.1 schema. The merger and registry
|
||||
construct documents with `paths: {}` and grow them as routes register —
|
||||
never with `paths: undefined`.
|
||||
|
||||
`Operation.responses` is typed as `Record<string, unknown>` rather than a
|
||||
structurally-typed response object because the actual response shapes
|
||||
are produced by `@hono/zod-openapi`'s `createRoute` and depend on the
|
||||
caller's Zod schemas. Tightening the type would require importing zod
|
||||
into the types module and would cascade compile-time dependencies. The
|
||||
merger only manipulates whole responses, never their internals, so the
|
||||
weaker shape is safe.
|
||||
|
||||
`HTTP_METHODS` exists alongside the literal union because the merger
|
||||
and filter helpers need to iterate the methods at runtime. Keeping the
|
||||
two in this single module guarantees they cannot drift.
|
||||
147
packages/api-stack/api/api-openapi/src/types/openapi.ts
Normal file
147
packages/api-stack/api/api-openapi/src/types/openapi.ts
Normal 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;
|
||||
126
packages/api-stack/api/api-openapi/tests/dep-graph.test.ts
Normal file
126
packages/api-stack/api/api-openapi/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = new URL("..", import.meta.url).pathname;
|
||||
const PKG_JSON = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_ALLOWLIST = new Set([
|
||||
"@hono/zod-openapi",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-plugin",
|
||||
"@suaveplan/api-schemas",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"hono",
|
||||
"tslib",
|
||||
"yaml",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
const SRC_ROOT = join(PKG_ROOT, "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) out.push(...walk(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FEATURE_MODULES = [
|
||||
"error-codes",
|
||||
"types",
|
||||
"registry",
|
||||
"route-factory",
|
||||
"schema-registry",
|
||||
"merger",
|
||||
"plugin-integration",
|
||||
"emitter",
|
||||
"namespacing",
|
||||
"strictness",
|
||||
"endpoints",
|
||||
];
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("dependencies match the allowlist", { timeout: 5000 }, () => {
|
||||
for (const dep of Object.keys(PKG_JSON.dependencies)) {
|
||||
expect(RUNTIME_ALLOWLIST.has(dep)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("subpath exports cover every feature module", { timeout: 5000 }, () => {
|
||||
const exports = PKG_JSON.exports;
|
||||
for (const m of FEATURE_MODULES) {
|
||||
expect(exports[`./${m}`]).toBeDefined();
|
||||
}
|
||||
expect(exports["."]).toBeDefined();
|
||||
});
|
||||
|
||||
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC_ROOT).filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".test.ts") &&
|
||||
!f.endsWith(".types.ts") &&
|
||||
!f.endsWith(".bench.ts") &&
|
||||
!f.endsWith(".spec-conformance.test.ts") &&
|
||||
!/[/\\]index\.ts$/.test(f) &&
|
||||
!/[/\\]src[/\\]index\.ts$/.test(f)
|
||||
);
|
||||
for (const f of files) {
|
||||
const md = f.replace(/\.ts$/, ".md");
|
||||
const has = (() => {
|
||||
try {
|
||||
statSync(md);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
expect({ file: f, hasMd: has }).toEqual({
|
||||
file: f,
|
||||
hasMd: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("no biome-ignore suppressions in src", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC_ROOT).filter((f) => f.endsWith(".ts"));
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, "utf8");
|
||||
expect(text.includes("biome-ignore")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("no direct vitest/bun:test imports", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC_ROOT)
|
||||
.concat(walk(join(PKG_ROOT, "tests")))
|
||||
.filter((f) => f.endsWith(".ts"));
|
||||
const banned =
|
||||
/from\s+['"](vitest|bun:test)['"]|require\(\s*['"](vitest|bun:test)['"]\s*\)/;
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, "utf8");
|
||||
if (banned.test(text)) {
|
||||
throw new Error(`${f} imports vitest or bun:test directly`);
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
|
||||
for (const m of FEATURE_MODULES) {
|
||||
const idx = join(SRC_ROOT, m, "index.ts");
|
||||
const text = readFileSync(idx, "utf8");
|
||||
expect(text.includes("export *")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { OpenAPIHono, z } from "@hono/zod-openapi";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { definePluginRoutes } from "../../src/plugin-integration/plugin-integration.js";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
DEFAULT_SPEC_PATH,
|
||||
} from "../../src/registry/registry.js";
|
||||
import {
|
||||
GET,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "../../src/route-factory/route-factory.js";
|
||||
|
||||
describe("full doc roundtrip — real OpenAPIHono + real registry + 3 plugins", () => {
|
||||
it("registers across 3 plugins and the served doc matches buildDocument()", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "All-in-One", version: "1.0.0" },
|
||||
});
|
||||
|
||||
// Plugin: users
|
||||
const listUsers = GET("/users")
|
||||
.tags("users")
|
||||
.operationId("listUsers")
|
||||
.response(successResponse(z.array(z.object({ id: z.string() }))))
|
||||
.build();
|
||||
const createUser = POST("/users")
|
||||
.tags("users")
|
||||
.operationId("createUser")
|
||||
.body(z.object({ email: z.string().email() }))
|
||||
.response(
|
||||
successResponse(z.object({ id: z.string(), email: z.string() }))
|
||||
)
|
||||
.build();
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: listUsers,
|
||||
handler: (c) =>
|
||||
c.json({ success: true as const, data: [] }, 200),
|
||||
},
|
||||
{
|
||||
route: createUser,
|
||||
handler: (c) =>
|
||||
c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { id: "1", email: "u@e.com" },
|
||||
},
|
||||
200
|
||||
),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "users" }
|
||||
);
|
||||
|
||||
// Plugin: projects
|
||||
const listProjects = GET("/projects")
|
||||
.tags("projects")
|
||||
.operationId("listProjects")
|
||||
.response(successResponse(z.array(z.object({ id: z.string() }))))
|
||||
.build();
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: listProjects,
|
||||
handler: (c) =>
|
||||
c.json({ success: true as const, data: [] }, 200),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "projects" }
|
||||
);
|
||||
|
||||
// Plugin: health
|
||||
const health = GET("/health")
|
||||
.tags("ops")
|
||||
.operationId("getHealth")
|
||||
.response(successResponse(z.object({ ok: z.boolean() })))
|
||||
.build();
|
||||
definePluginRoutes(
|
||||
[
|
||||
{
|
||||
route: health,
|
||||
handler: (c) =>
|
||||
c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { ok: true },
|
||||
},
|
||||
200
|
||||
),
|
||||
},
|
||||
],
|
||||
{ app, registry, pluginName: "health" }
|
||||
);
|
||||
|
||||
// Connect the registry to the app
|
||||
registry.applyTo(app);
|
||||
|
||||
// Assert registry-built doc contains every route
|
||||
const built = registry.buildDocument();
|
||||
expect(Object.keys(built.paths).sort()).toEqual([
|
||||
"/health",
|
||||
"/projects",
|
||||
"/users",
|
||||
]);
|
||||
expect(built.paths["/users"]?.get?.operationId).toBe("listUsers");
|
||||
expect(built.paths["/users"]?.post?.operationId).toBe("createUser");
|
||||
|
||||
// Tag set carries through
|
||||
expect(built.tags?.map((t) => t.name).sort()).toEqual([
|
||||
"ops",
|
||||
"projects",
|
||||
"users",
|
||||
]);
|
||||
|
||||
// Plugin-name introspection
|
||||
expect(
|
||||
registry
|
||||
.getRoutesByPlugin("users")
|
||||
.map((r) => r.path)
|
||||
.sort()
|
||||
).toEqual(["/users", "/users"]);
|
||||
expect(
|
||||
registry.getRoutesByPlugin("projects").map((r) => r.path)
|
||||
).toEqual(["/projects"]);
|
||||
|
||||
// Served doc must equal buildDocument() byte-for-byte
|
||||
const res = await app.request(DEFAULT_SPEC_PATH);
|
||||
expect(res.status).toBe(200);
|
||||
const served = (await res.json()) as unknown;
|
||||
expect(served).toEqual(built);
|
||||
});
|
||||
});
|
||||
56
packages/api-stack/api/api-openapi/tests/property.test.ts
Normal file
56
packages/api-stack/api/api-openapi/tests/property.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { mergeOpenAPIDocuments } from "../src/merger/merger.js";
|
||||
import type { OpenAPIDocument } from "../src/types/openapi.js";
|
||||
|
||||
const operationArb = fc.record({
|
||||
responses: fc.constant({}),
|
||||
});
|
||||
|
||||
const pathItemArb = fc.dictionary(
|
||||
fc.constantFrom("get", "post", "put", "patch", "delete"),
|
||||
operationArb,
|
||||
{ minKeys: 1, maxKeys: 3 }
|
||||
);
|
||||
|
||||
const pathsArb = fc.dictionary(
|
||||
fc.string({ minLength: 1, maxLength: 10 }).map((s) => `/${s}`),
|
||||
pathItemArb,
|
||||
{ minKeys: 0, maxKeys: 5 }
|
||||
);
|
||||
|
||||
const docArb = pathsArb.map<OpenAPIDocument>((paths) => ({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "T", version: "1.0.0" },
|
||||
paths,
|
||||
}));
|
||||
|
||||
describe("mergeOpenAPIDocuments — associativity property", () => {
|
||||
it("merge(a, b, c) === merge(merge(a, b), c) for arbitrary path sets", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(docArb, docArb, docArb, (a, b, c) => {
|
||||
const left = mergeOpenAPIDocuments(a, b, c);
|
||||
const right = mergeOpenAPIDocuments(
|
||||
mergeOpenAPIDocuments(a, b),
|
||||
c
|
||||
);
|
||||
expect(right.paths).toEqual(left.paths);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("merging with empty extras is idempotent on paths", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(docArb, (a) => {
|
||||
const m = mergeOpenAPIDocuments(a, {}, {}, {});
|
||||
expect(m.paths).toEqual(a.paths);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
34
packages/api-stack/api/api-openapi/tests/setup/polyfills.ts
Normal file
34
packages/api-stack/api/api-openapi/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/api-openapi.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module is imported. Tests that inspect emitted metrics can read them
|
||||
* from `testInMemoryMetricExporter`. Node ≥18 exposes `crypto.subtle`
|
||||
* and `crypto.getRandomValues` globally, so no crypto polyfill needed.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-openapi/tsconfig.json
Normal file
12
packages/api-stack/api/api-openapi/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-openapi/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-openapi/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/api-stack/api/api-openapi/vite.config.ts
Normal file
38
packages/api-stack/api/api-openapi/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-openapi",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"hono",
|
||||
"tslib",
|
||||
"yaml",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/api-stack/api/api-openapi/vitest.config.ts
Normal file
32
packages/api-stack/api/api-openapi/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user