Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreatePerPluginScalarUiHandlerInput,
|
||||
createPerPluginScalarUiHandler,
|
||||
type PerPluginScalarUiHandlerBundle,
|
||||
} from "./per-plugin.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# Per-plugin Scalar UI handler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The per-plugin module exposes `createPerPluginScalarUiHandler`, the
|
||||
variant the host mounts under `/docs/ui/scalar/<plugin>` so each plugin
|
||||
gets its own Scalar shell pointing at a filtered OpenAPI document
|
||||
(typically `/openapi.json?plugin=<name>`). It composes the standard
|
||||
`createScalarUiHandler` factory but rewrites the OpenAPI URL passthrough
|
||||
and fans out the OTel observation with a `{ plugin }` attribute so
|
||||
dashboards summing per-plugin mounts get distinct buckets without
|
||||
losing the top-line UI total.
|
||||
|
||||
## Features
|
||||
|
||||
- Defaults `uiEndpoint` to `/docs/ui/scalar/<plugin>` when the caller
|
||||
does not override it — keeps the host's `/docs/ui/scalar` namespace
|
||||
collision-free across N plugins.
|
||||
- Embeds the host-computed filtered URL verbatim — the host is
|
||||
responsible for query-string composition so this package never has to
|
||||
re-implement the OpenAPI filter semantics.
|
||||
- Same CSP + asset + Zod validation guarantees as
|
||||
`createScalarUiHandler`.
|
||||
- Per-plugin OTel attribute fan-out — every render emits both the
|
||||
baseline `{ ui: "scalar" }` and `{ ui: "scalar", plugin }` observations
|
||||
so dashboards can sum either way without losing the top-line total.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPerPluginScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar/per-plugin";
|
||||
|
||||
const { handler } = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
});
|
||||
|
||||
const response = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar/billing",
|
||||
});
|
||||
console.log(response.status); // 200
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPerPluginScalarUiHandler } from "@suaveplan/api-openapi-ui-scalar/per-plugin";
|
||||
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
theme: "deepSpace",
|
||||
assetMode: "bundled",
|
||||
uiEndpoint: "/docs/billing", // override the default mount path
|
||||
});
|
||||
|
||||
console.log(bundle.plugin); // "billing"
|
||||
console.log(bundle.options.uiEndpoint); // "/docs/billing"
|
||||
|
||||
// The shell embeds the filtered URL exactly once.
|
||||
const html = bundle.handler({
|
||||
method: "GET",
|
||||
path: "/docs/billing",
|
||||
}).body;
|
||||
console.assert(html.includes("/openapi.json?plugin=billing"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreatePerPluginScalarUiHandlerInput` — `Omit<ScalarUiOptionsInput,
|
||||
"openapiUrl">` plus `{ plugin, openapiUrlForPlugin, meterProvider?,
|
||||
bundledAssetProbe? }`.
|
||||
- `createPerPluginScalarUiHandler(input)` —
|
||||
`PerPluginScalarUiHandlerBundle`.
|
||||
- `PerPluginScalarUiHandlerBundle` extends `ScalarUiHandlerBundle` with
|
||||
the resolved `plugin` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The per-plugin factory deliberately delegates to the base
|
||||
`createScalarUiHandler` so the per-plugin path inherits every
|
||||
validation + CSP + asset-resolution guarantee. The only divergences are
|
||||
the rewritten OpenAPI URL, the default `uiEndpoint`, and the extra
|
||||
counter observation for the per-plugin attribute. Doubling the counter
|
||||
is intentional — the bare `{ ui: "scalar" }` total stays correct while
|
||||
the per-plugin breakdown remains queryable from the same series.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createPerPluginScalarUiHandler } from "./per-plugin.js";
|
||||
|
||||
describe("createPerPluginScalarUiHandler", () => {
|
||||
it("per-plugin handler embeds filtered URL", { timeout: 5000 }, () => {
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
});
|
||||
const response = bundle.handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar/billing",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const occurrences =
|
||||
response.body.split("/openapi.json?plugin=billing").length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
expect(bundle.plugin).toBe("billing");
|
||||
expect(bundle.options.uiEndpoint).toBe("/docs/ui/scalar/billing");
|
||||
});
|
||||
|
||||
it("honours caller-supplied uiEndpoint override", { timeout: 5000 }, () => {
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
uiEndpoint: "/docs/billing",
|
||||
});
|
||||
expect(bundle.options.uiEndpoint).toBe("/docs/billing");
|
||||
});
|
||||
|
||||
it("supports nonce passthrough", { timeout: 5000 }, () => {
|
||||
const { handler } = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
});
|
||||
const response = handler({
|
||||
method: "GET",
|
||||
path: "/docs/ui/scalar/billing",
|
||||
nonce: "n-9b2f7c2a",
|
||||
});
|
||||
expect(response.body).toContain('nonce="n-9b2f7c2a"');
|
||||
});
|
||||
|
||||
it("exposes the underlying assetHandler", { timeout: 5000 }, () => {
|
||||
const bundle = createPerPluginScalarUiHandler({
|
||||
plugin: "billing",
|
||||
openapiUrlForPlugin: "/openapi.json?plugin=billing",
|
||||
assetMode: "bundled",
|
||||
});
|
||||
const response = bundle.assetHandler({
|
||||
method: "GET",
|
||||
path: `${bundle.options.uiEndpoint}/assets/scalar.js`,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import type { BundledAssetProbe } from "../assets/assets.js";
|
||||
import {
|
||||
createScalarUiHandler,
|
||||
type ScalarHttpRequest,
|
||||
type ScalarUiHandlerBundle,
|
||||
} from "../handler/handler.js";
|
||||
import { recordUiRequest } from "../instruments/instruments.js";
|
||||
import type { ScalarUiOptionsInput } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Input to {@link createPerPluginScalarUiHandler}. The host computes the
|
||||
* plugin-filtered OpenAPI document URL upstream — usually
|
||||
* `"/openapi.json?plugin=<name>"` — and passes it through verbatim so
|
||||
* the shell embeds the exact filter parameters the host wants exposed.
|
||||
*/
|
||||
export interface CreatePerPluginScalarUiHandlerInput
|
||||
extends Omit<ScalarUiOptionsInput, "openapiUrl"> {
|
||||
/** Plugin name whose schema this UI mount surfaces. */
|
||||
readonly plugin: string;
|
||||
/** Filtered URL the shell points at — e.g. `/openapi.json?plugin=billing`. */
|
||||
readonly openapiUrlForPlugin: string;
|
||||
/** Optional OTel meter provider — defaults to the global. */
|
||||
readonly meterProvider?: MeterProvider;
|
||||
/** Optional asset-bundle probe (DI for tests). */
|
||||
readonly bundledAssetProbe?: BundledAssetProbe;
|
||||
}
|
||||
|
||||
/** Bundle returned by the per-plugin factory — adds `plugin` for routing. */
|
||||
export interface PerPluginScalarUiHandlerBundle extends ScalarUiHandlerBundle {
|
||||
readonly plugin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a per-plugin Scalar UI handler whose shell points at the
|
||||
* plugin-filtered OpenAPI URL.
|
||||
*
|
||||
* `uiEndpoint` defaults to `/docs/ui/scalar/<plugin>` when the caller
|
||||
* does not override it so multiple per-plugin shells can co-exist under
|
||||
* the host's `/docs/ui/scalar` namespace without colliding. The
|
||||
* counter observation is overridden so the per-plugin mount records
|
||||
* `{ ui: "scalar", plugin }` instead of the bare `{ ui: "scalar" }`
|
||||
* label set the global handler emits.
|
||||
*/
|
||||
export function createPerPluginScalarUiHandler(
|
||||
input: CreatePerPluginScalarUiHandlerInput
|
||||
): PerPluginScalarUiHandlerBundle {
|
||||
const { plugin, openapiUrlForPlugin, ...rest } = input;
|
||||
const defaultUiEndpoint = `/docs/ui/scalar/${plugin}`;
|
||||
const baseBundle = createScalarUiHandler({
|
||||
...rest,
|
||||
openapiUrl: openapiUrlForPlugin,
|
||||
uiEndpoint: rest.uiEndpoint ?? defaultUiEndpoint,
|
||||
});
|
||||
const pluginHandler = (request: ScalarHttpRequest) => {
|
||||
const response = baseBundle.handler(request);
|
||||
// The base handler already recorded the bare `{ ui }` observation.
|
||||
// Layer on the per-plugin attribute set so dashboards summing
|
||||
// per-plugin mounts get distinct buckets without losing the
|
||||
// top-line total.
|
||||
recordUiRequest(baseBundle.instruments, { plugin });
|
||||
return response;
|
||||
};
|
||||
return Object.freeze({ ...baseBundle, handler: pluginHandler, plugin });
|
||||
}
|
||||
Reference in New Issue
Block a user