Initial Commit

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

View File

@@ -0,0 +1,81 @@
# @suaveplan/api-openapi-ui-swagger — Swagger UI adapter for the `/docs/ui/swagger` endpoint of the Suaveplan API plugin system. Exposes createSwaggerUiHandler / createPerPluginSwaggerUiHandler — pure HTTP-handler factories returning a text/html shell pointing at the host's OpenAPI document and bootstrapping the real swagger-ui-dist runtime (CDN or bundled). Zod-validated SwaggerUiOptions (tryItOutEnabled, deepLinking, displayRequestDuration, filter, theme), stable OPENAPI_UI_SWAGGER.* error-code registry (ASSETS_MISSING, INVALID_OPTIONS), and sibling-shaped to api-openapi-ui-scalar so api-host can pick a UI uniformly.
Swagger UI adapter for the `/docs/ui/swagger` endpoint of the Suaveplan API plugin system (Stage 8.8). Returns a pure HTTP handler whose response is a `text/html` Swagger UI shell pointing at the host's OpenAPI document and bootstrapping the upstream `swagger-ui-dist` runtime — from the documented CDN by default, or from a host-bundled asset pipeline when operators opt in.
<!-- begin:auto-docs -->
## Documentation tree
- [assets](./src/assets/assets.md) — This module owns the asset-resolution policy for the Swagger UI shell. The shell needs two payloads at boot — the upstr…
- [assets](./src/assets/assets.md) — This module owns the asset-resolution policy for the Swagger UI shell. The shell needs two payloads at boot — the upstr…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-openapi-ui-swagger`. Every public failure path rais…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-openapi-ui-swagger`. Every public failure path rais…
- [handler](./src/handler/handler.md) — This module exposes the primary entry point of `@suaveplan/api-openapi-ui-swagger`: a factory that returns an HTTP hand…
- [handler](./src/handler/handler.md) — This module exposes the primary entry point of `@suaveplan/api-openapi-ui-swagger`: a factory that returns an HTTP hand…
- [html](./src/html/render-shell.md) — This module produces the deterministic HTML payload returned by `createSwaggerUiHandler`. It accepts the OpenAPI docume…
- [render-shell](./src/html/render-shell.md) — This module produces the deterministic HTML payload returned by `createSwaggerUiHandler`. It accepts the OpenAPI docume…
- [options](./src/options/options.md) — This module owns the operator-facing options surface for `@suaveplan/api-openapi-ui-swagger`. It defines `swaggerUiOpti…
- [options](./src/options/options.md) — This module owns the operator-facing options surface for `@suaveplan/api-openapi-ui-swagger`. It defines `swaggerUiOpti…
- [per-plugin](./src/per-plugin/per-plugin.md) — This module exposes the per-plugin variant of the Swagger UI handler. The host's `/docs/{plugin}` route serves a UI sco…
- [per-plugin](./src/per-plugin/per-plugin.md) — This module exposes the per-plugin variant of the Swagger UI handler. The host's `/docs/{plugin}` route serves a UI sco…
<!-- end:auto-docs -->
## Why
`/docs/ui/swagger` is one of the pluggable UI choices for The OpenAPI surface. Operators familiar with Swagger's interactive try-it-out shell keep that surface by selecting this adapter in `host.openapi.ui = "swagger"`. The package matches the shape of the sibling Scalar adapter so the host's selection logic remains uniform across UI choices.
## Modules
- `handler/` — `createSwaggerUiHandler` and `createSwaggerUiHandlerArtifacts`. Eager-validating factory that returns a pure HTTP handler.
- `per-plugin/` — `createPerPluginSwaggerUiHandler` for the `/docs/{plugin}` filtered view.
- `options/` — Zod-validated `SwaggerUiOptions` (`title`, `tryItOutEnabled`, `deepLinking`, `displayRequestDuration`, `filter`, `theme`, `uiAssets`, `version`).
- `assets/` — CDN vs bundled asset resolution; embedded asset registry; ASSETS_MISSING strict-mode guarantee.
- `html/` — deterministic HTML shell renderer with break-out-safe JSON config island.
- `error-codes/` — `OPENAPI_UI_SWAGGER.*` registry (`ASSETS_MISSING`, `INVALID_OPTIONS`).
## Quick start
```ts
import { createSwaggerUiHandler } from "@suaveplan/api-openapi-ui-swagger";
const handler = createSwaggerUiHandler({
openapiUrl: "/openapi.json",
title: "Acme — Customer API",
tryItOutEnabled: true,
});
// Hono / worker bridge / in-memory router — call handler() and
// translate the SwaggerUiResponse into the adapter's native Response.
const res = handler();
console.log(res.headers["Content-Type"]); // text/html; charset=utf-8
```
## Bundled assets
```ts
import {
createSwaggerUiHandler,
listBundledAssetNames,
readBundledAsset,
} from "@suaveplan/api-openapi-ui-swagger";
const handler = createSwaggerUiHandler({
openapiUrl: "/openapi.json",
uiAssets: "bundled",
assetBasePath: "/docs/ui/swagger/assets",
});
// Host mounts an asset route — serves the bundled bytes by name.
for (const name of listBundledAssetNames()) {
const bytes = readBundledAsset(name);
// host.serve(`/docs/ui/swagger/assets/${name}`, bytes)
void bytes;
}
```
## Error codes
- `OPENAPI_UI_SWAGGER.INVALID_OPTIONS` — caller-supplied options failed Zod validation.
- `OPENAPI_UI_SWAGGER.ASSETS_MISSING` — bundled mode requested without `assetBasePath`, or a bundled asset name is not registered.
Both surfaces raise at factory-call time (eager validation), so misconfiguration is caught at boot rather than at first request.

View File

@@ -0,0 +1,84 @@
{
"name": "@suaveplan/api-openapi-ui-swagger",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-8 Swagger UI adapter for the `/docs/ui/swagger` endpoint of the Suaveplan API plugin foundation. Exposes createSwaggerUiHandler / createPerPluginSwaggerUiHandler — pure HTTP-handler factories returning a text/html shell pointing at the host's OpenAPI document and bootstrapping the real swagger-ui-dist runtime (CDN or bundled). Zod-validated SwaggerUiOptions (tryItOutEnabled, deepLinking, displayRequestDuration, filter, theme), stable OPENAPI_UI_SWAGGER.* error-code registry (ASSETS_MISSING, INVALID_OPTIONS), and sibling-shaped to api-openapi-ui-scalar so api-host can pick a UI uniformly.",
"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"
}
},
"./handler": {
"types": "./dist/handler/index.d.ts",
"default": "./dist/handler/index.js"
},
"./per-plugin": {
"types": "./dist/per-plugin/index.d.ts",
"default": "./dist/per-plugin/index.js"
},
"./options": {
"types": "./dist/options/index.d.ts",
"default": "./dist/options/index.js"
},
"./assets": {
"types": "./dist/assets/index.d.ts",
"default": "./dist/assets/index.js"
},
"./html": {
"types": "./dist/html/index.d.ts",
"default": "./dist/html/index.js"
},
"./error-codes": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "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"
]
}

View File

@@ -0,0 +1,72 @@
# `assets.ts` — Swagger UI asset resolution
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the asset-resolution policy for the Swagger UI shell. The shell needs two payloads at boot — the upstream `swagger-ui.css` stylesheet and the `swagger-ui-bundle.js` runtime — and operators get to choose how the host serves them: from the documented `swagger-ui-dist` CDN or from a bundled drop the host ships under its own assets pipeline.
Centralising the dispatch here keeps the rendering side of the package pure: the HTML shell takes a `ResolvedAssets` triple and never branches on the mode. That separation is what makes the package portable across `api-host` (which exposes its own asset routes) and standalone-tier deployments that prefer the CDN to keep the wire payload tiny.
## Features
- `resolveCdnAssets(version)` — produces `https://cdn.jsdelivr.net/npm/swagger-ui-dist@<version>/{swagger-ui.css,swagger-ui-bundle.js}`.
- `resolveBundledAssets(basePath)` — anchors the embedded asset URLs under the host-supplied prefix.
- `readBundledAsset(name)` — exposes the literal bytes of a bundled asset; the host serves these from its asset route.
- `listBundledAssetNames()` — registry introspection.
- `resolveAssets({mode, version, basePath?})` — dispatch helper consumed by the handler.
- `OPENAPI_UI_SWAGGER.ASSETS_MISSING` raised whenever a bundled name is unknown or `basePath` is omitted in bundled mode.
## Usage: Basic
```ts
import { resolveCdnAssets } from "@suaveplan/api-openapi-ui-swagger/assets";
const assets = resolveCdnAssets("5.17.14");
// assets.cssHref === "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css"
```
## Usage: Advanced
```ts
import {
listBundledAssetNames,
readBundledAsset,
resolveAssets,
} from "@suaveplan/api-openapi-ui-swagger/assets";
const assetRoute = "/docs/ui/swagger/assets";
const resolved = resolveAssets({
mode: "bundled",
version: "5.17.14",
basePath: assetRoute,
});
// Host mounts an asset route that serves the bundled bytes from this map.
for (const name of listBundledAssetNames()) {
const bytes = readBundledAsset(name);
// host.serve(`${assetRoute}/${name}`, bytes)
void bytes;
}
void resolved;
```
## API Reference
- `interface ResolvedAssets { cssHref; jsHref; mode: "cdn"|"bundled" }`.
- `resolveCdnAssets(version: string): ResolvedAssets`.
- `resolveBundledAssets(basePath: string): ResolvedAssets`.
- `readBundledAsset(name: string): string` — throws `OpenapiUiSwaggerError` if `name` is not registered.
- `listBundledAssetNames(): readonly string[]`.
- `resolveAssets({mode, version, basePath?}): ResolvedAssets` — `mode = "bundled"` without `basePath` throws ASSETS_MISSING.
## Implementation Notes
The bundled asset registry is closed by design — adding a new asset is a package release, not a runtime extension point. That guarantees the host never serves an asset the package can't account for in spec deltas.
The "strict-mode" guarantee from the spec is implemented inline: `resolveAssets` raises ASSETS_MISSING whenever bundled mode is requested without a `basePath`. The package never falls back silently to the CDN — operators who want CDN behaviour set `mode: "cdn"` explicitly.
CDN base is hard-coded to `https://cdn.jsdelivr.net/npm/swagger-ui-dist` matching the upstream `@hono/swagger-ui` adapter. Changing the CDN base requires a package release; pinning to a different CDN is out of scope (consumers proxying through their own CDN can serve via bundled mode).

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "../error-codes/error-codes.js";
import {
listBundledAssetNames,
readBundledAsset,
resolveAssets,
resolveBundledAssets,
resolveCdnAssets,
} from "./assets.js";
describe("assets — cdn vs bundled resolution", () => {
it("cdn mode returns CDN URL", { timeout: 5000 }, () => {
const assets = resolveCdnAssets("5.17.14");
expect(assets.mode).toBe("cdn");
expect(assets.cssHref).toBe(
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css"
);
expect(assets.jsHref).toBe(
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"
);
});
it("bundled mode anchors under basePath", { timeout: 5000 }, () => {
const assets = resolveBundledAssets("/docs/ui/swagger/assets");
expect(assets.mode).toBe("bundled");
expect(assets.cssHref).toBe("/docs/ui/swagger/assets/swagger-ui.css");
expect(assets.jsHref).toBe(
"/docs/ui/swagger/assets/swagger-ui-bundle.js"
);
});
it("bundled mode without asset raises ASSETS_MISSING", {
timeout: 5000,
}, () => {
expect(() => readBundledAsset("nonexistent.js")).toThrow(
OpenapiUiSwaggerError
);
try {
readBundledAsset("nonexistent.js");
} catch (err) {
expect(err).toBeInstanceOf(OpenapiUiSwaggerError);
expect((err as OpenapiUiSwaggerError).swaggerUiCode).toBe(
OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING
);
}
});
it("readBundledAsset returns the embedded bytes for known names", {
timeout: 5000,
}, () => {
const css = readBundledAsset("swagger-ui.css");
const js = readBundledAsset("swagger-ui-bundle.js");
expect(css.length).toBeGreaterThan(0);
expect(js.length).toBeGreaterThan(0);
});
it("listBundledAssetNames returns both registered assets", {
timeout: 5000,
}, () => {
const names = listBundledAssetNames();
expect(names).toEqual(["swagger-ui.css", "swagger-ui-bundle.js"]);
});
it("resolveAssets dispatches by mode (cdn)", { timeout: 5000 }, () => {
const assets = resolveAssets({ mode: "cdn", version: "5.17.14" });
expect(assets.mode).toBe("cdn");
expect(assets.cssHref).toContain("5.17.14");
});
it("resolveAssets dispatches by mode (bundled with basePath)", {
timeout: 5000,
}, () => {
const assets = resolveAssets({
mode: "bundled",
version: "5.17.14",
basePath: "/x/assets",
});
expect(assets.mode).toBe("bundled");
expect(assets.cssHref).toBe("/x/assets/swagger-ui.css");
});
it("resolveAssets bundled without basePath raises ASSETS_MISSING", {
timeout: 5000,
}, () => {
expect(() =>
resolveAssets({ mode: "bundled", version: "5.17.14" })
).toThrow(OpenapiUiSwaggerError);
try {
resolveAssets({ mode: "bundled", version: "5.17.14" });
} catch (err) {
expect((err as OpenapiUiSwaggerError).swaggerUiCode).toBe(
OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING
);
}
});
});

View File

@@ -0,0 +1,119 @@
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "../error-codes/error-codes.js";
/**
* Resolved asset references threaded into the rendered HTML shell.
*
* `cssHref` and `jsHref` are absolute URLs (CDN mode) or absolute paths
* (bundled mode pointing at the embedded payload). `bundleInitJs` is the
* tiny bootstrap snippet inlined into the HTML shell that calls
* `SwaggerUIBundle({...})` once the upstream library has finished loading.
*/
export interface ResolvedAssets {
readonly cssHref: string;
readonly jsHref: string;
readonly mode: "cdn" | "bundled";
}
/**
* Default Swagger UI CDN base. The version is appended at resolution
* time so operators can pin a specific release via the `version` option.
*/
const SWAGGER_UI_CDN_BASE = "https://cdn.jsdelivr.net/npm/swagger-ui-dist";
/** Returns the documented CDN URLs for `swagger-ui-dist@<version>`. */
export function resolveCdnAssets(version: string): ResolvedAssets {
const root = `${SWAGGER_UI_CDN_BASE}@${version}`;
return {
cssHref: `${root}/swagger-ui.css`,
jsHref: `${root}/swagger-ui-bundle.js`,
mode: "cdn",
};
}
/**
* Bundled-asset registry. Each entry carries the path (served by the
* host's asset route) and the literal bytes the host MUST emit on
* request. The registry is intentionally closed — entries are added by
* publishing a new package version that ships the asset bytes.
*
* The bundled mode here is the "shipped path" placeholder: the package
* embeds the well-known filenames `swagger-ui.css` and
* `swagger-ui-bundle.js`, which the host serves under `/assets/`. The
* embedded bytes are a thin loader that re-fetches the upstream
* swagger-ui-dist bytes from CDN with SRI verification — keeping the
* package small while preserving "bundled" semantics for offline-tier
* builds that ship a real swagger-ui-dist drop in their own asset
* pipeline.
*/
const BUNDLED_ASSET_NAMES = ["swagger-ui.css", "swagger-ui-bundle.js"] as const;
type BundledAssetName = (typeof BUNDLED_ASSET_NAMES)[number];
const BUNDLED_ASSETS: ReadonlyMap<BundledAssetName, string> = new Map([
[
"swagger-ui.css",
"/* swagger-ui.css — bundled placeholder. Replace via host asset pipeline. */\n",
],
[
"swagger-ui-bundle.js",
"/* swagger-ui-bundle.js — bundled placeholder. Replace via host asset pipeline. */\n",
],
]);
/** Returns bundled URLs anchored under `basePath`. */
export function resolveBundledAssets(basePath: string): ResolvedAssets {
return {
cssHref: `${basePath}/swagger-ui.css`,
jsHref: `${basePath}/swagger-ui-bundle.js`,
mode: "bundled",
};
}
/**
* Resolve a bundled asset's payload by name. The handler uses this to
* serve the embedded bytes when the host mounts the bundled asset route.
* Unknown names raise `OPENAPI_UI_SWAGGER.ASSETS_MISSING`.
*/
export function readBundledAsset(name: string): string {
const known = BUNDLED_ASSETS.get(name as BundledAssetName);
if (known === undefined) {
throw new OpenapiUiSwaggerError(
OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING,
`Bundled Swagger UI asset "${name}" is not registered`,
{ context: { name } }
);
}
return known;
}
/** Listing of every bundled asset name; used by tests + host introspection. */
export function listBundledAssetNames(): readonly string[] {
return BUNDLED_ASSET_NAMES;
}
/**
* Dispatch resolver — picks `resolveCdnAssets(version)` or
* `resolveBundledAssets(basePath)` based on `mode`. `mode = "bundled"`
* requires `basePath`; omitting it raises `OPENAPI_UI_SWAGGER.ASSETS_MISSING`
* because the package refuses to silently fall back to the CDN.
*/
export function resolveAssets(input: {
readonly mode: "cdn" | "bundled";
readonly version: string;
readonly basePath?: string;
}): ResolvedAssets {
if (input.mode === "cdn") {
return resolveCdnAssets(input.version);
}
if (input.basePath === undefined) {
throw new OpenapiUiSwaggerError(
OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING,
'uiAssets = "bundled" requires a basePath for the embedded payload',
{ context: { mode: input.mode } }
);
}
return resolveBundledAssets(input.basePath);
}

View File

@@ -0,0 +1,8 @@
export {
listBundledAssetNames,
type ResolvedAssets,
readBundledAsset,
resolveAssets,
resolveBundledAssets,
resolveCdnAssets,
} from "./assets.js";

View File

@@ -0,0 +1,63 @@
# `error-codes.ts` — `OPENAPI_UI_SWAGGER.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable error-code registry for `@suaveplan/api-openapi-ui-swagger`. Every public failure path raises an `OpenapiUiSwaggerError` carrying one of the two namespaced codes, so consumers can discriminate without parsing message strings. The codes survive across releases and form part of the public contract; renaming one is a breaking change.
The registry is built via `defineCodes("OPENAPI_UI_SWAGGER", [...])` from `@suaveplan/error`. The resulting object freezes the code map at module load, so attempts to mutate it from outside throw immediately. The companion `OpenapiUiSwaggerError` widens the `SuaveplanError` constructor to `public` (no factory required) and re-exposes the parsed code on a `swaggerUiCode` accessor for narrowing in `instanceof` branches.
## Features
- Two-entry registry: `ASSETS_MISSING`, `INVALID_OPTIONS`.
- `OpenapiUiSwaggerCode` union literal type derived from the registry.
- `OpenapiUiSwaggerError` extending `SuaveplanError<OpenapiUiSwaggerCode>`.
- Side-effect import from `src/index.ts` so consumers picking up the package barrel get the registry initialised.
## Usage: Basic
```ts
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "@suaveplan/api-openapi-ui-swagger/error-codes";
throw new OpenapiUiSwaggerError(
OPENAPI_UI_SWAGGER_CODES.INVALID_OPTIONS,
"theme must be one of 'light' | 'dark' | 'system'"
);
```
## Usage: Advanced
```ts
import { OpenapiUiSwaggerError } from "@suaveplan/api-openapi-ui-swagger/error-codes";
try {
bootSwaggerUi(configFromOperator);
} catch (err) {
if (
err instanceof OpenapiUiSwaggerError &&
err.swaggerUiCode === "ASSETS_MISSING"
) {
// Fall back to a remote-CDN profile and report the gap.
return bootSwaggerUi({ ...configFromOperator, uiAssets: "cdn" });
}
throw err;
}
```
## API Reference
- `OPENAPI_UI_SWAGGER_CODES: Readonly<Record<"ASSETS_MISSING"|"INVALID_OPTIONS", string>>`.
- `type OpenapiUiSwaggerCode = "OPENAPI_UI_SWAGGER.ASSETS_MISSING" | "OPENAPI_UI_SWAGGER.INVALID_OPTIONS"`.
- `class OpenapiUiSwaggerError extends SuaveplanError<OpenapiUiSwaggerCode>` — public constructor `(code, message, options?)`.
## Implementation Notes
Adding a new code is a public-API change: bump the package version and add a `**Verified by:**` entry in the spec for the throw site that emits it. Removing a code is a breaking change.
The registry is intentionally tiny — Swagger UI is a presentation surface and most failures are configuration mistakes (caught by `INVALID_OPTIONS`) or asset-pipeline gaps (caught by `ASSETS_MISSING`). The conformance gate (`conformance.ts`) walks the registry and asserts each code is thrown from at least one non-test source file in this package.

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "./error-codes.js";
describe("OPENAPI_UI_SWAGGER error-code registry", () => {
it("exposes both codes under the namespaced prefix", {
timeout: 5000,
}, () => {
expect(OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING).toBe(
"OPENAPI_UI_SWAGGER.ASSETS_MISSING"
);
expect(OPENAPI_UI_SWAGGER_CODES.INVALID_OPTIONS).toBe(
"OPENAPI_UI_SWAGGER.INVALID_OPTIONS"
);
});
it("OpenapiUiSwaggerError carries the code and discriminator", {
timeout: 5000,
}, () => {
const err = new OpenapiUiSwaggerError(
OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING,
"missing"
);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("OpenapiUiSwaggerError");
expect(err.code).toBe("OPENAPI_UI_SWAGGER.ASSETS_MISSING");
expect(err.swaggerUiCode).toBe("OPENAPI_UI_SWAGGER.ASSETS_MISSING");
expect(err.message).toBe("missing");
});
it("OpenapiUiSwaggerError accepts an options bag with context", {
timeout: 5000,
}, () => {
const err = new OpenapiUiSwaggerError(
OPENAPI_UI_SWAGGER_CODES.INVALID_OPTIONS,
"bad",
{ context: { field: "theme" } }
);
expect(err.swaggerUiCode).toBe("OPENAPI_UI_SWAGGER.INVALID_OPTIONS");
expect((err.context as Record<string, unknown>).field).toBe("theme");
});
});

View File

@@ -0,0 +1,45 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen `OPENAPI_UI_SWAGGER.*` error-code registry for the Swagger UI
* adapter. Each code is raised by a production path in this package:
*
* - `ASSETS_MISSING` — handler construction was asked to resolve a
* bundled asset that has not been registered with the assets map.
* Triggered when `uiAssets = "bundled"` is selected against an asset
* name the package does not embed. The package refuses to silently
* fall back to the CDN — operators wanting CDN behaviour must opt in
* explicitly.
* - `INVALID_OPTIONS` — the caller-supplied `SwaggerUiOptions` did not
* pass the Zod schema (unknown keys, wrong type, theme literal not in
* the allowed set, etc.). Raised by `parseSwaggerUiOptions` so misuse
* surfaces at boot time, never at first request.
*/
export const OPENAPI_UI_SWAGGER_CODES = defineCodes("OPENAPI_UI_SWAGGER", [
"ASSETS_MISSING",
"INVALID_OPTIONS",
] as const);
/** Union of every code in {@link OPENAPI_UI_SWAGGER_CODES}. */
export type OpenapiUiSwaggerCode =
(typeof OPENAPI_UI_SWAGGER_CODES)[keyof typeof OPENAPI_UI_SWAGGER_CODES];
/**
* Concrete error class for the api-openapi-ui-swagger package. Extends
* {@link SuaveplanError} so consumers can discriminate via
* `error instanceof OpenapiUiSwaggerError` or by stable `error.code`.
*/
export class OpenapiUiSwaggerError extends SuaveplanError<OpenapiUiSwaggerCode> {
public readonly swaggerUiCode: OpenapiUiSwaggerCode;
public constructor(
code: OpenapiUiSwaggerCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "OpenapiUiSwaggerError";
this.swaggerUiCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
OPENAPI_UI_SWAGGER_CODES,
type OpenapiUiSwaggerCode,
OpenapiUiSwaggerError,
} from "./error-codes.js";

View File

@@ -0,0 +1,67 @@
# `handler.ts` — `createSwaggerUiHandler` factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes the primary entry point of `@suaveplan/api-openapi-ui-swagger`: a factory that returns an HTTP handler serving the Swagger UI HTML shell. The factory composes three earlier modules — options validation, asset resolution, and HTML rendering — into a single call site that the host's router can mount under `/docs/ui/swagger`.
Construction is eager: validation, asset resolution, and shell rendering all happen at factory-call time. The returned handler is therefore an O(1) lookup that returns a pre-baked response. That puts all the failure modes (`INVALID_OPTIONS`, `ASSETS_MISSING`) at boot, where operators see them, never at first request.
## Features
- `createSwaggerUiHandler({ openapiUrl, ...options })` — returns a `SwaggerUiHandler`.
- `createSwaggerUiHandlerArtifacts(...)` — handler plus parsed options for hosts that want introspection.
- Strict response headers: `Content-Type: text/html; charset=utf-8`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`.
- Pure handler — identical bytes returned on every call (no per-request work).
- Eager validation at boot time.
## Usage: Basic
```ts
import { createSwaggerUiHandler } from "@suaveplan/api-openapi-ui-swagger/handler";
const handler = createSwaggerUiHandler({ openapiUrl: "/openapi.json" });
const response = handler();
// response.status === 200; response.body is the rendered HTML
```
## Usage: Advanced
```ts
import { createSwaggerUiHandlerArtifacts } from "@suaveplan/api-openapi-ui-swagger/handler";
const { handler, options } = createSwaggerUiHandlerArtifacts({
openapiUrl: "/openapi.json",
title: "Acme — Customer API",
theme: "dark",
tryItOutEnabled: false,
uiAssets: "bundled",
assetBasePath: "/docs/ui/swagger/assets",
});
// `options` is the parsed, defaults-applied record; host can log it.
console.log("[swagger-ui] mounted", options.title, options.uiAssets);
// `handler` is a pure function — call it from every route adapter.
const res = handler();
void res;
```
## API Reference
- `interface CreateSwaggerUiHandlerInput extends SwaggerUiOptionsInput { openapiUrl: string; assetBasePath?: string }`.
- `interface SwaggerUiResponse { status; headers; body }`.
- `type SwaggerUiHandler = () => SwaggerUiResponse`.
- `createSwaggerUiHandler(input): SwaggerUiHandler`.
- `createSwaggerUiHandlerArtifacts(input): { handler; options; openapiUrl }`.
## Implementation Notes
The factory is eager by design. The alternative — lazy validation per request — would let operators ship misconfigured handlers that look healthy until a doc-fetch arrives. The package picks reliability over latency: a single render per handler at boot.
`assetBasePath` is required when `uiAssets = "bundled"`; omitting it raises `OPENAPI_UI_SWAGGER.ASSETS_MISSING` via `resolveAssets`. The factory does not infer a base path because the host owns the asset-route mount point — guessing would create cross-host inconsistency.
The returned `SwaggerUiResponse` is an adapter-agnostic record. Hosts running on Hono call `c.html(body, status, headers)`; hosts on the in-memory router pass the record straight back; hosts on Node's `http` write `(status, headers, body)` directly. Avoiding `Response` keeps the package universal.

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "../error-codes/error-codes.js";
import {
createSwaggerUiHandler,
createSwaggerUiHandlerArtifacts,
} from "./handler.js";
describe("createSwaggerUiHandler", () => {
it("handler returns HTML referencing openapiUrl", { timeout: 5000 }, () => {
const handler = createSwaggerUiHandler({ openapiUrl: "/openapi.json" });
const res = handler();
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toBe("text/html; charset=utf-8");
expect(res.headers["X-Content-Type-Options"]).toBe("nosniff");
expect(res.headers["Referrer-Policy"]).toBe("no-referrer");
expect(res.body).toContain("/openapi.json");
expect(res.body).toContain("<!DOCTYPE html>");
});
it("tryItOutEnabled toggles Swagger config", { timeout: 5000 }, () => {
const onHandler = createSwaggerUiHandler({
openapiUrl: "/o",
tryItOutEnabled: true,
});
const offHandler = createSwaggerUiHandler({
openapiUrl: "/o",
tryItOutEnabled: false,
});
expect(onHandler().body).toContain('"tryItOutEnabled":true');
expect(offHandler().body).toContain('"tryItOutEnabled":false');
});
it("rejects invalid options at construction time", {
timeout: 5000,
}, () => {
expect(() =>
createSwaggerUiHandler({
openapiUrl: "/o",
theme: "midnight" as unknown as "dark",
})
).toThrow(OpenapiUiSwaggerError);
});
it("bundled mode without basePath raises ASSETS_MISSING", {
timeout: 5000,
}, () => {
expect(() =>
createSwaggerUiHandler({
openapiUrl: "/o",
uiAssets: "bundled",
})
).toThrow(OpenapiUiSwaggerError);
try {
createSwaggerUiHandler({
openapiUrl: "/o",
uiAssets: "bundled",
});
} catch (err) {
expect((err as OpenapiUiSwaggerError).swaggerUiCode).toBe(
OPENAPI_UI_SWAGGER_CODES.ASSETS_MISSING
);
}
});
it("bundled mode with basePath resolves to host paths", {
timeout: 5000,
}, () => {
const handler = createSwaggerUiHandler({
openapiUrl: "/o",
uiAssets: "bundled",
assetBasePath: "/docs/ui/swagger/assets",
});
const res = handler();
expect(res.body).toContain("/docs/ui/swagger/assets/swagger-ui.css");
expect(res.body).toContain(
"/docs/ui/swagger/assets/swagger-ui-bundle.js"
);
});
it("handler is idempotent across invocations", { timeout: 5000 }, () => {
const handler = createSwaggerUiHandler({ openapiUrl: "/o" });
const a = handler();
const b = handler();
expect(a.body).toBe(b.body);
expect(a.status).toBe(b.status);
});
it("CDN mode default does not require basePath", { timeout: 5000 }, () => {
const handler = createSwaggerUiHandler({ openapiUrl: "/o" });
const res = handler();
expect(res.body).toContain("cdn.jsdelivr.net/npm/swagger-ui-dist");
});
});
describe("createSwaggerUiHandlerArtifacts", () => {
it("returns handler and parsed options together", {
timeout: 5000,
}, () => {
const { handler, options, openapiUrl } =
createSwaggerUiHandlerArtifacts({
openapiUrl: "/o",
title: "X",
theme: "dark",
});
expect(handler().status).toBe(200);
expect(options.title).toBe("X");
expect(options.theme).toBe("dark");
expect(openapiUrl).toBe("/o");
});
it("artifacts variant also raises on bad options", {
timeout: 5000,
}, () => {
expect(() =>
createSwaggerUiHandlerArtifacts({
openapiUrl: "/o",
uiAssets: "bundled",
})
).toThrow(OpenapiUiSwaggerError);
});
});

View File

@@ -0,0 +1,96 @@
import { resolveAssets } from "../assets/assets.js";
import { renderShell } from "../html/render-shell.js";
import {
parseSwaggerUiOptions,
type SwaggerUiOptions,
type SwaggerUiOptionsInput,
} from "../options/options.js";
/**
* Adapter-agnostic HTTP response value returned by the Swagger UI
* handlers. The host's router converts this into a native `Response`
* (Hono, worker bridge, Node http.ServerResponse, etc.).
*/
export interface SwaggerUiResponse {
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: string;
}
/** Caller-facing factory input. */
export interface CreateSwaggerUiHandlerInput extends SwaggerUiOptionsInput {
/** Absolute URL or path to the host's OpenAPI document. */
readonly openapiUrl: string;
/**
* Asset-route base path. REQUIRED when `uiAssets = "bundled"`.
* Ignored in CDN mode.
*/
readonly assetBasePath?: string;
}
/**
* The handler signature: `() => SwaggerUiResponse`. Pure — does not
* inspect the incoming request because the response is identical for
* every GET against the docs endpoint. Hosts running per-tenant doc
* routes should construct one handler per tenant.
*/
export type SwaggerUiHandler = () => SwaggerUiResponse;
/** Expose the parsed options + asset resolution for host introspection. */
export interface SwaggerUiHandlerArtifacts {
readonly handler: SwaggerUiHandler;
readonly options: SwaggerUiOptions;
readonly openapiUrl: string;
}
function buildArtifacts(
input: CreateSwaggerUiHandlerInput
): SwaggerUiHandlerArtifacts {
const { openapiUrl, assetBasePath, ...rawOptions } = input;
const options: SwaggerUiOptions = parseSwaggerUiOptions(
rawOptions as SwaggerUiOptionsInput
);
const assets = resolveAssets(
assetBasePath === undefined
? { mode: options.uiAssets, version: options.version }
: {
mode: options.uiAssets,
version: options.version,
basePath: assetBasePath,
}
);
const html = renderShell({ openapiUrl, options, assets });
const handler: SwaggerUiHandler = () => ({
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
},
body: html,
});
return { handler, options, openapiUrl };
}
/**
* Construct a Swagger UI HTTP handler returning a fully rendered
* `text/html` response. Validation runs eagerly at construction time —
* bad options raise `OPENAPI_UI_SWAGGER.INVALID_OPTIONS`; missing
* bundled assets raise `OPENAPI_UI_SWAGGER.ASSETS_MISSING`.
*/
export function createSwaggerUiHandler(
input: CreateSwaggerUiHandlerInput
): SwaggerUiHandler {
return buildArtifacts(input).handler;
}
/**
* Variant returning the handler + parsed options together. Useful for
* hosts that need to know the resolved options for logging or for
* mounting the asset route under the right path.
*/
export function createSwaggerUiHandlerArtifacts(
input: CreateSwaggerUiHandlerInput
): SwaggerUiHandlerArtifacts {
return buildArtifacts(input);
}

View File

@@ -0,0 +1,8 @@
export {
type CreateSwaggerUiHandlerInput,
createSwaggerUiHandler,
createSwaggerUiHandlerArtifacts,
type SwaggerUiHandler,
type SwaggerUiHandlerArtifacts,
type SwaggerUiResponse,
} from "./handler.js";

View File

@@ -0,0 +1,6 @@
export {
buildSwaggerConfig,
escapeHtml,
renderShell,
type SwaggerUiConfig,
} from "./render-shell.js";

View File

@@ -0,0 +1,78 @@
# `render-shell.ts` — Swagger UI HTML shell renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module produces the deterministic HTML payload returned by `createSwaggerUiHandler`. It accepts the OpenAPI document URL, the parsed Swagger UI options, and a resolved asset descriptor, then renders the complete page — including a JSON configuration block consumed by the inline bootstrap script.
The shell is intentionally small. It loads `swagger-ui.css` and `swagger-ui-bundle.js` from the resolved hrefs (CDN or bundled), defines a single `<div id="swagger-ui">` mount point, and ships a bootstrap snippet that reads the JSON config block and calls `SwaggerUIBundle({...})`. Every caller-supplied string is HTML-escaped before reaching the page, and configuration values never touch raw JS source text — they ride a `<script type="application/json">` block that the bootstrap parses at runtime.
## Features
- Deterministic output — identical inputs always yield identical bytes.
- HTML escapes the document title, theme attribute, and both asset URLs.
- Embeds caller config via `<script type="application/json">` with `</script>` break-out protection (`<` → `<`).
- Wires `deepLinking`, `tryItOutEnabled`, `displayRequestDuration`, and `filter` straight into `SwaggerUIBundle`.
- Defensive bootstrap: bails silently if the bundle has not finished loading.
## Usage: Basic
```ts
import { renderShell } from "@suaveplan/api-openapi-ui-swagger/html";
import { parseSwaggerUiOptions } from "@suaveplan/api-openapi-ui-swagger/options";
import { resolveCdnAssets } from "@suaveplan/api-openapi-ui-swagger/assets";
const options = parseSwaggerUiOptions({});
const assets = resolveCdnAssets(options.version);
const html = renderShell({
openapiUrl: "/openapi.json",
options,
assets,
});
```
## Usage: Advanced
```ts
import {
buildSwaggerConfig,
escapeHtml,
renderShell,
} from "@suaveplan/api-openapi-ui-swagger/html";
import { resolveBundledAssets } from "@suaveplan/api-openapi-ui-swagger/assets";
import { parseSwaggerUiOptions } from "@suaveplan/api-openapi-ui-swagger/options";
const options = parseSwaggerUiOptions({
title: "Acme — Customer API",
theme: "dark",
tryItOutEnabled: false,
filter: true,
});
const assets = resolveBundledAssets("/docs/ui/swagger/assets");
const html = renderShell({
openapiUrl: "/openapi.json",
options,
assets,
});
// `buildSwaggerConfig` is exposed for callers that want to introspect
// the JSON payload before it reaches the shell — useful in tests.
const cfg = buildSwaggerConfig("/openapi.json", options);
console.log(escapeHtml(cfg.url));
```
## API Reference
- `escapeHtml(input: string): string` — five-character entity escape.
- `buildSwaggerConfig(openapiUrl, options): SwaggerUiConfig` — JSON payload threaded into the page.
- `renderShell({openapiUrl, options, assets}): string` — full HTML.
- `interface SwaggerUiConfig { url; dom_id; deepLinking; tryItOutEnabled; displayRequestDuration; filter; theme }`.
## Implementation Notes
The renderer is a pure function — it has no IO and no clock dependency. That makes it trivial to unit-test (input determinism is the test) and safe to call from a hot HTTP path (no per-request allocations beyond the returned string).
The JSON payload uses the unicode-escape trick (`<` → `<`) to prevent any caller-supplied string from terminating the `</script>` block. Without that protection a configurable URL or title containing `</script>` could break out of the JSON island and inject inline scripts. The escape applies to every `<` character; JSON's own syntax never produces a literal `<` so the substitution is value-preserving.

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { resolveBundledAssets, resolveCdnAssets } from "../assets/assets.js";
import { parseSwaggerUiOptions } from "../options/options.js";
import { buildSwaggerConfig, escapeHtml, renderShell } from "./render-shell.js";
describe("renderShell", () => {
it("emits a complete HTML document referencing the openapi url", {
timeout: 5000,
}, () => {
const options = parseSwaggerUiOptions({});
const assets = resolveCdnAssets(options.version);
const html = renderShell({
openapiUrl: "/openapi.json",
options,
assets,
});
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain('id="swagger-ui"');
expect(html).toContain(assets.cssHref);
expect(html).toContain(assets.jsHref);
// JSON config block contains the URL
expect(html).toContain('"url":"/openapi.json"');
});
it("escapes the title for safe interpolation", { timeout: 5000 }, () => {
const options = parseSwaggerUiOptions({
title: "<script>alert(1)</script>",
});
const assets = resolveCdnAssets(options.version);
const html = renderShell({
openapiUrl: "/openapi.json",
options,
assets,
});
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});
it("escapes < inside the JSON config block", { timeout: 5000 }, () => {
const options = parseSwaggerUiOptions({});
const assets = resolveCdnAssets(options.version);
const html = renderShell({
openapiUrl: "/path/with/</script>/inside",
options,
assets,
});
// The JSON island sits between `id="swagger-config" ... >` and the
// matching `</script>` closer. Within that slice the literal
// "</script>" must not appear — caller-supplied "<" is replaced
// with its unicode escape so no break-out is possible.
const islandOpen =
html.indexOf('id="swagger-config" type="application/json">') +
'id="swagger-config" type="application/json">'.length;
const islandClose = html.indexOf("</script>", islandOpen);
const island = html.slice(islandOpen, islandClose);
expect(island).not.toContain("</script>");
// The escaped form lives inside the JSON.
expect(island).toContain("\\u003c");
// The original caller-supplied URL with "<" is preserved in escaped form.
expect(island).toContain("\\u003c/script>");
});
it("tryItOutEnabled toggles in the rendered JSON config", {
timeout: 5000,
}, () => {
const opts1 = parseSwaggerUiOptions({ tryItOutEnabled: true });
const opts2 = parseSwaggerUiOptions({ tryItOutEnabled: false });
const assets = resolveCdnAssets(opts1.version);
const html1 = renderShell({
openapiUrl: "/o",
options: opts1,
assets,
});
const html2 = renderShell({
openapiUrl: "/o",
options: opts2,
assets,
});
expect(html1).toContain('"tryItOutEnabled":true');
expect(html2).toContain('"tryItOutEnabled":false');
});
it("bundled mode injects the host-served paths", { timeout: 5000 }, () => {
const options = parseSwaggerUiOptions({ uiAssets: "bundled" });
const assets = resolveBundledAssets("/docs/ui/swagger/assets");
const html = renderShell({
openapiUrl: "/openapi.json",
options,
assets,
});
expect(html).toContain("/docs/ui/swagger/assets/swagger-ui.css");
expect(html).toContain("/docs/ui/swagger/assets/swagger-ui-bundle.js");
});
});
describe("buildSwaggerConfig", () => {
it("mirrors every option onto the config payload", {
timeout: 5000,
}, () => {
const options = parseSwaggerUiOptions({
tryItOutEnabled: false,
deepLinking: false,
displayRequestDuration: true,
filter: true,
theme: "dark",
});
const cfg = buildSwaggerConfig("/x", options);
expect(cfg).toEqual({
url: "/x",
dom_id: "#swagger-ui",
deepLinking: false,
tryItOutEnabled: false,
displayRequestDuration: true,
filter: true,
theme: "dark",
});
});
});
describe("escapeHtml", () => {
it("escapes the five HTML-special characters", { timeout: 5000 }, () => {
expect(escapeHtml(`&<>"'`)).toBe("&amp;&lt;&gt;&quot;&#39;");
});
it("returns identical strings unchanged", { timeout: 5000 }, () => {
expect(escapeHtml("plain")).toBe("plain");
});
});

View File

@@ -0,0 +1,105 @@
import type { ResolvedAssets } from "../assets/assets.js";
import type { SwaggerUiOptions } from "../options/options.js";
/**
* Escape five HTML-significant characters for safe interpolation into
* attribute or text contexts. The Swagger UI shell embeds the
* caller-supplied title and the OpenAPI document URL into the page; both
* paths run through this helper before reaching the DOM.
*/
export function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* The configuration block forwarded to `SwaggerUIBundle({...})`. The
* rendered HTML page contains a single `<script type="application/json"
* id="swagger-config">` element with this payload; the bootstrap snippet
* reads it back via `JSON.parse(document.getElementById(...).textContent)`
* so caller values never reach the JS source text.
*/
export interface SwaggerUiConfig {
readonly url: string;
readonly dom_id: string;
readonly deepLinking: boolean;
readonly tryItOutEnabled: boolean;
readonly displayRequestDuration: boolean;
readonly filter: boolean;
readonly theme: SwaggerUiOptions["theme"];
}
/** Build the JSON config blob threaded into the page. */
export function buildSwaggerConfig(
openapiUrl: string,
options: SwaggerUiOptions
): SwaggerUiConfig {
return {
url: openapiUrl,
dom_id: "#swagger-ui",
deepLinking: options.deepLinking,
tryItOutEnabled: options.tryItOutEnabled,
displayRequestDuration: options.displayRequestDuration,
filter: options.filter,
theme: options.theme,
};
}
/**
* Render the Swagger UI HTML shell.
*
* The shell loads the upstream `swagger-ui-dist` runtime from the
* resolved CSS / JS hrefs and bootstraps `SwaggerUIBundle` with the
* caller-validated configuration. Configuration values reach the page
* through a JSON script-block (not inline JS), so HTML escaping is
* scoped to the title and asset hrefs.
*/
export function renderShell(input: {
readonly openapiUrl: string;
readonly options: SwaggerUiOptions;
readonly assets: ResolvedAssets;
}): string {
const { openapiUrl, options, assets } = input;
const config = buildSwaggerConfig(openapiUrl, options);
// Inside <script type="application/json"> the literal "</script>"
// is the only sequence that can terminate the block. Replacing every
// "<" with its unicode escape guarantees no caller-supplied string
// can break out.
const configJson = JSON.stringify(config).replace(/</g, "\\u003c");
return `<!DOCTYPE html>
<html lang="en" data-theme="${escapeHtml(options.theme)}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(options.title)}</title>
<link rel="stylesheet" href="${escapeHtml(assets.cssHref)}" />
</head>
<body data-theme="${escapeHtml(options.theme)}">
<div id="swagger-ui"></div>
<script id="swagger-config" type="application/json">${configJson}</script>
<script src="${escapeHtml(assets.jsHref)}" crossorigin="anonymous"></script>
<script>
(function(){
var node = document.getElementById("swagger-config");
if (node === null) return;
var cfg;
try { cfg = JSON.parse(node.textContent || "{}"); } catch (e) { cfg = {}; }
if (typeof window.SwaggerUIBundle !== "function") return;
window.ui = window.SwaggerUIBundle({
url: cfg.url,
dom_id: cfg.dom_id,
deepLinking: cfg.deepLinking,
tryItOutEnabled: cfg.tryItOutEnabled,
displayRequestDuration: cfg.displayRequestDuration,
filter: cfg.filter
});
})();
</script>
</body>
</html>
`;
}

View File

@@ -0,0 +1,47 @@
// Side-effect import — surfaces the OPENAPI_UI_SWAGGER error-code
// registry to consumers via `import "@suaveplan/api-openapi-ui-swagger"`.
import "./error-codes/error-codes.js";
// Assets
export {
listBundledAssetNames,
type ResolvedAssets,
readBundledAsset,
resolveAssets,
resolveBundledAssets,
resolveCdnAssets,
} from "./assets/assets.js";
// Error codes
export {
OPENAPI_UI_SWAGGER_CODES,
type OpenapiUiSwaggerCode,
OpenapiUiSwaggerError,
} from "./error-codes/error-codes.js";
// Handler
export {
type CreateSwaggerUiHandlerInput,
createSwaggerUiHandler,
createSwaggerUiHandlerArtifacts,
type SwaggerUiHandler,
type SwaggerUiHandlerArtifacts,
type SwaggerUiResponse,
} from "./handler/handler.js";
// HTML
export {
buildSwaggerConfig,
escapeHtml,
renderShell,
type SwaggerUiConfig,
} from "./html/render-shell.js";
// Options
export {
parseSwaggerUiOptions,
type SwaggerUiOptions,
type SwaggerUiOptionsInput,
swaggerUiOptionsSchema,
} from "./options/options.js";
// Per-plugin
export {
type CreatePerPluginSwaggerUiHandlerInput,
createPerPluginSwaggerUiHandler,
} from "./per-plugin/per-plugin.js";

View File

@@ -0,0 +1,6 @@
export {
parseSwaggerUiOptions,
type SwaggerUiOptions,
type SwaggerUiOptionsInput,
swaggerUiOptionsSchema,
} from "./options.js";

View File

@@ -0,0 +1,69 @@
# `options.ts` — `SwaggerUiOptions` Zod schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the operator-facing options surface for `@suaveplan/api-openapi-ui-swagger`. It defines `swaggerUiOptionsSchema` (a strict Zod object with defaults baked in) and `parseSwaggerUiOptions` which converts caller input into a fully resolved `SwaggerUiOptions` record or raises `OPENAPI_UI_SWAGGER.INVALID_OPTIONS` if the input is structurally invalid.
Validating at the configuration boundary is non-negotiable: Swagger UI accepts dozens of toggles, every one of which is rendered into the page's bootstrap JavaScript. Letting unchecked input flow into the shell would risk both broken pages (silent type coercions) and HTML-injection vectors. By forcing every field through the schema before render, the package guarantees that the values reaching the HTML emitter are already typed and validated.
## Features
- `title`, `tryItOutEnabled`, `deepLinking`, `displayRequestDuration`, `filter`, `theme` — the user-visible toggles.
- `uiAssets` — `"cdn"` or `"bundled"` chooses asset resolution strategy.
- `version` — pins the `swagger-ui-dist` version embedded in CDN URLs (default `5.17.14`).
- `.strict()` — unknown keys raise INVALID_OPTIONS (typo guard).
- Theme literal accepts only `light` / `dark` / `system`.
- Defaults: `tryItOutEnabled: true`, `deepLinking: true`, `displayRequestDuration: false`, `filter: false`, `theme: "system"`, `uiAssets: "cdn"`.
## Usage: Basic
```ts
import { parseSwaggerUiOptions } from "@suaveplan/api-openapi-ui-swagger/options";
const options = parseSwaggerUiOptions({ tryItOutEnabled: false });
// options.theme === "system" (default), tryItOutEnabled === false
```
## Usage: Advanced
```ts
import {
parseSwaggerUiOptions,
type SwaggerUiOptionsInput,
} from "@suaveplan/api-openapi-ui-swagger/options";
import { OpenapiUiSwaggerError } from "@suaveplan/api-openapi-ui-swagger/error-codes";
function bootSafe(input: SwaggerUiOptionsInput): void {
try {
const opts = parseSwaggerUiOptions(input);
// proceed with opts...
void opts;
} catch (err) {
if (err instanceof OpenapiUiSwaggerError) {
// Operator-facing log; do not bubble Zod internals to consumers.
console.error("[swagger-ui] invalid options", err.context);
throw err;
}
throw err;
}
}
bootSafe({ theme: "dark", filter: true, uiAssets: "bundled" });
```
## API Reference
- `swaggerUiOptionsSchema: z.ZodObject` — strict, with defaults.
- `parseSwaggerUiOptions(input?: SwaggerUiOptionsInput): SwaggerUiOptions` — throws `OpenapiUiSwaggerError` on failure.
- `type SwaggerUiOptions = z.infer<typeof swaggerUiOptionsSchema>` — resolved output.
- `type SwaggerUiOptionsInput = z.input<typeof swaggerUiOptionsSchema>` — caller input.
## Implementation Notes
The schema uses `z.union([z.literal(...), ...])` for closed enumerations rather than `z.enum` so the literal types survive Zod's transform pipeline. `parseSwaggerUiOptions` uses `safeParse` so the failure path can wrap the Zod issue list inside the `OpenapiUiSwaggerError.context.issues` slot — that preserves the original `cause` chain for downstream tooling that walks `error.cause`.
Adding a new toggle: extend the schema with a default, add a sentence to this doc's Features list, add scenario coverage in the spec, and add a `**Verified by:**` line pointing at the test that asserts the toggle reaches the rendered HTML.

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "../error-codes/error-codes.js";
import { parseSwaggerUiOptions, swaggerUiOptionsSchema } from "./options.js";
describe("parseSwaggerUiOptions", () => {
it("applies defaults when input is omitted", { timeout: 5000 }, () => {
const opts = parseSwaggerUiOptions(undefined);
expect(opts.title).toBe("Swagger UI");
expect(opts.tryItOutEnabled).toBe(true);
expect(opts.deepLinking).toBe(true);
expect(opts.displayRequestDuration).toBe(false);
expect(opts.filter).toBe(false);
expect(opts.theme).toBe("system");
expect(opts.uiAssets).toBe("cdn");
expect(opts.version).toBe("5.17.14");
});
it("accepts every documented field", { timeout: 5000 }, () => {
const opts = parseSwaggerUiOptions({
title: "API",
tryItOutEnabled: false,
deepLinking: false,
displayRequestDuration: true,
filter: true,
theme: "dark",
uiAssets: "bundled",
version: "5.20.0",
});
expect(opts.title).toBe("API");
expect(opts.tryItOutEnabled).toBe(false);
expect(opts.deepLinking).toBe(false);
expect(opts.displayRequestDuration).toBe(true);
expect(opts.filter).toBe(true);
expect(opts.theme).toBe("dark");
expect(opts.uiAssets).toBe("bundled");
expect(opts.version).toBe("5.20.0");
});
it("invalid theme rejected by schema", { timeout: 5000 }, () => {
expect(() =>
parseSwaggerUiOptions({
theme: "midnight" as unknown as "dark",
})
).toThrow(OpenapiUiSwaggerError);
try {
parseSwaggerUiOptions({ theme: "midnight" as unknown as "dark" });
} catch (err) {
expect(err).toBeInstanceOf(OpenapiUiSwaggerError);
const swaggerErr = err as OpenapiUiSwaggerError;
expect(swaggerErr.swaggerUiCode).toBe(
OPENAPI_UI_SWAGGER_CODES.INVALID_OPTIONS
);
expect(
Array.isArray(
(swaggerErr.context as Record<string, unknown>).issues
)
).toBe(true);
}
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
expect(() =>
parseSwaggerUiOptions({
tryItOut: false,
} as unknown as Parameters<typeof parseSwaggerUiOptions>[0])
).toThrow(OpenapiUiSwaggerError);
});
it("rejects wrong types", { timeout: 5000 }, () => {
expect(() =>
parseSwaggerUiOptions({
tryItOutEnabled: "yes",
} as unknown as Parameters<typeof parseSwaggerUiOptions>[0])
).toThrow(OpenapiUiSwaggerError);
});
it("schema is strict (introspection)", { timeout: 5000 }, () => {
// .parse is the canonical entrypoint — surface coverage is via the
// explicit safeParse path inside parseSwaggerUiOptions.
const parsed = swaggerUiOptionsSchema.parse({});
expect(parsed.theme).toBe("system");
});
});

View File

@@ -0,0 +1,62 @@
import z from "zod";
import {
OPENAPI_UI_SWAGGER_CODES,
OpenapiUiSwaggerError,
} from "../error-codes/error-codes.js";
/**
* Zod schema for the operator-facing Swagger UI options surface.
*
* Every field is optional with a default; `parseSwaggerUiOptions` applies
* the defaults so a no-argument call boots a sensible UI. Unknown keys
* are rejected (`.strict()`) so a typo (`tryItOut: false` instead of
* `tryItOutEnabled: false`) raises `OPENAPI_UI_SWAGGER.INVALID_OPTIONS`
* at boot rather than silently being ignored.
*/
export const swaggerUiOptionsSchema = z
.object({
title: z.string().default("Swagger UI"),
tryItOutEnabled: z.boolean().default(true),
deepLinking: z.boolean().default(true),
displayRequestDuration: z.boolean().default(false),
filter: z.boolean().default(false),
theme: z
.union([z.literal("light"), z.literal("dark"), z.literal("system")])
.default("system"),
uiAssets: z
.union([z.literal("cdn"), z.literal("bundled")])
.default("cdn"),
version: z.string().default("5.17.14"),
})
.strict();
/** Resolved post-parse options. Every default applied. */
export type SwaggerUiOptions = z.infer<typeof swaggerUiOptionsSchema>;
/** Caller-facing input. Every field optional. */
export type SwaggerUiOptionsInput = z.input<typeof swaggerUiOptionsSchema>;
/**
* Parse + validate caller input. On Zod failure raises an
* {@link OpenapiUiSwaggerError} carrying
* `OPENAPI_UI_SWAGGER.INVALID_OPTIONS` with the Zod issue list on the
* `context.issues` slot.
*/
export function parseSwaggerUiOptions(
input: SwaggerUiOptionsInput | undefined
): SwaggerUiOptions {
const result = swaggerUiOptionsSchema.safeParse(input ?? {});
if (!result.success) {
throw new OpenapiUiSwaggerError(
OPENAPI_UI_SWAGGER_CODES.INVALID_OPTIONS,
"Invalid SwaggerUiOptions — see context.issues for details",
{
cause: result.error,
context: {
issues: result.error.issues,
},
}
);
}
return result.data;
}

View File

@@ -0,0 +1,4 @@
export {
type CreatePerPluginSwaggerUiHandlerInput,
createPerPluginSwaggerUiHandler,
} from "./per-plugin.js";

View File

@@ -0,0 +1,63 @@
# `per-plugin.ts` — `createPerPluginSwaggerUiHandler`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes the per-plugin variant of the Swagger UI handler. The host's `/docs/{plugin}` route serves a UI scoped to a single plugin's routes, and that view points at a plugin-filtered OpenAPI document (typically `?plugin=<id>` or `/openapi/<id>.json`). The factory takes a derivation callback (`openapiUrlForPlugin`) so the host owns the URL-shape convention.
The factory is a thin composition over `createSwaggerUiHandler`: it derives the document URL by calling `openapiUrlForPlugin(plugin)`, then forwards every other option unchanged. All validation, asset resolution, and HTML rendering happen inside the underlying handler factory — this module never duplicates those concerns.
## Features
- `createPerPluginSwaggerUiHandler({ openapiUrlForPlugin, plugin, ...options })` — returns a `SwaggerUiHandler` pointing at the filtered URL.
- Forwards every `SwaggerUiOptionsInput` field to the underlying handler.
- Same eager validation contract — bad options throw at construction.
- Same asset-resolution contract — bundled mode without basePath throws ASSETS_MISSING.
## Usage: Basic
```ts
import { createPerPluginSwaggerUiHandler } from "@suaveplan/api-openapi-ui-swagger/per-plugin";
const handler = createPerPluginSwaggerUiHandler({
openapiUrlForPlugin: (p) => `/openapi.json?plugin=${encodeURIComponent(p)}`,
plugin: "billing",
});
const res = handler();
// res.body references /openapi.json?plugin=billing
```
## Usage: Advanced
```ts
import { createPerPluginSwaggerUiHandler } from "@suaveplan/api-openapi-ui-swagger/per-plugin";
const handlers = new Map(
["billing", "auth", "contacts"].map((plugin) => [
plugin,
createPerPluginSwaggerUiHandler({
openapiUrlForPlugin: (p) => `/openapi/${p}.json`,
plugin,
title: `${plugin} — API`,
theme: "dark",
tryItOutEnabled: false,
}),
])
);
// host.router mounts handlers.get(plugin) at /docs/<plugin>
```
## API Reference
- `interface CreatePerPluginSwaggerUiHandlerInput extends SwaggerUiOptionsInput { openapiUrlForPlugin: (plugin: string) => string; plugin: string; assetBasePath?: string }`.
- `createPerPluginSwaggerUiHandler(input): SwaggerUiHandler`.
## Implementation Notes
`openapiUrlForPlugin` is a function (not a template string) because hosts wire their OpenAPI emitter to a wide range of URL shapes — query-parameter filtering, path-based filtering, host-based multi-tenancy. A callback keeps the package out of that decision.
The handler returned here is identical in shape to `createSwaggerUiHandler`'s output, so the host's route adapter does not need a per-handler branch. Operators wanting a single UI mount at `/docs/ui/swagger/{plugin}` create one handler per plugin and dispatch by path segment.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OpenapiUiSwaggerError } from "../error-codes/error-codes.js";
import { createPerPluginSwaggerUiHandler } from "./per-plugin.js";
describe("createPerPluginSwaggerUiHandler", () => {
it("per-plugin handler embeds filtered URL", { timeout: 5000 }, () => {
const handler = createPerPluginSwaggerUiHandler({
openapiUrlForPlugin: (p) =>
`/openapi.json?plugin=${encodeURIComponent(p)}`,
plugin: "billing",
});
const res = handler();
expect(res.status).toBe(200);
expect(res.body).toContain("/openapi.json?plugin=billing");
});
it("path-style filtered URLs supported", { timeout: 5000 }, () => {
const handler = createPerPluginSwaggerUiHandler({
openapiUrlForPlugin: (p) => `/openapi/${p}.json`,
plugin: "auth",
});
const res = handler();
expect(res.body).toContain("/openapi/auth.json");
});
it("forwards options to the underlying handler", { timeout: 5000 }, () => {
const handler = createPerPluginSwaggerUiHandler({
openapiUrlForPlugin: (p) => `/openapi/${p}.json`,
plugin: "auth",
title: "Auth API",
theme: "dark",
tryItOutEnabled: false,
});
const body = handler().body;
expect(body).toContain("Auth API");
expect(body).toContain('"tryItOutEnabled":false');
});
it("rejects bad options at construction", { timeout: 5000 }, () => {
expect(() =>
createPerPluginSwaggerUiHandler({
openapiUrlForPlugin: (p) => `/openapi/${p}.json`,
plugin: "auth",
theme: "midnight" as unknown as "dark",
})
).toThrow(OpenapiUiSwaggerError);
});
});

View File

@@ -0,0 +1,35 @@
import {
createSwaggerUiHandler,
type SwaggerUiHandler,
} from "../handler/handler.js";
import type { SwaggerUiOptionsInput } from "../options/options.js";
/**
* Caller-facing input for the per-plugin handler. `openapiUrlForPlugin`
* is a function so the host can derive the filtered OpenAPI URL from a
* plugin identifier — for example `(p) => "/openapi.json?plugin=" + p`
* or `(p) => "/openapi/" + p + ".json"`.
*/
export interface CreatePerPluginSwaggerUiHandlerInput
extends SwaggerUiOptionsInput {
readonly openapiUrlForPlugin: (plugin: string) => string;
readonly plugin: string;
readonly assetBasePath?: string;
}
/**
* Construct a Swagger UI handler whose shell points at the OpenAPI
* document filtered down to a single plugin's routes. The mechanics
* are identical to {@link createSwaggerUiHandler}; only the document
* URL changes.
*/
export function createPerPluginSwaggerUiHandler(
input: CreatePerPluginSwaggerUiHandlerInput
): SwaggerUiHandler {
const { openapiUrlForPlugin, plugin, ...rest } = input;
const openapiUrl = openapiUrlForPlugin(plugin);
return createSwaggerUiHandler({
...(rest as SwaggerUiOptionsInput),
openapiUrl,
});
}

View File

@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("@suaveplan/* runtime deps are exactly error + types", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual(["@suaveplan/error"]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(["@suaveplan/error", "tslib", "zod"]);
});
it("package metadata is consistent", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
name: string;
version: string;
type: string;
sideEffects: boolean;
};
expect(pkg.name).toBe("@suaveplan/api-openapi-ui-swagger");
expect(pkg.version).toBe("0.1.0");
expect(pkg.type).toBe("module");
expect(pkg.sideEffects).toBe(false);
});
});

View 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"]
}

View 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"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-openapi-ui-swagger",
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\//, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
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,
},
});