// A self-contained, spec-driven HTTP operations driver for the e2e harness (issue #157, S3).
//
// The driver reads the *booted app's own* OpenAPI document, enumerates its operations, and lets a
// test call them by `operationId` — the same stable identity the runtime dispatches on
// (`
/`) and the ADR-0059 "one HTTP surface" treats as the source of truth. A test
// never hard-codes a route path or method: those are derived from the spec, so the driver can never
// drift from the surface it drives (AGENTS.md "Derivation Over Duplication").
//
// Like `wasm-engine.ts`, this module is deliberately *self-contained*: it does NOT import urban's
// `parseSpec`/`collectOperations`. The testkit and `@nanobpm/urban` both resolve to the app-under-
// test's own `node_modules` in an e2e (an app can be on an older urban than the testkit's peer
// floor), so importing a runtime-internal export would couple the driver to a specific urban
// version. A tiny standalone OpenAPI enumerator is trivial and version-robust, and mirrors exactly
// how the runtime mounts operations (fixed `/app/api` base + the operation's path template).
import { parse as parseYaml } from "yaml";
import type { HttpResponse } from "@nanobpm/urban/runtime";
/** The fixed operation base the runtime mounts every operation under (urban `api.ts` `API_BASE`).
* A constant there, so a constant here — the two are the single contract this driver relies on. */
const API_BASE = "/app/api";
/** OpenAPI HTTP method keys, lower-cased as they appear on a Path Item Object. */
const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
function isRecord(v: unknown): v is Record {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** One enumerated operation: its id, HTTP method, and OpenAPI path template.
* `pathParams` are the `{name}` placeholders in the template — the names a caller MUST supply. */
export interface ApiOperation {
readonly operationId: string;
readonly method: HttpMethod;
/** The OpenAPI path template, e.g. "/invoices/{id}" (WITHOUT the `/app/api` base). */
readonly path: string;
/** Placeholder names in `path`, e.g. ["id"] for "/invoices/{id}". */
readonly pathParams: readonly string[];
}
/**
* Parse an OpenAPI document from text. JSON is tried first (fast path + precise errors; also covers
* a generated `openapi.json`), then YAML (which subsumes JSON, so authored `.yaml`/`.yml` load).
* Mirrors urban's own `parseSpec` so a spec that loads in production loads here too — including its
* root-shape guard: a non-object root (e.g. `42`, `[]`, `null`) is rejected, so a malformed spec
* fails fast here exactly as the runtime rejects it ("spec must be an object"), rather than silently
* enumerating zero operations and surfacing later as a confusing "unknown operationId".
*/
export function parseOpenApi(text: string): unknown {
let doc: unknown;
try {
doc = JSON.parse(text);
} catch (jsonError) {
try {
doc = parseYaml(text);
} catch (yamlError) {
const jsonMessage = jsonError instanceof Error ? jsonError.message : String(jsonError);
const yamlMessage = yamlError instanceof Error ? yamlError.message : String(yamlError);
throw new Error(
`testkit: OpenAPI spec is not valid JSON or YAML: JSON parse error: ${jsonMessage}; ` +
`YAML parse error: ${yamlMessage}`,
);
}
}
if (!isRecord(doc)) {
throw new Error("testkit: OpenAPI spec must be an object (a mapping at the document root)");
}
return doc;
}
/** An operationId names the delegate module file (`/`), so the runtime only mounts
* it when it is a single safe path segment — separators (`/`, `\`) and parent-dir traversal (`..`,
* `.`) are rejected so a crafted spec can't import a file outside the operations directory. Mirrors
* urban's `isSafeOperationId` (openapi/spec.ts) so the driver enumerates exactly the mounted surface
* and never lists/calls an operation the runtime would have skipped. Kept standalone for the same
* version-robustness reason the whole module is (see file header) rather than importing urban's. */
function isSafeOperationId(id: string): boolean {
return id.length > 0 && !id.includes("/") && !id.includes("\\") && !id.includes("..") && id !== ".";
}
/** Extract the `{name}` placeholders from a path template, in order of appearance. */
function templateParams(path: string): string[] {
const names: string[] = [];
const re = /\{([^{}]+)\}/g;
let m: RegExpExecArray | null = re.exec(path);
while (m !== null) {
names.push(m[1]);
m = re.exec(path);
}
return names;
}
/**
* Enumerate the operations of an OpenAPI document exactly as the runtime does: walk `paths` → each
* HTTP method → the operation's `operationId`. Operations without an `operationId` are skipped (the
* runtime cannot dispatch them either), as are those whose `operationId` is not a safe path segment
* (`isSafeOperationId`) — the runtime never mounts those, so listing them here would let a test call
* an operation that does not exist. Paths are visited in sorted order for a stable enumeration.
*/
export function collectOperations(doc: unknown): ApiOperation[] {
const out: ApiOperation[] = [];
const paths = isRecord(doc) && isRecord(doc.paths) ? doc.paths : {};
for (const path of Object.keys(paths).sort()) {
const item = paths[path];
if (!isRecord(item)) continue;
const pathParams = templateParams(path);
for (const method of HTTP_METHODS) {
const opRaw = item[method];
if (!isRecord(opRaw)) continue;
const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
if (!operationId || !isSafeOperationId(operationId)) continue;
out.push({ operationId, method, path, pathParams });
}
}
return out;
}
/** A route invocation against the in-process router — the low-level primitive the driver builds on. */
export interface DriverRouteRequest {
method: string;
path: string;
query?: Record | URLSearchParams;
headers?: Record | Headers;
body?: string;
}
/** The response returned by {@link ApiDriver.call} and {@link ApiDriver.callRoute}. */
export interface ApiResponse {
/** HTTP status (defaults to 200 when the handler omitted it, matching the runtime). */
readonly status: number;
/** Response headers. */
readonly headers: Headers;
/** The raw response body text (empty string when the handler returned no body). */
readonly text: string;
/** The response body parsed as JSON when it is JSON (by `content-type` or a successful parse),
* otherwise the raw text. Typed as `T` for caller convenience; no runtime cast is performed. */
readonly body: T;
}
/** Per-call inputs for {@link ApiDriver.call}. All are optional. */
export interface ApiCallOptions {
/** Values for the operation's `{name}` path placeholders. Every placeholder must be supplied. */
params?: Record;
/** Query-string parameters (appended to the URL). */
query?: Record | URLSearchParams;
/** Request body — JSON-serialized, with `content-type: application/json` set automatically.
* Omit for a body-less request (e.g. a GET); pass a string via {@link ApiDriver.callRoute} for
* a non-JSON body. */
body?: unknown;
/** Extra request headers (merged over the automatic `content-type`). */
headers?: Record | Headers;
}
/** Drives a booted app's OpenAPI operations by `operationId`, plus raw routes via `callRoute`. */
export interface ApiDriver {
/**
* Call an operation by its `operationId`. Fills the path template from `params`, prefixes the
* `/app/api` base, JSON-serializes `body`, and dispatches through the in-process router. Throws
* on an unknown `operationId` or a missing required path parameter (a test bug, surfaced loudly).
*/
call(operationId: string, opts?: ApiCallOptions): Promise>;
/**
* Call a raw route (a page action, a hook, or any path not in the operation set) by method + path.
* A thin, response-parsing wrapper over the low-level `ui.call`; the caller supplies the exact
* path (no `/app/api` base is added).
*/
callRoute(req: DriverRouteRequest): Promise>;
/** The `operationId`s this app exposes, in enumeration order (feeds a coverage gate later). */
operationIds(): string[];
/** Look up one enumerated operation by id (its method + path template), or undefined. */
operation(operationId: string): ApiOperation | undefined;
}
function toQuery(query: ApiCallOptions["query"]): URLSearchParams {
if (query instanceof URLSearchParams) return query;
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(query ?? {})) sp.set(k, String(v));
return sp;
}
function mergeHeaders(base: Record, extra: ApiCallOptions["headers"]): Headers {
const h = new Headers(base);
const extraHeaders = extra instanceof Headers ? extra : new Headers(extra ?? {});
extraHeaders.forEach((value, key) => h.set(key, value));
return h;
}
/** Fill a path template's `{name}` placeholders from `params`, throwing on any missing value. */
function fillPath(op: ApiOperation, params: Record): string {
return op.path.replace(/\{([^{}]+)\}/g, (_full, name: string) => {
if (!Object.hasOwn(params, name)) {
throw new Error(
`testkit api.call("${op.operationId}"): missing path parameter "${name}" ` +
`(template ${op.path})`,
);
}
return encodeURIComponent(String(params[name]));
});
}
function isJsonContentType(headers: Headers): boolean {
const ct = headers.get("content-type");
return ct !== null && ct.toLowerCase().includes("json");
}
/** Build an {@link ApiResponse} from the router's raw {@link HttpResponse}, parsing a JSON body. */
function toApiResponse(res: HttpResponse): ApiResponse {
const headers = new Headers(res.headers ?? {});
const text = res.body ?? "";
let body: unknown = text;
if (text.length > 0) {
if (isJsonContentType(headers)) {
body = JSON.parse(text);
} else {
// No/other content-type: attempt JSON opportunistically (operations JSON-serialize by
// default) but fall back to the raw text so a plain-text route still returns its body.
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
}
// The response body is an untyped runtime boundary (whatever the handler JSON-serialized). The
// generic `T` is an ergonomic annotation the test opts into; the driver performs NO runtime
// validation of the shape — that is the test's own assertion. This is the one place a cast is
// genuinely unavoidable, per the AGENTS.md untyped-boundary exception.
// biome-ignore lint/plugin: HTTP response body is an untyped runtime boundary; T is a caller annotation, not a validated shape.
return { status: res.status ?? 200, headers, text, body: body as T };
}
/**
* Build an {@link ApiDriver} over an enumerated operation set and a low-level route caller (the
* harness's `ui.call`). Self-contained: no urban runtime imports beyond the `HttpResponse` type.
*/
export function createApiDriver(
operations: readonly ApiOperation[],
uiCall: (req: DriverRouteRequest) => Promise,
): ApiDriver {
const byId = new Map();
for (const op of operations) byId.set(op.operationId, op);
const callRoute = async (req: DriverRouteRequest): Promise> => {
const res = await uiCall(req);
return toApiResponse(res);
};
return {
callRoute,
operationIds: () => operations.map((op) => op.operationId),
operation: (operationId) => byId.get(operationId),
call: async (operationId: string, opts: ApiCallOptions = {}): Promise> => {
const op = byId.get(operationId);
if (!op) {
const known = operations.map((o) => o.operationId).sort().join(", ") || "(none)";
throw new Error(
`testkit api.call: unknown operationId "${operationId}". Known operations: ${known}`,
);
}
const path = API_BASE + fillPath(op, opts.params ?? {});
const hasBody = opts.body !== undefined;
const headers = mergeHeaders(
hasBody ? { "content-type": "application/json" } : {},
opts.headers,
);
return callRoute({
method: op.method.toUpperCase(),
path,
query: toQuery(opts.query),
headers,
body: hasBody ? JSON.stringify(opts.body) : undefined,
});
},
};
}