Initial Commit
This commit is contained in:
83
packages/api-stack/api/api-openapi-ui-redoc/README.md
Normal file
83
packages/api-stack/api/api-openapi-ui-redoc/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# @suaveplan/api-openapi-ui-redoc — ReDoc UI adapter for the Suaveplan api-host. Mounts the ReDoc HTML shell at /docs/ui/redoc (or any caller-chosen path) via createRedocUiHandler({ openapiUrl, title, theme, expandResponses }) and at /docs/{plugin}/ui/redoc via createPerPluginRedocUiHandler. Validates RedocUiOptions through a strict Zod schema, resolves the ReDoc runtime bundle from a cdn / bundled / inline strategy, embeds the configured options into the shell as a JSON island, and raises the OPENAPI_UI_REDOC_* error-code family on collision, missing asset, or invalid options. Structural sibling of api-graphql-ui-altair and api-graphql-ui-graphiql.
|
||||
|
||||
Stage-8 ReDoc UI adapter for the Suaveplan api-host. Mounts the ReDoc HTML
|
||||
shell at `/docs/ui/redoc` (or any caller-chosen path) via
|
||||
`createRedocUiHandler` and at `/docs/{plugin}/ui/redoc` via
|
||||
`createPerPluginRedocUiHandler`. Validates options through a strict Zod
|
||||
schema, resolves the runtime bundle from a cdn / bundled / inline strategy,
|
||||
and raises the `OPENAPI_UI_REDOC_*` error-code family on missing assets or
|
||||
invalid options.
|
||||
|
||||
Structural sibling of `@suaveplan/api-graphql-ui-altair` and
|
||||
`@suaveplan/api-graphql-ui-graphiql`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [assets](./src/assets/assets.md) — This module resolves the runtime asset URL the ReDoc HTML shell references. The package supports three asset strategies…
|
||||
- [assets](./src/assets/assets.md) — This module resolves the runtime asset URL the ReDoc HTML shell references. The package supports three asset strategies…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module defines the frozen error-code namespace shared by the ReDoc UI adapter. Every fault the package raises carr…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module defines the frozen error-code namespace shared by the ReDoc UI adapter. Every fault the package raises carr…
|
||||
- [handler](./src/handler/handler.md) — This module exposes `createRedocUiHandler`, the factory the api-host calls to obtain the HTTP handler serving the ReDoc…
|
||||
- [handler](./src/handler/handler.md) — This module exposes `createRedocUiHandler`, the factory the api-host calls to obtain the HTTP handler serving the ReDoc…
|
||||
- [html](./src/html/render-shell.md) — This module exposes `renderRedocShell`, the pure function that produces the ReDoc HTML shell consumed by both the globa…
|
||||
- [render-shell](./src/html/render-shell.md) — This module exposes `renderRedocShell`, the pure function that produces the ReDoc HTML shell consumed by both the globa…
|
||||
- [options](./src/options/options.md) — This module defines the Zod schema and parser for `RedocUiOptions`, the configuration bundle the ReDoc UI handler accep…
|
||||
- [options](./src/options/options.md) — This module defines the Zod schema and parser for `RedocUiOptions`, the configuration bundle the ReDoc UI handler accep…
|
||||
- [per-plugin](./src/per-plugin/per-plugin.md) — This module exposes `createPerPluginRedocUiHandler`, the sibling of `createRedocUiHandler` for hosts that mount per-plu…
|
||||
- [per-plugin](./src/per-plugin/per-plugin.md) — This module exposes `createPerPluginRedocUiHandler`, the sibling of `createRedocUiHandler` for hosts that mount per-plu…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-openapi-ui-redoc
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createRedocUiHandler } from "@suaveplan/api-openapi-ui-redoc";
|
||||
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
title: "Acme API",
|
||||
theme: "dark",
|
||||
expandResponses: "200,201",
|
||||
});
|
||||
|
||||
const res = handler();
|
||||
// res.body is the rendered HTML shell, ready to send.
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Subpath | Description |
|
||||
| ---------------------------- | --------------------------------------------- | ----------- |
|
||||
| `handler/handler.ts` | `@suaveplan/api-openapi-ui-redoc/handler` | `createRedocUiHandler` + `RedocHttpResponse` |
|
||||
| `per-plugin/per-plugin.ts` | `@suaveplan/api-openapi-ui-redoc/per-plugin` | `createPerPluginRedocUiHandler` |
|
||||
| `options/options.ts` | `@suaveplan/api-openapi-ui-redoc/options` | `RedocUiOptions` Zod schema + parser |
|
||||
| `assets/assets.ts` | `@suaveplan/api-openapi-ui-redoc/assets` | cdn / bundled / inline strategy resolver |
|
||||
| `html/render-shell.ts` | `@suaveplan/api-openapi-ui-redoc/html` | Pure HTML shell renderer |
|
||||
| `error-codes/error-codes.ts` | `@suaveplan/api-openapi-ui-redoc/error-codes` | `OPENAPI_UI_REDOC_*` registry |
|
||||
|
||||
## Asset strategies
|
||||
|
||||
- **`cdn`** (default) — point the shell at the canonical Redocly CDN URL
|
||||
(`https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js`). Zero-byte
|
||||
deploy; one network round-trip per first-paint.
|
||||
- **`bundled`** — operators supply the runtime bytes; the package serves
|
||||
them from a cache-busted local URL.
|
||||
- **`inline`** — embed the runtime body directly into the HTML shell. One
|
||||
network round-trip total, at the cost of a larger first-paint payload.
|
||||
|
||||
## Error codes
|
||||
|
||||
- `OPENAPI_UI_REDOC_ASSETS_MISSING` — `bundled` or `inline` mode requested
|
||||
without a payload.
|
||||
- `OPENAPI_UI_REDOC_INVALID_OPTIONS` — Zod rejected the option bundle.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
84
packages/api-stack/api/api-openapi-ui-redoc/package.json
Normal file
84
packages/api-stack/api/api-openapi-ui-redoc/package.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "@suaveplan/api-openapi-ui-redoc",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-8 ReDoc UI adapter for the Suaveplan api-host. Mounts the ReDoc HTML shell at /docs/ui/redoc (or any caller-chosen path) via createRedocUiHandler({ openapiUrl, title, theme, expandResponses }) and at /docs/{plugin}/ui/redoc via createPerPluginRedocUiHandler. Validates RedocUiOptions through a strict Zod schema, resolves the ReDoc runtime bundle from a cdn / bundled / inline strategy, embeds the configured options into the shell as a JSON island, and raises the OPENAPI_UI_REDOC_* error-code family on collision, missing asset, or invalid options. Structural sibling of api-graphql-ui-altair and api-graphql-ui-graphiql.",
|
||||
"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/handler.d.ts",
|
||||
"default": "./dist/handler/handler.js"
|
||||
},
|
||||
"./per-plugin": {
|
||||
"types": "./dist/per-plugin/per-plugin.d.ts",
|
||||
"default": "./dist/per-plugin/per-plugin.js"
|
||||
},
|
||||
"./options": {
|
||||
"types": "./dist/options/options.d.ts",
|
||||
"default": "./dist/options/options.js"
|
||||
},
|
||||
"./assets": {
|
||||
"types": "./dist/assets/assets.d.ts",
|
||||
"default": "./dist/assets/assets.js"
|
||||
},
|
||||
"./html": {
|
||||
"types": "./dist/html/render-shell.d.ts",
|
||||
"default": "./dist/html/render-shell.js"
|
||||
},
|
||||
"./error-codes": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# `assets` — ReDoc runtime asset strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module resolves the runtime asset URL the ReDoc HTML shell references.
|
||||
The package supports three asset strategies — `cdn`, `bundled`, and `inline` —
|
||||
so operators can choose between zero-byte deploys (CDN), air-gapped
|
||||
deployments (bundled), and single-request first-paint (inline) without
|
||||
changing the handler call site. The resolver returns a typed
|
||||
`ResolvedRedocAsset` whose `url` slots straight into the shell renderer.
|
||||
|
||||
## Features
|
||||
|
||||
- `REDOC_CDN_URL` — the canonical Redocly CDN URL for the `redoc.standalone.js`
|
||||
bundle.
|
||||
- `defineRedocAsset(name, contentType, body)` builds a bundled-asset record
|
||||
with a SHA-256-prefix cache-bust hash derived from the payload bytes.
|
||||
- `resolveRedocAsset({ strategy, bundled, inline })` returns the asset
|
||||
descriptor for the chosen strategy. Missing payloads raise
|
||||
`OPENAPI_UI_REDOC_ASSETS_MISSING`.
|
||||
- Bundled URLs are cache-busted (`./assets/<hash>/<name>`) so re-deploys with
|
||||
unchanged payloads keep their CDN warm-cache entries.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveRedocAsset, REDOC_CDN_URL } from "@suaveplan/api-openapi-ui-redoc/assets";
|
||||
|
||||
const cdn = resolveRedocAsset({ strategy: "cdn" });
|
||||
console.log(cdn.url === REDOC_CDN_URL); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Ship the runtime alongside the host binary for air-gapped operation:
|
||||
|
||||
```ts
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
defineRedocAsset,
|
||||
resolveRedocAsset,
|
||||
} from "@suaveplan/api-openapi-ui-redoc/assets";
|
||||
|
||||
const bundle = readFileSync("./vendor/redoc.standalone.js", "utf8");
|
||||
const asset = defineRedocAsset(
|
||||
"redoc.standalone.js",
|
||||
"application/javascript; charset=utf-8",
|
||||
bundle
|
||||
);
|
||||
|
||||
const resolved = resolveRedocAsset({ strategy: "bundled", bundled: asset });
|
||||
// resolved.url === `./assets/${asset.hash}/redoc.standalone.js`
|
||||
```
|
||||
|
||||
The host then wires a `GET ./assets/:hash/:name` route that serves the
|
||||
asset's `body` with its `contentType` and `Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------- | ------ | ----------- |
|
||||
| `REDOC_CDN_URL` | const | Upstream Redocly CDN URL |
|
||||
| `REDOC_BUNDLED_URL` | const | Conventional relative URL for bundled mode |
|
||||
| `RedocAssetStrategy` | type | `"cdn" \| "bundled" \| "inline"` |
|
||||
| `BundledRedocAsset` | type | `{ name, contentType, body, hash }` |
|
||||
| `ResolvedRedocAsset` | type | `{ strategy, url, inlineBody? }` |
|
||||
| `defineRedocAsset` | fn | Build a bundled-asset record with a content-derived cache-bust hash |
|
||||
| `resolveRedocAsset` | fn | Resolve `{ strategy, bundled?, inline? }` → `ResolvedRedocAsset` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineRedocAsset` hashes the payload via `node:crypto` SHA-256 and keeps the
|
||||
first 16 hex characters — collision-resistant for cache-busting and short
|
||||
enough to keep URLs readable. The `inline` strategy returns a sentinel
|
||||
`data:inline` URL plus the body itself; the shell renderer detects this
|
||||
sentinel and inlines a `<script>` tag rather than emitting a `<script src=...>`.
|
||||
This avoids leaking the literal payload into a query string.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OpenapiUiRedocError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
defineRedocAsset,
|
||||
REDOC_BUNDLED_URL,
|
||||
REDOC_CDN_URL,
|
||||
resolveRedocAsset,
|
||||
} from "./assets.js";
|
||||
|
||||
describe("resolveRedocAsset", () => {
|
||||
it("cdn mode returns CDN URL", { timeout: 5000 }, () => {
|
||||
const resolved = resolveRedocAsset({ strategy: "cdn" });
|
||||
expect(resolved.strategy).toBe("cdn");
|
||||
expect(resolved.url).toBe(REDOC_CDN_URL);
|
||||
expect(resolved.url).toContain("cdn.redoc.ly");
|
||||
});
|
||||
|
||||
it("bundled mode without asset raises ASSETS_MISSING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
resolveRedocAsset({ strategy: "bundled" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenapiUiRedocError);
|
||||
expect((err as OpenapiUiRedocError).redocCode).toBe(
|
||||
"OPENAPI_UI_REDOC.ASSETS_MISSING"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("bundled mode with asset returns cache-busted URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const asset = defineRedocAsset(
|
||||
"redoc.standalone.js",
|
||||
"application/javascript; charset=utf-8",
|
||||
"/* runtime */"
|
||||
);
|
||||
const resolved = resolveRedocAsset({
|
||||
strategy: "bundled",
|
||||
bundled: asset,
|
||||
});
|
||||
expect(resolved.strategy).toBe("bundled");
|
||||
expect(resolved.url).toBe(`./assets/${asset.hash}/redoc.standalone.js`);
|
||||
});
|
||||
|
||||
it("inline mode without body raises ASSETS_MISSING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => resolveRedocAsset({ strategy: "inline" })).toThrow(
|
||||
OpenapiUiRedocError
|
||||
);
|
||||
expect(() =>
|
||||
resolveRedocAsset({ strategy: "inline", inline: "" })
|
||||
).toThrow(OpenapiUiRedocError);
|
||||
});
|
||||
|
||||
it("inline mode with body returns marker URL + body", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const resolved = resolveRedocAsset({
|
||||
strategy: "inline",
|
||||
inline: "/* inline runtime */",
|
||||
});
|
||||
expect(resolved.strategy).toBe("inline");
|
||||
expect(resolved.url).toBe("data:inline");
|
||||
expect(resolved.inlineBody).toBe("/* inline runtime */");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineRedocAsset", () => {
|
||||
it("derives a stable cache-bust hash from the body", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = defineRedocAsset("r.js", "text/js", "abc");
|
||||
const b = defineRedocAsset("r.js", "text/js", "abc");
|
||||
const c = defineRedocAsset("r.js", "text/js", "abcd");
|
||||
expect(a.hash).toBe(b.hash);
|
||||
expect(a.hash).not.toBe(c.hash);
|
||||
});
|
||||
|
||||
it("supports Uint8Array bodies", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("/* binary-ish */");
|
||||
const a = defineRedocAsset("r.js", "text/js", bytes);
|
||||
expect(typeof a.hash).toBe("string");
|
||||
expect(a.hash.length).toBeGreaterThan(0);
|
||||
expect(a.body).toBe(bytes);
|
||||
});
|
||||
|
||||
it("exposes constants", { timeout: 5000 }, () => {
|
||||
expect(REDOC_CDN_URL).toContain("cdn.redoc.ly");
|
||||
expect(REDOC_BUNDLED_URL).toBe("./assets/redoc.standalone.js");
|
||||
});
|
||||
});
|
||||
117
packages/api-stack/api/api-openapi-ui-redoc/src/assets/assets.ts
Normal file
117
packages/api-stack/api/api-openapi-ui-redoc/src/assets/assets.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Upstream ReDoc CDN URL — the canonical
|
||||
* `redoc.standalone.js` build published by Redocly. Used when
|
||||
* `uiAssets = "cdn"` (the default).
|
||||
*/
|
||||
export const REDOC_CDN_URL =
|
||||
"https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" as const;
|
||||
|
||||
/**
|
||||
* Marker URL for the bundled ReDoc runtime — the request path the
|
||||
* HTML shell references when `uiAssets = "bundled"`. The host route
|
||||
* MUST be wired separately to serve the bundled payload at this URL.
|
||||
*/
|
||||
export const REDOC_BUNDLED_URL = "./assets/redoc.standalone.js" as const;
|
||||
|
||||
/** Asset-strategy literal — narrows the public surface. */
|
||||
export type RedocAssetStrategy = "cdn" | "bundled" | "inline";
|
||||
|
||||
/**
|
||||
* A bundled ReDoc runtime payload. Operators ship the official
|
||||
* `redoc.standalone.js` bytes via this contract.
|
||||
*/
|
||||
export interface BundledRedocAsset {
|
||||
readonly name: string;
|
||||
readonly contentType: string;
|
||||
readonly body: string | Uint8Array;
|
||||
readonly hash: string;
|
||||
}
|
||||
|
||||
/** Resolved asset descriptor returned by {@link resolveRedocAsset}. */
|
||||
export interface ResolvedRedocAsset {
|
||||
readonly strategy: RedocAssetStrategy;
|
||||
/** URL the HTML shell references for the runtime script. */
|
||||
readonly url: string;
|
||||
/** Optional inline body — present only when `strategy === "inline"`. */
|
||||
readonly inlineBody?: string;
|
||||
}
|
||||
|
||||
function sha256(input: string | Uint8Array): string {
|
||||
return createHash("sha256")
|
||||
.update(typeof input === "string" ? Buffer.from(input, "utf8") : input)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single bundled-asset entry. The cache-bust hash is derived
|
||||
* from the payload bytes so re-deploys with unchanged payloads keep
|
||||
* the same URL (good for CDN warm-caches) and changed payloads bust
|
||||
* the cache automatically.
|
||||
*/
|
||||
export function defineRedocAsset(
|
||||
name: string,
|
||||
contentType: string,
|
||||
body: string | Uint8Array
|
||||
): BundledRedocAsset {
|
||||
return Object.freeze({
|
||||
name,
|
||||
contentType,
|
||||
body,
|
||||
hash: sha256(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime asset URL given the chosen strategy and the
|
||||
* (optionally) supplied bundled / inline payloads.
|
||||
*
|
||||
* - `cdn` → returns {@link REDOC_CDN_URL}; payload args ignored.
|
||||
* - `bundled` → returns the cache-busted local URL; throws
|
||||
* `OPENAPI_UI_REDOC_ASSETS_MISSING` if `bundled` is `undefined`.
|
||||
* - `inline` → returns a `javascript:` data-shaped marker URL and
|
||||
* carries the inline body in the resolved record; throws
|
||||
* `OPENAPI_UI_REDOC_ASSETS_MISSING` if `inline` is `undefined`.
|
||||
*/
|
||||
export function resolveRedocAsset(args: {
|
||||
readonly strategy: RedocAssetStrategy;
|
||||
readonly bundled?: BundledRedocAsset;
|
||||
readonly inline?: string;
|
||||
}): ResolvedRedocAsset {
|
||||
const { strategy, bundled, inline } = args;
|
||||
if (strategy === "cdn") {
|
||||
return Object.freeze({ strategy, url: REDOC_CDN_URL });
|
||||
}
|
||||
if (strategy === "bundled") {
|
||||
if (!bundled) {
|
||||
throw new OpenapiUiRedocError(
|
||||
OPENAPI_UI_REDOC_CODES.ASSETS_MISSING,
|
||||
"uiAssets is 'bundled' but no bundled ReDoc runtime asset was supplied",
|
||||
{ context: { strategy } }
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
strategy,
|
||||
url: `./assets/${bundled.hash}/${bundled.name}`,
|
||||
});
|
||||
}
|
||||
// strategy === "inline"
|
||||
if (!inline || inline.length === 0) {
|
||||
throw new OpenapiUiRedocError(
|
||||
OPENAPI_UI_REDOC_CODES.ASSETS_MISSING,
|
||||
"uiAssets is 'inline' but no inline ReDoc runtime body was supplied",
|
||||
{ context: { strategy } }
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
strategy,
|
||||
url: "data:inline",
|
||||
inlineBody: inline,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type BundledRedocAsset,
|
||||
defineRedocAsset,
|
||||
REDOC_BUNDLED_URL,
|
||||
REDOC_CDN_URL,
|
||||
type RedocAssetStrategy,
|
||||
type ResolvedRedocAsset,
|
||||
resolveRedocAsset,
|
||||
} from "./assets.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# `error-codes` — `OPENAPI_UI_REDOC.*` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the frozen error-code namespace shared by the ReDoc UI
|
||||
adapter. Every fault the package raises carries one of the two codes registered
|
||||
here, so operators can map ReDoc-adapter failures to dashboards, alerts, and
|
||||
runbooks without spelunking through stack traces. The registry is wired into
|
||||
the cross-package `defineCodes` registry so callers that bare-import the
|
||||
package automatically surface the namespace alongside every other suaveplan
|
||||
code prefix.
|
||||
|
||||
## Features
|
||||
|
||||
- Two stable codes: `OPENAPI_UI_REDOC_ASSETS_MISSING` for asset-strategy
|
||||
faults and `OPENAPI_UI_REDOC_INVALID_OPTIONS` for Zod-rejected option
|
||||
bundles.
|
||||
- A concrete `OpenapiUiRedocError` subclass that narrows `code` so consumers
|
||||
can `instanceof`-discriminate the package's faults from other Suaveplan
|
||||
errors.
|
||||
- A `redocCode` getter that re-exposes the code at a typed alias for
|
||||
pattern-matching in dashboards.
|
||||
- Frozen tuple via `defineCodes(...)` so the literal types survive bundling
|
||||
and tree-shaking.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
} from "@suaveplan/api-openapi-ui-redoc/error-codes";
|
||||
|
||||
throw new OpenapiUiRedocError(
|
||||
OPENAPI_UI_REDOC_CODES.ASSETS_MISSING,
|
||||
"uiAssets='bundled' but no payload supplied"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pattern-match codes in a host fault sink to send INVALID_OPTIONS to the
|
||||
operator dashboard and ASSETS_MISSING to the deploy-pipeline alarm:
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
} from "@suaveplan/api-openapi-ui-redoc/error-codes";
|
||||
|
||||
function handleFault(err: unknown): void {
|
||||
if (!(err instanceof OpenapiUiRedocError)) return;
|
||||
switch (err.redocCode) {
|
||||
case OPENAPI_UI_REDOC_CODES.ASSETS_MISSING:
|
||||
alertDeployPipeline(err);
|
||||
return;
|
||||
case OPENAPI_UI_REDOC_CODES.INVALID_OPTIONS:
|
||||
alertOperatorConsole(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| -------------------------- | ------ | ---------------------------------------- |
|
||||
| `OPENAPI_UI_REDOC_CODES` | const | Frozen `Record<"ASSETS_MISSING"\|"INVALID_OPTIONS", string>` |
|
||||
| `OpenapiUiRedocErrorCode` | type | Union of the two namespaced literals |
|
||||
| `OpenapiUiRedocError` | class | `SuaveplanError<OpenapiUiRedocErrorCode>` subclass with `redocCode` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are registered exactly once at module init via `defineCodes("OPENAPI_UI_REDOC", […])`.
|
||||
Importing the package's root barrel surfaces this module as a side-effect so
|
||||
subpath consumers (e.g. `…/handler`) still benefit from the registration even
|
||||
if they never reach the top-level export. The concrete class re-stores the
|
||||
typed `redocCode` so narrowed `instanceof` checks expose the code on a typed
|
||||
property rather than the loosely-typed `SuaveplanError.code`.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OPENAPI_UI_REDOC_CODES, OpenapiUiRedocError } from "./error-codes.js";
|
||||
|
||||
describe("OPENAPI_UI_REDOC error-code registry", () => {
|
||||
it("registers ASSETS_MISSING + INVALID_OPTIONS with namespaced strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(OPENAPI_UI_REDOC_CODES.ASSETS_MISSING).toBe(
|
||||
"OPENAPI_UI_REDOC.ASSETS_MISSING"
|
||||
);
|
||||
expect(OPENAPI_UI_REDOC_CODES.INVALID_OPTIONS).toBe(
|
||||
"OPENAPI_UI_REDOC.INVALID_OPTIONS"
|
||||
);
|
||||
});
|
||||
|
||||
it("OpenapiUiRedocError preserves code, message, and redocCode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new OpenapiUiRedocError(
|
||||
OPENAPI_UI_REDOC_CODES.ASSETS_MISSING,
|
||||
"boom"
|
||||
);
|
||||
expect(err).toBeInstanceOf(OpenapiUiRedocError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("OPENAPI_UI_REDOC.ASSETS_MISSING");
|
||||
expect(err.redocCode).toBe("OPENAPI_UI_REDOC.ASSETS_MISSING");
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("forwards SuaveplanError options (cause)", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new OpenapiUiRedocError(
|
||||
OPENAPI_UI_REDOC_CODES.INVALID_OPTIONS,
|
||||
"bad",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen `OPENAPI_UI_REDOC.*` error-code registry.
|
||||
*
|
||||
* - `OPENAPI_UI_REDOC_ASSETS_MISSING` — `uiAssets: "bundled"` was
|
||||
* requested but the bundled ReDoc runtime payload is not present in
|
||||
* the asset registry. Operators using `"bundled"` MUST provide a
|
||||
* non-empty bundled asset (see `assets.ts`).
|
||||
* - `OPENAPI_UI_REDOC_INVALID_OPTIONS` — the supplied options failed
|
||||
* Zod validation. The original `ZodError` is attached as the
|
||||
* `cause`.
|
||||
*/
|
||||
export const OPENAPI_UI_REDOC_CODES = defineCodes("OPENAPI_UI_REDOC", [
|
||||
"ASSETS_MISSING",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type OpenapiUiRedocErrorCode =
|
||||
(typeof OPENAPI_UI_REDOC_CODES)[keyof typeof OPENAPI_UI_REDOC_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class — `instanceof OpenapiUiRedocError` narrows
|
||||
* `code` to {@link OpenapiUiRedocErrorCode}.
|
||||
*/
|
||||
export class OpenapiUiRedocError extends SuaveplanError<OpenapiUiRedocErrorCode> {
|
||||
public readonly redocCode: OpenapiUiRedocErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: OpenapiUiRedocErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.redocCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
type OpenapiUiRedocErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# `handler` — `createRedocUiHandler`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `createRedocUiHandler`, the factory the api-host calls
|
||||
to obtain the HTTP handler serving the ReDoc HTML shell. The returned
|
||||
handler is a zero-argument function the host invokes per request. It is
|
||||
intentionally router-agnostic — adapters in the api-host layer translate
|
||||
the returned `RedocHttpResponse` into Hono / api-router envelopes — so the
|
||||
package can be wired into any HTTP surface without dragging a router peer
|
||||
dependency through every consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates options at construction; invalid input raises
|
||||
`OPENAPI_UI_REDOC_INVALID_OPTIONS` before any handler runs.
|
||||
- Resolves the runtime asset URL up-front; missing bundled / inline payloads
|
||||
raise `OPENAPI_UI_REDOC_ASSETS_MISSING` at construction, not at request time.
|
||||
- Pre-renders the HTML shell once at construction — every subsequent request
|
||||
is a constant-time return of the frozen response object.
|
||||
- The response carries `Content-Type: text/html; charset=utf-8`,
|
||||
`Cache-Control: no-cache`, and `X-Content-Type-Options: nosniff`.
|
||||
- The handler is idempotent — the same call always returns the same frozen
|
||||
object, so adapters can safely cache it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRedocUiHandler } from "@suaveplan/api-openapi-ui-redoc/handler";
|
||||
|
||||
const handler = createRedocUiHandler({ openapiUrl: "/openapi.json" });
|
||||
const res = handler();
|
||||
// res.body is the rendered HTML shell.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire the bundled-asset strategy into a Hono app, including the cache-busted
|
||||
asset route:
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createRedocUiHandler,
|
||||
defineRedocAsset,
|
||||
} from "@suaveplan/api-openapi-ui-redoc";
|
||||
|
||||
const asset = defineRedocAsset(
|
||||
"redoc.standalone.js",
|
||||
"application/javascript; charset=utf-8",
|
||||
runtimeBody
|
||||
);
|
||||
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
uiAssets: "bundled",
|
||||
bundledAsset: asset,
|
||||
title: "Acme API",
|
||||
expandResponses: "200,201",
|
||||
theme: "dark",
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.get("/docs/ui/redoc", (c) => {
|
||||
const r = handler();
|
||||
Object.entries(r.headers).forEach(([k, v]) => c.header(k, v));
|
||||
return c.body(r.body, r.status);
|
||||
});
|
||||
app.get(`/docs/ui/redoc/assets/${asset.hash}/${asset.name}`, (c) => {
|
||||
c.header("Content-Type", asset.contentType);
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
return c.body(asset.body as string);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --------------------------- | ---- | ----------- |
|
||||
| `RedocHttpResponse` | type | `{ status, headers, body }` — router-agnostic response |
|
||||
| `RedocHttpHandler` | type | `() => RedocHttpResponse` |
|
||||
| `CreateRedocUiHandlerInput` | type | `RedocUiOptionsInput & { bundledAsset?, inlineAsset? }` |
|
||||
| `createRedocUiHandler` | fn | Build the handler; pre-renders the shell |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handler pre-renders the HTML once at construction. This keeps the
|
||||
per-request hot path a constant-time return of a frozen object — the same
|
||||
shape the api-router would otherwise have to allocate per call. Pre-rendering
|
||||
also means every option-level fault surfaces at host start-up, not at the
|
||||
first request, which is the failure mode operators want for misconfigured
|
||||
deploys.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defineRedocAsset } from "../assets/assets.js";
|
||||
import { OpenapiUiRedocError } from "../error-codes/error-codes.js";
|
||||
import { createRedocUiHandler } from "./handler.js";
|
||||
|
||||
describe("createRedocUiHandler", () => {
|
||||
it("handler returns HTML referencing openapiUrl", { timeout: 5000 }, () => {
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["Content-Type"]).toBe("text/html; charset=utf-8");
|
||||
expect(res.body).toContain('spec-url="/openapi.json"');
|
||||
expect(res.body).toContain("<!doctype html>");
|
||||
});
|
||||
|
||||
it("expandResponses is embedded in config", { timeout: 5000 }, () => {
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
expandResponses: "200,201",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain("expandResponses");
|
||||
expect(res.body).toContain("200,201");
|
||||
});
|
||||
|
||||
it("is idempotent — every call returns the same frozen response", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const a = handler();
|
||||
const b = handler();
|
||||
expect(a).toBe(b);
|
||||
expect(Object.isFrozen(a)).toBe(true);
|
||||
expect(Object.isFrozen(a.headers)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders bundled-mode shell with cache-busted asset URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundled = defineRedocAsset(
|
||||
"redoc.standalone.js",
|
||||
"application/javascript; charset=utf-8",
|
||||
"/* redoc bundled body */"
|
||||
);
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
uiAssets: "bundled",
|
||||
bundledAsset: bundled,
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain(
|
||||
`./assets/${bundled.hash}/redoc.standalone.js`
|
||||
);
|
||||
});
|
||||
|
||||
it("renders inline-mode shell with embedded body", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
uiAssets: "inline",
|
||||
inlineAsset: "/* inline-runtime */",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain("/* inline-runtime */");
|
||||
// No <script src=...> for the runtime in inline mode.
|
||||
expect(res.body).not.toContain("data:inline");
|
||||
});
|
||||
|
||||
it("default uiAssets is cdn (Redocly CDN URL)", { timeout: 5000 }, () => {
|
||||
const handler = createRedocUiHandler();
|
||||
const res = handler();
|
||||
expect(res.body).toContain("cdn.redoc.ly/redoc/latest");
|
||||
});
|
||||
|
||||
it("title is HTML-escaped", { timeout: 5000 }, () => {
|
||||
const handler = createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
title: "<script>alert(1)</script>",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).not.toContain("<script>alert(1)");
|
||||
expect(res.body).toContain("<script>alert(1)");
|
||||
});
|
||||
|
||||
it("invalid options raise OPENAPI_UI_REDOC_INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createRedocUiHandler({
|
||||
openapiUrl: "/openapi.json",
|
||||
// @ts-expect-error — deliberate misuse to exercise the validator
|
||||
theme: "neon",
|
||||
})
|
||||
).toThrow(OpenapiUiRedocError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type BundledRedocAsset, resolveRedocAsset } from "../assets/assets.js";
|
||||
import { renderRedocShell } from "../html/render-shell.js";
|
||||
import {
|
||||
parseRedocUiOptions,
|
||||
type RedocUiOptions,
|
||||
type RedocUiOptionsInput,
|
||||
} from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Minimal HTTP response shape. Adapters in the api-host layer adapt
|
||||
* this into the Hono / api-router envelopes they expect. The package
|
||||
* deliberately depends on no router so it can be wired into any host.
|
||||
*/
|
||||
export interface RedocHttpResponse {
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** Signature of the handler returned by {@link createRedocUiHandler}. */
|
||||
export type RedocHttpHandler = () => RedocHttpResponse;
|
||||
|
||||
/** Input to {@link createRedocUiHandler}. */
|
||||
export interface CreateRedocUiHandlerInput extends RedocUiOptionsInput {
|
||||
/** Bundled runtime payload — required when `uiAssets = "bundled"`. */
|
||||
readonly bundledAsset?: BundledRedocAsset;
|
||||
/** Inline runtime body — required when `uiAssets = "inline"`. */
|
||||
readonly inlineAsset?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ReDoc UI HTTP handler. Returns a zero-argument function
|
||||
* the host invokes per request; the handler is pure given the
|
||||
* options + asset bundle (the document itself is fetched by the
|
||||
* browser from `openapiUrl`).
|
||||
*
|
||||
* - Invalid options raise `OPENAPI_UI_REDOC_INVALID_OPTIONS`.
|
||||
* - `uiAssets = "bundled"` with no payload raises
|
||||
* `OPENAPI_UI_REDOC_ASSETS_MISSING`.
|
||||
*/
|
||||
export function createRedocUiHandler(
|
||||
input?: CreateRedocUiHandlerInput
|
||||
): RedocHttpHandler {
|
||||
const { bundledAsset, inlineAsset, ...optionsInput } = input ?? {};
|
||||
const options: RedocUiOptions = parseRedocUiOptions(optionsInput);
|
||||
const resolvedAsset = resolveRedocAsset({
|
||||
strategy: options.uiAssets,
|
||||
...(bundledAsset !== undefined ? { bundled: bundledAsset } : {}),
|
||||
...(inlineAsset !== undefined ? { inline: inlineAsset } : {}),
|
||||
});
|
||||
const html = renderRedocShell({ options, resolvedAsset });
|
||||
|
||||
const response: RedocHttpResponse = Object.freeze({
|
||||
status: 200,
|
||||
headers: Object.freeze({
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}),
|
||||
body: html,
|
||||
});
|
||||
|
||||
return function redocHandler(): RedocHttpResponse {
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type CreateRedocUiHandlerInput,
|
||||
createRedocUiHandler,
|
||||
type RedocHttpHandler,
|
||||
type RedocHttpResponse,
|
||||
} from "./handler.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type RenderRedocShellInput,
|
||||
renderRedocShell,
|
||||
} from "./render-shell.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `html/render-shell` — ReDoc HTML shell renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `renderRedocShell`, the pure function that produces the
|
||||
ReDoc HTML shell consumed by both the global and per-plugin handlers. It
|
||||
embeds the OpenAPI document URL into a `<redoc spec-url="…">` custom element,
|
||||
ships a JSON option island for the runtime to read, and injects the runtime
|
||||
runtime asset reference based on the chosen asset strategy. Keeping the
|
||||
renderer separate from the handler factories lets host operators embed the
|
||||
shell in custom adapters (e.g. SSR, Edge functions) without reaching for the
|
||||
handler builder.
|
||||
|
||||
## Features
|
||||
|
||||
- HTML-escapes every user-supplied value (`title`, `openapiUrl`, etc.) so
|
||||
option strings cannot break out of attribute or text-node scope.
|
||||
- Inlines the runtime body when `strategy === "inline"` and emits a
|
||||
`<script src="…">` tag for `cdn` / `bundled` strategies.
|
||||
- Embeds `theme`, `hideDownloadButton`, `nativeScrollbars`, and
|
||||
`expandResponses` into the JSON options island the runtime reads.
|
||||
- Tags the body element with `data-redoc-theme` so external stylesheets can
|
||||
override the colour scheme.
|
||||
- Pure — no I/O, no clocks, no globals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderRedocShell } from "@suaveplan/api-openapi-ui-redoc/html";
|
||||
import {
|
||||
parseRedocUiOptions,
|
||||
resolveRedocAsset,
|
||||
} from "@suaveplan/api-openapi-ui-redoc";
|
||||
|
||||
const html = renderRedocShell({
|
||||
options: parseRedocUiOptions({ openapiUrl: "/openapi.json" }),
|
||||
resolvedAsset: resolveRedocAsset({ strategy: "cdn" }),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pre-render the shell for a multi-tenant host that picks up the OpenAPI URL
|
||||
from the request path:
|
||||
|
||||
```ts
|
||||
import { renderRedocShell } from "@suaveplan/api-openapi-ui-redoc/html";
|
||||
|
||||
const options = parseRedocUiOptions({ openapiUrl: "/openapi.json", theme: "dark" });
|
||||
const resolvedAsset = resolveRedocAsset({ strategy: "cdn" });
|
||||
|
||||
function htmlForTenant(tenant: string): string {
|
||||
return renderRedocShell({
|
||||
options,
|
||||
resolvedAsset,
|
||||
openapiUrlOverride: `/${tenant}/openapi.json`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------ | ---- | ----------- |
|
||||
| `RenderRedocShellInput` | type | `{ options, resolvedAsset, openapiUrlOverride? }` |
|
||||
| `renderRedocShell` | fn | Render the HTML shell as a string |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The escape function covers the five HTML special characters (`&`, `<`, `>`,
|
||||
`"`, `'`) plus the apostrophe — the same surface as the WHATWG HTML
|
||||
serialization-attribute rules. The JSON island is serialised via
|
||||
`JSON.stringify` and HTML-escaped so embedded `</script>` sequences in
|
||||
option values cannot escape the `<script>` context. The shell's bottom-of-
|
||||
body initialiser calls `window.Redoc.init` when the runtime defines it,
|
||||
falling back gracefully when the runtime is unavailable (e.g. during DOM
|
||||
unit tests that don't load the standalone bundle).
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { resolveRedocAsset } from "../assets/assets.js";
|
||||
import { parseRedocUiOptions } from "../options/options.js";
|
||||
import { renderRedocShell } from "./render-shell.js";
|
||||
|
||||
describe("renderRedocShell", () => {
|
||||
it("renders a valid HTML5 shell", { timeout: 5000 }, () => {
|
||||
const html = renderRedocShell({
|
||||
options: parseRedocUiOptions({ openapiUrl: "/openapi.json" }),
|
||||
resolvedAsset: resolveRedocAsset({ strategy: "cdn" }),
|
||||
});
|
||||
expect(html.startsWith("<!doctype html>")).toBe(true);
|
||||
expect(html).toContain("</html>");
|
||||
expect(html).toContain("<redoc");
|
||||
});
|
||||
|
||||
it("embeds the JSON options island with hideDownloadButton + nativeScrollbars", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const html = renderRedocShell({
|
||||
options: parseRedocUiOptions({
|
||||
openapiUrl: "/openapi.json",
|
||||
hideDownloadButton: true,
|
||||
nativeScrollbars: true,
|
||||
}),
|
||||
resolvedAsset: resolveRedocAsset({ strategy: "cdn" }),
|
||||
});
|
||||
expect(html).toContain("hideDownloadButton");
|
||||
expect(html).toContain("nativeScrollbars");
|
||||
// JSON-stringified true value (HTML-escaped is just "true")
|
||||
expect(html).toContain(""hideDownloadButton":true");
|
||||
expect(html).toContain(""nativeScrollbars":true");
|
||||
});
|
||||
|
||||
it("escapes HTML special characters in openapiUrl override", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const html = renderRedocShell({
|
||||
options: parseRedocUiOptions({}),
|
||||
resolvedAsset: resolveRedocAsset({ strategy: "cdn" }),
|
||||
openapiUrlOverride: '/x"><script>',
|
||||
});
|
||||
expect(html).not.toContain('"><script>');
|
||||
expect(html).toContain(""><script>");
|
||||
});
|
||||
|
||||
it("uses dark-theme primary color when theme=dark", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const html = renderRedocShell({
|
||||
options: parseRedocUiOptions({ theme: "dark" }),
|
||||
resolvedAsset: resolveRedocAsset({ strategy: "cdn" }),
|
||||
});
|
||||
expect(html).toContain("#90caf9");
|
||||
expect(html).toContain('data-redoc-theme="dark"');
|
||||
});
|
||||
|
||||
it("inline strategy embeds the body in a script tag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const html = renderRedocShell({
|
||||
options: parseRedocUiOptions({ uiAssets: "inline" }),
|
||||
resolvedAsset: resolveRedocAsset({
|
||||
strategy: "inline",
|
||||
inline: "/* runtime */",
|
||||
}),
|
||||
});
|
||||
expect(html).toContain("<script>/* runtime */</script>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ResolvedRedocAsset } from "../assets/assets.js";
|
||||
import type { RedocUiOptions } from "../options/options.js";
|
||||
|
||||
/** Input to {@link renderRedocShell}. */
|
||||
export interface RenderRedocShellInput {
|
||||
readonly options: RedocUiOptions;
|
||||
readonly resolvedAsset: ResolvedRedocAsset;
|
||||
/** Optional override of the `openapiUrl` from `options` — used by the per-plugin handler. */
|
||||
readonly openapiUrlOverride?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-escape a value used in an attribute / text node so user-
|
||||
* supplied option values cannot break out of the shell scaffold.
|
||||
*/
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the ReDoc HTML shell. The shell follows the official ReDoc
|
||||
* embed contract:
|
||||
*
|
||||
* - `<redoc spec-url="…">` custom element binds to the OpenAPI doc.
|
||||
* - A `<script id="redoc-options">` JSON island carries the
|
||||
* `theme` / `expandResponses` / `hideDownloadButton` /
|
||||
* `nativeScrollbars` options the ReDoc runtime reads.
|
||||
* - A `<script src="…">` (or inline `<script>` for `inline` mode)
|
||||
* pulls in the runtime per the resolved asset strategy.
|
||||
*/
|
||||
export function renderRedocShell(input: RenderRedocShellInput): string {
|
||||
const { options, resolvedAsset, openapiUrlOverride } = input;
|
||||
const openapiUrl = openapiUrlOverride ?? options.openapiUrl;
|
||||
const title = escapeHtml(options.title);
|
||||
const specUrl = escapeHtml(openapiUrl);
|
||||
|
||||
const redocOpts: Record<string, unknown> = {
|
||||
theme: {
|
||||
colors: {
|
||||
primary: {
|
||||
main: options.theme === "dark" ? "#90caf9" : "#1976d2",
|
||||
},
|
||||
},
|
||||
},
|
||||
hideDownloadButton: options.hideDownloadButton,
|
||||
nativeScrollbars: options.nativeScrollbars,
|
||||
};
|
||||
if (options.expandResponses !== undefined) {
|
||||
redocOpts["expandResponses"] = options.expandResponses;
|
||||
}
|
||||
|
||||
const optionsJson = escapeHtml(JSON.stringify(redocOpts));
|
||||
|
||||
const runtimeTag =
|
||||
resolvedAsset.strategy === "inline" && resolvedAsset.inlineBody
|
||||
? `<script>${resolvedAsset.inlineBody}</script>`
|
||||
: `<script src="${escapeHtml(resolvedAsset.url)}"></script>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>body { margin: 0; padding: 0; }</style>
|
||||
</head>
|
||||
<body data-redoc-theme="${escapeHtml(options.theme)}">
|
||||
<redoc spec-url="${specUrl}"></redoc>
|
||||
<script id="redoc-options" type="application/json">${optionsJson}</script>
|
||||
${runtimeTag}
|
||||
<script>
|
||||
(function () {
|
||||
var el = document.querySelector("redoc");
|
||||
var optsEl = document.getElementById("redoc-options");
|
||||
if (!el || !optsEl) { return; }
|
||||
var opts;
|
||||
try { opts = JSON.parse(optsEl.textContent || "{}"); } catch (e) { opts = {}; }
|
||||
if (typeof window !== "undefined" && typeof window.Redoc !== "undefined" && typeof window.Redoc.init === "function") {
|
||||
window.Redoc.init(el.getAttribute("spec-url"), opts, el);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
45
packages/api-stack/api/api-openapi-ui-redoc/src/index.ts
Normal file
45
packages/api-stack/api/api-openapi-ui-redoc/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Side-effect import — surfaces the OPENAPI_UI_REDOC.* code registry
|
||||
// on bare `import "@suaveplan/api-openapi-ui-redoc"`.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Assets
|
||||
export {
|
||||
type BundledRedocAsset,
|
||||
defineRedocAsset,
|
||||
REDOC_BUNDLED_URL,
|
||||
REDOC_CDN_URL,
|
||||
type RedocAssetStrategy,
|
||||
type ResolvedRedocAsset,
|
||||
resolveRedocAsset,
|
||||
} from "./assets/assets.js";
|
||||
// Error codes
|
||||
export {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
type OpenapiUiRedocErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Handler
|
||||
export {
|
||||
type CreateRedocUiHandlerInput,
|
||||
createRedocUiHandler,
|
||||
type RedocHttpHandler,
|
||||
type RedocHttpResponse,
|
||||
} from "./handler/handler.js";
|
||||
// HTML shell
|
||||
export {
|
||||
type RenderRedocShellInput,
|
||||
renderRedocShell,
|
||||
} from "./html/render-shell.js";
|
||||
// Options
|
||||
export {
|
||||
parseRedocUiOptions,
|
||||
type RedocUiOptions,
|
||||
type RedocUiOptionsInput,
|
||||
redocUiOptionsSchema,
|
||||
} from "./options/options.js";
|
||||
// Per-plugin handler
|
||||
export {
|
||||
type CreatePerPluginRedocUiHandlerInput,
|
||||
createPerPluginRedocUiHandler,
|
||||
type OpenapiUrlForPlugin,
|
||||
} from "./per-plugin/per-plugin.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
parseRedocUiOptions,
|
||||
type RedocUiOptions,
|
||||
type RedocUiOptionsInput,
|
||||
redocUiOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# `options` — `RedocUiOptions` schema + parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the Zod schema and parser for `RedocUiOptions`, the
|
||||
configuration bundle the ReDoc UI handler accepts. It is the single boundary
|
||||
where caller-supplied option values transition from untrusted input to a
|
||||
fully-defaulted, immutable typed record the rest of the package treats as
|
||||
ground truth. Refusing malformed input here means the rendered HTML shell
|
||||
never has to defend against half-validated values at request time.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict schema — unknown keys are refused so typos surface immediately
|
||||
instead of silently producing a misconfigured shell.
|
||||
- Sensible defaults so the bare `createRedocUiHandler()` call still produces
|
||||
a working `/docs/ui/redoc` shell against `/openapi.json`.
|
||||
- `expandResponses` is a regex-validated comma-separated list of three-character
|
||||
HTTP status codes (or `"all"`), matching the official ReDoc option.
|
||||
- `uiAssets` strategy literal — one of `"cdn"`, `"bundled"`, `"inline"`.
|
||||
- Zod failures are wrapped as `OpenapiUiRedocError(INVALID_OPTIONS)` with the
|
||||
original `ZodError` attached as `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRedocUiOptions } from "@suaveplan/api-openapi-ui-redoc/options";
|
||||
|
||||
const opts = parseRedocUiOptions({
|
||||
openapiUrl: "/openapi.json",
|
||||
theme: "dark",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine with the host-config layer to validate `host.openapi.ui = "redoc"`
|
||||
options at ingestion time, before any handler is constructed:
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRedocUiOptions,
|
||||
redocUiOptionsSchema,
|
||||
type RedocUiOptionsInput,
|
||||
} from "@suaveplan/api-openapi-ui-redoc/options";
|
||||
|
||||
const hostConfigSchema = z.object({
|
||||
openapi: z.object({
|
||||
ui: z.literal("redoc"),
|
||||
redoc: redocUiOptionsSchema.partial().default({}),
|
||||
}),
|
||||
});
|
||||
|
||||
function buildHandler(raw: unknown) {
|
||||
const cfg = hostConfigSchema.parse(raw);
|
||||
const opts: RedocUiOptionsInput = cfg.openapi.redoc;
|
||||
return parseRedocUiOptions(opts);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------- | ------ | ----------- |
|
||||
| `redocUiOptionsSchema` | const | `z.ZodObject` of the strict options shape |
|
||||
| `RedocUiOptions` | type | `z.infer<typeof redocUiOptionsSchema>` (defaulted) |
|
||||
| `RedocUiOptionsInput` | type | `z.input<typeof redocUiOptionsSchema>` (caller-facing) |
|
||||
| `parseRedocUiOptions` | fn | `(input?) => RedocUiOptions` — throws `OpenapiUiRedocError(INVALID_OPTIONS)` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is `.strict()` so additional keys raise the catch-all
|
||||
`INVALID_OPTIONS` code rather than silently passing through. We use
|
||||
`safeParse` internally so we can wrap the error rather than re-throw the raw
|
||||
`ZodError`; the wrapper carries the registry code and stable message but the
|
||||
underlying issue list remains accessible via `err.cause`. Defaults are baked
|
||||
into the schema so a `parseRedocUiOptions({})` call returns a fully-formed
|
||||
options record without callers having to reach for any other layer.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseRedocUiOptions, redocUiOptionsSchema } from "./options.js";
|
||||
|
||||
describe("parseRedocUiOptions", () => {
|
||||
it("defaults are applied when no input is given", { timeout: 5000 }, () => {
|
||||
const parsed = parseRedocUiOptions(undefined);
|
||||
expect(parsed.openapiUrl).toBe("/openapi.json");
|
||||
expect(parsed.title).toBe("API Reference");
|
||||
expect(parsed.theme).toBe("light");
|
||||
expect(parsed.uiAssets).toBe("cdn");
|
||||
expect(parsed.hideDownloadButton).toBe(false);
|
||||
expect(parsed.nativeScrollbars).toBe(false);
|
||||
expect(parsed.expandResponses).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes valid expandResponses values", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
parseRedocUiOptions({ expandResponses: "200,201" }).expandResponses
|
||||
).toBe("200,201");
|
||||
expect(
|
||||
parseRedocUiOptions({ expandResponses: "all" }).expandResponses
|
||||
).toBe("all");
|
||||
expect(
|
||||
parseRedocUiOptions({ expandResponses: "2xx" }).expandResponses
|
||||
).toBe("2xx");
|
||||
});
|
||||
|
||||
it("invalid expandResponses rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseRedocUiOptions({ expandResponses: "blahblah" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenapiUiRedocError);
|
||||
expect((err as OpenapiUiRedocError).redocCode).toBe(
|
||||
OPENAPI_UI_REDOC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown keys are refused by the strict schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseRedocUiOptions({
|
||||
// @ts-expect-error — deliberate
|
||||
bogus: "field",
|
||||
})
|
||||
).toThrow(OpenapiUiRedocError);
|
||||
});
|
||||
|
||||
it("schema is exported and accepts a partial input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = redocUiOptionsSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returned options object is frozen", { timeout: 5000 }, () => {
|
||||
const parsed = parseRedocUiOptions({});
|
||||
expect(Object.isFrozen(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
OPENAPI_UI_REDOC_CODES,
|
||||
OpenapiUiRedocError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Asset-strategy literals. `cdn` — point the shell at the upstream
|
||||
* ReDoc CDN URL. `bundled` — serve the runtime from a caller-supplied
|
||||
* payload (see `assets.ts`). `inline` — embed the entire runtime
|
||||
* literal into the HTML shell (no separate asset request).
|
||||
*/
|
||||
const uiAssetsSchema = z
|
||||
.enum(["cdn", "bundled", "inline"] as const)
|
||||
.default("cdn");
|
||||
|
||||
/**
|
||||
* `expandResponses` is a comma-separated list of HTTP status codes
|
||||
* (or `"all"`) per the ReDoc option of the same name. We validate
|
||||
* shape strictly to refuse free-form garbage that would silently
|
||||
* break the shell at runtime.
|
||||
*/
|
||||
const EXPAND_RESPONSES_RE = /^(all|[0-9xX*]{3}(,[0-9xX*]{3})*)$/;
|
||||
|
||||
const expandResponsesSchema = z
|
||||
.string()
|
||||
.regex(EXPAND_RESPONSES_RE, {
|
||||
message:
|
||||
"expandResponses must be 'all' or a comma-separated list of three-character codes (e.g. '200,201' or '2xx' / '2XX')",
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Zod schema for {@link RedocUiOptions}. Strict — unknown keys are
|
||||
* refused so typos surface at boundary parse time, not at runtime
|
||||
* when the rendered shell silently ignores them.
|
||||
*/
|
||||
export const redocUiOptionsSchema = z
|
||||
.object({
|
||||
openapiUrl: z.string().min(1).default("/openapi.json"),
|
||||
title: z.string().min(1).default("API Reference"),
|
||||
theme: z.enum(["light", "dark"] as const).default("light"),
|
||||
expandResponses: expandResponsesSchema,
|
||||
hideDownloadButton: z.boolean().default(false),
|
||||
nativeScrollbars: z.boolean().default(false),
|
||||
uiAssets: uiAssetsSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Parsed, defaulted options consumed by `createRedocUiHandler`. */
|
||||
export type RedocUiOptions = z.infer<typeof redocUiOptionsSchema>;
|
||||
|
||||
/** Caller-facing input — every field optional, defaults applied by Zod. */
|
||||
export type RedocUiOptionsInput = z.input<typeof redocUiOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Validate caller-supplied options. Throws
|
||||
* `OpenapiUiRedocError(INVALID_OPTIONS)` on shape violation with the
|
||||
* underlying `ZodError` attached as `cause`. Returns a fully
|
||||
* defaulted, immutable `RedocUiOptions` on success.
|
||||
*/
|
||||
export function parseRedocUiOptions(
|
||||
input: RedocUiOptionsInput | undefined
|
||||
): RedocUiOptions {
|
||||
const result = redocUiOptionsSchema.safeParse(input ?? {});
|
||||
if (!result.success) {
|
||||
throw new OpenapiUiRedocError(
|
||||
OPENAPI_UI_REDOC_CODES.INVALID_OPTIONS,
|
||||
`Invalid RedocUiOptions: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
return Object.freeze(result.data);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreatePerPluginRedocUiHandlerInput,
|
||||
createPerPluginRedocUiHandler,
|
||||
type OpenapiUrlForPlugin,
|
||||
} from "./per-plugin.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `per-plugin` — per-plugin ReDoc handler factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `createPerPluginRedocUiHandler`, the sibling of
|
||||
`createRedocUiHandler` for hosts that mount per-plugin documentation surfaces
|
||||
at `/docs/{plugin}/ui/redoc`. The rendered shell references the plugin-filtered
|
||||
OpenAPI URL (typically `/openapi.json?plugin=foo`) so a single host can serve
|
||||
both the global aggregate documentation and per-plugin slices through the
|
||||
same UI adapter.
|
||||
|
||||
## Features
|
||||
|
||||
- Plugin-specific URL resolver — operators inject a custom `openapiUrlForPlugin`
|
||||
if their host filters by path segment rather than query string.
|
||||
- Sensible default resolver appends `?plugin=<encoded>` to `options.openapiUrl`
|
||||
(or `&plugin=…` if the URL already has a query string).
|
||||
- Validates options through the same Zod schema as the global handler.
|
||||
- Pre-renders the shell once per plugin handler so per-request cost is zero.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPerPluginRedocUiHandler } from "@suaveplan/api-openapi-ui-redoc/per-plugin";
|
||||
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "contacts",
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
// handler().body references "/openapi.json?plugin=contacts"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use a path-segment resolver when the host serves filtered documents at
|
||||
`/openapi/<plugin>.json`:
|
||||
|
||||
```ts
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrl: "/openapi.json",
|
||||
openapiUrlForPlugin: (p) => `/openapi/${p}.json`,
|
||||
title: "Billing API",
|
||||
expandResponses: "all",
|
||||
});
|
||||
```
|
||||
|
||||
The api-host typically lazy-builds one per-plugin handler per request, but
|
||||
because construction validates options + asset strategy up-front, callers can
|
||||
also pre-build the per-plugin handlers at start-up and dispatch by slug.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------------------- | ---- | ----------- |
|
||||
| `OpenapiUrlForPlugin` | type | `(plugin: string) => string` |
|
||||
| `CreatePerPluginRedocUiHandlerInput` | type | `RedocUiOptionsInput & { plugin, openapiUrlForPlugin?, bundledAsset?, inlineAsset? }` |
|
||||
| `createPerPluginRedocUiHandler` | fn | Build the per-plugin handler |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default resolver URL-encodes the plugin slug via `encodeURIComponent` so
|
||||
slugs containing `/` or other delimiters cannot break out of the query
|
||||
parameter. The shell is rendered with the resolved URL substituted in via
|
||||
`openapiUrlOverride` on `renderRedocShell`, leaving the rest of the options
|
||||
record (theme, expandResponses, …) untouched.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defineRedocAsset } from "../assets/assets.js";
|
||||
import { createPerPluginRedocUiHandler } from "./per-plugin.js";
|
||||
|
||||
describe("createPerPluginRedocUiHandler", () => {
|
||||
it("per-plugin handler embeds filtered URL", { timeout: 5000 }, () => {
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "contacts",
|
||||
openapiUrl: "/openapi.json",
|
||||
openapiUrlForPlugin: (p) => `/openapi.json?plugin=${p}`,
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain('spec-url="/openapi.json?plugin=contacts"');
|
||||
});
|
||||
|
||||
it("default resolver appends ?plugin=… to openapiUrl", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "auth",
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain("/openapi.json?plugin=auth");
|
||||
});
|
||||
|
||||
it("default resolver uses & when openapiUrl already has a query string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "auth",
|
||||
openapiUrl: "/openapi.json?v=1",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain("/openapi.json?v=1&plugin=auth");
|
||||
});
|
||||
|
||||
it("forwards bundledAsset into the resolved asset (bundled mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundled = defineRedocAsset(
|
||||
"redoc.standalone.js",
|
||||
"application/javascript; charset=utf-8",
|
||||
"/* bundled */"
|
||||
);
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "auth",
|
||||
openapiUrl: "/openapi.json",
|
||||
uiAssets: "bundled",
|
||||
bundledAsset: bundled,
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain(
|
||||
`./assets/${bundled.hash}/redoc.standalone.js`
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards inlineAsset into the resolved asset (inline mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "auth",
|
||||
openapiUrl: "/openapi.json",
|
||||
uiAssets: "inline",
|
||||
inlineAsset: "/* inline-per-plugin */",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain("/* inline-per-plugin */");
|
||||
});
|
||||
|
||||
it("URL-encodes plugin slug in default resolver", { timeout: 5000 }, () => {
|
||||
const handler = createPerPluginRedocUiHandler({
|
||||
plugin: "billing/v2",
|
||||
openapiUrl: "/openapi.json",
|
||||
});
|
||||
const res = handler();
|
||||
expect(res.body).toContain("plugin=billing%2Fv2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type BundledRedocAsset, resolveRedocAsset } from "../assets/assets.js";
|
||||
import type {
|
||||
RedocHttpHandler,
|
||||
RedocHttpResponse,
|
||||
} from "../handler/handler.js";
|
||||
import { renderRedocShell } from "../html/render-shell.js";
|
||||
import {
|
||||
parseRedocUiOptions,
|
||||
type RedocUiOptions,
|
||||
type RedocUiOptionsInput,
|
||||
} from "../options/options.js";
|
||||
|
||||
/** Signature of the per-plugin URL resolver. */
|
||||
export type OpenapiUrlForPlugin = (plugin: string) => string;
|
||||
|
||||
/** Input to {@link createPerPluginRedocUiHandler}. */
|
||||
export interface CreatePerPluginRedocUiHandlerInput
|
||||
extends RedocUiOptionsInput {
|
||||
/** The plugin slug (e.g. `"contacts"`). */
|
||||
readonly plugin: string;
|
||||
/**
|
||||
* Resolver returning the per-plugin filtered OpenAPI URL. Default:
|
||||
* `(p) => \`${options.openapiUrl}?plugin=${p}\``.
|
||||
*/
|
||||
readonly openapiUrlForPlugin?: OpenapiUrlForPlugin;
|
||||
/** Bundled runtime payload — required when `uiAssets = "bundled"`. */
|
||||
readonly bundledAsset?: BundledRedocAsset;
|
||||
/** Inline runtime body — required when `uiAssets = "inline"`. */
|
||||
readonly inlineAsset?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-plugin ReDoc UI handler. The rendered shell references
|
||||
* the plugin-filtered OpenAPI URL so a single host can ship a global
|
||||
* `/docs/ui/redoc` view and per-plugin `/docs/{plugin}/ui/redoc` views.
|
||||
*/
|
||||
export function createPerPluginRedocUiHandler(
|
||||
input: CreatePerPluginRedocUiHandlerInput
|
||||
): RedocHttpHandler {
|
||||
const {
|
||||
plugin,
|
||||
openapiUrlForPlugin,
|
||||
bundledAsset,
|
||||
inlineAsset,
|
||||
...optionsInput
|
||||
} = input;
|
||||
const options: RedocUiOptions = parseRedocUiOptions(optionsInput);
|
||||
const resolvedAsset = resolveRedocAsset({
|
||||
strategy: options.uiAssets,
|
||||
...(bundledAsset !== undefined ? { bundled: bundledAsset } : {}),
|
||||
...(inlineAsset !== undefined ? { inline: inlineAsset } : {}),
|
||||
});
|
||||
|
||||
const resolver: OpenapiUrlForPlugin =
|
||||
openapiUrlForPlugin ??
|
||||
((p: string): string => {
|
||||
const sep = options.openapiUrl.includes("?") ? "&" : "?";
|
||||
return `${options.openapiUrl}${sep}plugin=${encodeURIComponent(p)}`;
|
||||
});
|
||||
|
||||
const html = renderRedocShell({
|
||||
options,
|
||||
resolvedAsset,
|
||||
openapiUrlOverride: resolver(plugin),
|
||||
});
|
||||
|
||||
const response: RedocHttpResponse = Object.freeze({
|
||||
status: 200,
|
||||
headers: Object.freeze({
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}),
|
||||
body: html,
|
||||
});
|
||||
|
||||
return function perPluginRedocHandler(): RedocHttpResponse {
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
walk(full, out);
|
||||
} else {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const missing: string[] = [];
|
||||
for (const f of walk(SRC)) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
if (f.endsWith(".spec-conformance.test.ts")) continue;
|
||||
if (f.endsWith(".types.ts")) continue;
|
||||
if (f.endsWith("/index.ts")) continue;
|
||||
const md = f.replace(/\.ts$/, ".md");
|
||||
if (!existsSync(md)) missing.push(relative(ROOT, md));
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("every subpath export resolves to a real source file", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entries = pkg.exports as Record<string, unknown>;
|
||||
const missing: string[] = [];
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (key === "./package.json") continue;
|
||||
const distPath =
|
||||
typeof value === "object" && value !== null
|
||||
? (
|
||||
value as {
|
||||
import?: { default?: string };
|
||||
}
|
||||
).import?.default
|
||||
: (value as string);
|
||||
if (typeof distPath !== "string") continue;
|
||||
const srcPath = distPath
|
||||
.replace(/^\.\/dist\//, "./src/")
|
||||
.replace(/\.js$/, ".ts");
|
||||
const abs = join(ROOT, srcPath);
|
||||
if (!existsSync(abs)) missing.push(`${key} → ${srcPath}`);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime dependencies match the spec (error, tslib, zod only)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(deps).toEqual(["@suaveplan/error", "tslib", "zod"]);
|
||||
});
|
||||
|
||||
it("no test-runner imports leak into src/", { timeout: 5000 }, () => {
|
||||
const offenders: string[] = [];
|
||||
const re = /from\s+["'](vitest|bun:test)["']/;
|
||||
for (const f of walk(SRC)) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
const text = readFileSync(f, "utf8");
|
||||
if (re.test(text)) offenders.push(relative(ROOT, f));
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-openapi-ui-redoc/tsconfig.json
Normal file
12
packages/api-stack/api/api-openapi-ui-redoc/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"]
|
||||
}
|
||||
@@ -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", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-openapi-ui-redoc/vite.config.ts
Normal file
30
packages/api-stack/api/api-openapi-ui-redoc/vite.config.ts
Normal 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-redoc",
|
||||
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\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-openapi-ui-redoc/vitest.config.ts
Normal file
30
packages/api-stack/api/api-openapi-ui-redoc/vitest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user