@nanobpm/urban 0.31.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/modules/api.d.ts +8 -0
- package/dist/runtime/core/modules/api.js +116 -4
- package/dist/runtime/core/modules/pages.d.ts +6 -0
- package/dist/runtime/core/modules/pages.js +37 -7
- package/dist/runtime/core/router.d.ts +7 -4
- package/dist/runtime/core/router.js +8 -5
- package/package.json +2 -2
- package/src/runtime/core/modules/api.test.ts +77 -3
- package/src/runtime/core/modules/api.ts +134 -4
- package/src/runtime/core/modules/pages.test.ts +28 -0
- package/src/runtime/core/modules/pages.ts +43 -7
- package/src/runtime/core/router.test.ts +6 -0
- package/src/runtime/core/router.ts +8 -5
|
@@ -9,6 +9,10 @@ export interface ApiBinding {
|
|
|
9
9
|
base?: string;
|
|
10
10
|
validateResponses?: "dev" | "always" | "never";
|
|
11
11
|
eject?: boolean;
|
|
12
|
+
/** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
|
|
13
|
+
* off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
|
|
14
|
+
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
15
|
+
docs?: boolean | string;
|
|
12
16
|
}
|
|
13
17
|
/** The `{ params, query, body }` contract a typed operation is generic over. */
|
|
14
18
|
export interface OperationContract {
|
|
@@ -48,6 +52,10 @@ export type OperationHandler<Req extends OperationContract = DefaultContract, Re
|
|
|
48
52
|
export declare function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(_id: string, handler: OperationHandler<Req, Res>): OperationHandler<Req, Res>;
|
|
49
53
|
/** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
|
|
50
54
|
export declare function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined;
|
|
55
|
+
/** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
|
|
56
|
+
* or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
|
|
57
|
+
* the docs without re-deriving the path. */
|
|
58
|
+
export declare function apiDocsPath(manifest: unknown): string | undefined;
|
|
51
59
|
export interface ApiHandle {
|
|
52
60
|
readonly name: string;
|
|
53
61
|
routes: Route[];
|
|
@@ -12,9 +12,41 @@
|
|
|
12
12
|
// `x-urban-eject: true` OpenAPI vendor extension. The delegate is always additively imperative: it
|
|
13
13
|
// gets the raw `req` and full `AppApi` on top of the validated input.
|
|
14
14
|
import { errorMessage } from "../guards.js";
|
|
15
|
-
import { json, normalizeRoutePath } from "../router.js";
|
|
15
|
+
import { html, json, normalizeRoutePath } from "../router.js";
|
|
16
16
|
import { resolveAppPath } from "./datasource.js";
|
|
17
17
|
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
|
|
18
|
+
/** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
|
|
19
|
+
* Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
|
|
20
|
+
* ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
|
|
21
|
+
const SWAGGER_UI_VERSION = "5.17.14";
|
|
22
|
+
/** Escape HTML-significant characters before embedding a value (the app name) in the docs markup. */
|
|
23
|
+
function escapeHtml(s) {
|
|
24
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
|
25
|
+
}
|
|
26
|
+
/** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
|
|
27
|
+
* unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
|
|
28
|
+
* which keeps the docs route decoupled from spec-load failures. */
|
|
29
|
+
function swaggerUiPage(title, specUrl) {
|
|
30
|
+
const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
|
|
31
|
+
const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
|
|
32
|
+
return `<!doctype html>
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="utf-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
37
|
+
<title>${escapeHtml(title)}</title>
|
|
38
|
+
<link rel="stylesheet" href="${css}">
|
|
39
|
+
<style>body{margin:0}</style>
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<div id="swagger-ui"></div>
|
|
43
|
+
<script src="${bundle}" crossorigin="anonymous"></script>
|
|
44
|
+
<script>
|
|
45
|
+
window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
|
|
46
|
+
</script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>`;
|
|
49
|
+
}
|
|
18
50
|
/**
|
|
19
51
|
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
20
52
|
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
@@ -47,6 +79,7 @@ function readApiBinding(manifest) {
|
|
|
47
79
|
const base = Reflect.get(raw, "base");
|
|
48
80
|
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
49
81
|
const eject = Reflect.get(raw, "eject");
|
|
82
|
+
const docs = Reflect.get(raw, "docs");
|
|
50
83
|
// Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
|
|
51
84
|
return {
|
|
52
85
|
spec: spec.trim(),
|
|
@@ -56,8 +89,32 @@ function readApiBinding(manifest) {
|
|
|
56
89
|
? validateResponses
|
|
57
90
|
: undefined,
|
|
58
91
|
eject: eject === true,
|
|
92
|
+
// Docs default ON (spec-first apps get Swagger for free); `false` disables; a non-empty
|
|
93
|
+
// string overrides the route. Anything else (including whitespace) falls back to the default.
|
|
94
|
+
docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
|
|
59
95
|
};
|
|
60
96
|
}
|
|
97
|
+
/** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
|
|
98
|
+
* serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
|
|
99
|
+
function resolveApiRoutes(binding) {
|
|
100
|
+
const base = normalizeRoutePath(binding.base, "/app/api");
|
|
101
|
+
const docsEnabled = binding.docs !== false;
|
|
102
|
+
// Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
|
|
103
|
+
// of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
|
|
104
|
+
// that owns the `${base}/` prefix (a string `docs` overrides the whole route).
|
|
105
|
+
const docsBase = normalizeRoutePath(typeof binding.docs === "string" ? binding.docs : `${base}-docs`, `${base}-docs`);
|
|
106
|
+
return { base, docsEnabled, docsBase };
|
|
107
|
+
}
|
|
108
|
+
/** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
|
|
109
|
+
* or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
|
|
110
|
+
* the docs without re-deriving the path. */
|
|
111
|
+
export function apiDocsPath(manifest) {
|
|
112
|
+
const binding = readApiBinding(manifest);
|
|
113
|
+
if (!binding)
|
|
114
|
+
return undefined;
|
|
115
|
+
const { docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
116
|
+
return docsEnabled ? docsBase : undefined;
|
|
117
|
+
}
|
|
61
118
|
/** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
|
|
62
119
|
* validate. A value that will not coerce is left as-is and fails the type check with a clear
|
|
63
120
|
* message. The delegate still receives the original string(s). */
|
|
@@ -89,9 +146,16 @@ export function mountApi(ctx, app) {
|
|
|
89
146
|
// Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
|
|
90
147
|
// ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
|
|
91
148
|
// like "app/api" would never match request paths, which always start with "/".
|
|
92
|
-
|
|
149
|
+
//
|
|
150
|
+
// The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
|
|
151
|
+
// this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
|
|
152
|
+
// shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
|
|
153
|
+
// copy of that logic here. The spec JSON is served under the docs path so the operations
|
|
154
|
+
// namespace stays purely operations.
|
|
155
|
+
const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
93
156
|
const dir = binding.dir ?? "operations";
|
|
94
157
|
const surfaceEject = binding.eject === true;
|
|
158
|
+
const specRoutePath = `${docsBase}/openapi.json`;
|
|
95
159
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
96
160
|
// pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
|
|
97
161
|
// than failing app boot.
|
|
@@ -351,9 +415,57 @@ export function mountApi(ctx, app) {
|
|
|
351
415
|
source: `api:${binding.spec}`,
|
|
352
416
|
handler: dispatch,
|
|
353
417
|
};
|
|
418
|
+
// Docs routes (ADR 0058): the Swagger UI page + the OpenAPI JSON it reads. Both are exact GET
|
|
419
|
+
// routes on the `${base}-docs` sibling namespace, so they are wholly independent of the
|
|
420
|
+
// operation dispatcher above.
|
|
421
|
+
const serveSpec = async () => {
|
|
422
|
+
let d;
|
|
423
|
+
try {
|
|
424
|
+
d = await loadDoc();
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
return json({ error: errorMessage(e) }, 500);
|
|
428
|
+
}
|
|
429
|
+
// Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
|
|
430
|
+
// (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
|
|
431
|
+
// makes the interactive console call the real Urban routes rather than the origin root.
|
|
432
|
+
return json({ ...d, servers: [{ url: base }] });
|
|
433
|
+
};
|
|
434
|
+
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
435
|
+
const serveDocs = () => html(swaggerUiPage(docsTitle, specRoutePath));
|
|
436
|
+
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
437
|
+
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
438
|
+
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
439
|
+
// (slash-less) `docsBase` that `apiDocsPath` and every internal link already use.
|
|
440
|
+
const redirectDocsSlash = () => ({ status: 308, headers: { location: docsBase } });
|
|
441
|
+
const docsRoutes = docsEnabled
|
|
442
|
+
? [
|
|
443
|
+
{ method: "GET", path: docsBase, source: `api-docs:${binding.spec}`, handler: serveDocs },
|
|
444
|
+
{
|
|
445
|
+
method: "GET",
|
|
446
|
+
path: `${docsBase}/`,
|
|
447
|
+
source: `api-docs:${binding.spec}`,
|
|
448
|
+
handler: redirectDocsSlash,
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
method: "GET",
|
|
452
|
+
path: specRoutePath,
|
|
453
|
+
source: `api-docs:${binding.spec}`,
|
|
454
|
+
handler: serveSpec,
|
|
455
|
+
},
|
|
456
|
+
]
|
|
457
|
+
: [];
|
|
354
458
|
return {
|
|
355
459
|
name: "api",
|
|
356
|
-
routes: [rootRoute, route],
|
|
357
|
-
describe: () => ({
|
|
460
|
+
routes: [...docsRoutes, rootRoute, route],
|
|
461
|
+
describe: () => ({
|
|
462
|
+
api: {
|
|
463
|
+
spec: binding.spec,
|
|
464
|
+
base,
|
|
465
|
+
dir,
|
|
466
|
+
eject: surfaceEject,
|
|
467
|
+
docs: docsEnabled ? { ui: docsBase, spec: specRoutePath } : false,
|
|
468
|
+
},
|
|
469
|
+
}),
|
|
358
470
|
};
|
|
359
471
|
}
|
|
@@ -18,6 +18,12 @@ export interface PagesOptions {
|
|
|
18
18
|
rowLimit?: number;
|
|
19
19
|
/** The injected default datasource name (the alias apps bind to). Default `app`. */
|
|
20
20
|
sourceName?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The app's Swagger UI route (from the `api` binding, resolved by `apiDocsPath`). When set,
|
|
23
|
+
* the shell renders a persistent "API docs" badge linking to it — so a spec-first app surfaces
|
|
24
|
+
* its interactive docs from its own UI for free. Omitted → no badge (app declares no `api`).
|
|
25
|
+
*/
|
|
26
|
+
apiDocsPath?: string;
|
|
21
27
|
}
|
|
22
28
|
export interface PagesDeps {
|
|
23
29
|
db: PagesDataSource;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { errorMessage, isRecord } from "../guards.js";
|
|
18
18
|
import { html, json } from "../router.js";
|
|
19
19
|
import { cancelInstanceReconciling } from "./cancel.js";
|
|
20
|
+
import { apiDocsPath } from "./api.js";
|
|
20
21
|
import { quoteIdent } from "./gateway.js";
|
|
21
22
|
/** A JavaScript-body response (the renderer module served at /app/runtime.js). */
|
|
22
23
|
function javascript(body, status = 200) {
|
|
@@ -66,7 +67,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
66
67
|
return p;
|
|
67
68
|
};
|
|
68
69
|
const routes = [];
|
|
69
|
-
const shell = html(rendererShell(homePage));
|
|
70
|
+
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
70
71
|
// ── the renderer shell + module ─────────────────────────────────────────
|
|
71
72
|
routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
|
|
72
73
|
routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
|
|
@@ -335,6 +336,9 @@ export function mountPages(ctx, app) {
|
|
|
335
336
|
homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
|
|
336
337
|
rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
|
|
337
338
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
339
|
+
// Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
|
|
340
|
+
// binding (resolved by the api module, so the path never drifts from where docs mount).
|
|
341
|
+
apiDocsPath: apiDocsPath(ctx.manifest),
|
|
338
342
|
};
|
|
339
343
|
const sourceName = opts.sourceName ?? "app";
|
|
340
344
|
const bindings = ctx.manifest.instanceTracking ?? [];
|
|
@@ -361,7 +365,13 @@ export function mountPages(ctx, app) {
|
|
|
361
365
|
function escapeAttr(s) {
|
|
362
366
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
363
367
|
}
|
|
364
|
-
function rendererShell(homePage) {
|
|
368
|
+
function rendererShell(homePage, apiDocsPath) {
|
|
369
|
+
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
370
|
+
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
371
|
+
// tab a handle back to this window (reverse-tabnabbing).
|
|
372
|
+
const apiDocsBadge = apiDocsPath
|
|
373
|
+
? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
|
|
374
|
+
: "";
|
|
365
375
|
return `<!doctype html>
|
|
366
376
|
<html lang="en">
|
|
367
377
|
<head>
|
|
@@ -370,7 +380,7 @@ function rendererShell(homePage) {
|
|
|
370
380
|
<title>Urban App</title>
|
|
371
381
|
<style>${RENDERER_CSS}</style>
|
|
372
382
|
</head>
|
|
373
|
-
<body
|
|
383
|
+
<body>${apiDocsBadge}
|
|
374
384
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
375
385
|
<script type="module" src="./app/runtime.js"></script>
|
|
376
386
|
</body>
|
|
@@ -417,6 +427,8 @@ const RENDERER_CSS = `
|
|
|
417
427
|
* { box-sizing: border-box; }
|
|
418
428
|
body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
|
|
419
429
|
.pc-empty { color:var(--nano-text-faint); }
|
|
430
|
+
.pc-apidocs { position:fixed; top:.75rem; right:.75rem; z-index:10; font-size:.8rem; text-decoration:none; padding:.3rem .6rem; border:1px solid var(--nano-edge); border-radius:999px; background:var(--nano-panel); color:var(--nano-text-muted); }
|
|
431
|
+
.pc-apidocs:hover { color:var(--nano-text); border-color:var(--nano-accent); }
|
|
420
432
|
.pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
|
|
421
433
|
.pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
|
|
422
434
|
.pc-body { margin:.5rem 0; }
|
|
@@ -663,10 +675,28 @@ function renderActionForm(node) {
|
|
|
663
675
|
}
|
|
664
676
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
665
677
|
try {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
678
|
+
// Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
|
|
679
|
+
// publishMessage -> engine.publishMessage, which correlates a message to a message start
|
|
680
|
+
// (or intermediate catch) event. correlationKeyField names the form field that carries
|
|
681
|
+
// the correlation key; the whole form becomes the message variables.
|
|
682
|
+
if (p.action && p.action.kind === "publishMessage") {
|
|
683
|
+
const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
|
|
684
|
+
if (!correlationKey) {
|
|
685
|
+
msg.className = "pc-msg err";
|
|
686
|
+
msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
await getJSON("/app/actions/message", { method: "POST",
|
|
690
|
+
headers: { "content-type": "application/json" },
|
|
691
|
+
body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
|
|
692
|
+
msg.className = "pc-msg ok";
|
|
693
|
+
msg.textContent = "Message published";
|
|
694
|
+
} else {
|
|
695
|
+
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
696
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
697
|
+
msg.className = "pc-msg ok";
|
|
698
|
+
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
699
|
+
}
|
|
670
700
|
for (const input of inputs.values()) input.value = "";
|
|
671
701
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
672
702
|
} catch (e) {
|
|
@@ -12,10 +12,13 @@ export declare function json(body: unknown, status?: number): HttpResponse;
|
|
|
12
12
|
export declare function html(body: string, status?: number): HttpResponse;
|
|
13
13
|
export declare function text(body: string, status?: number): HttpResponse;
|
|
14
14
|
/**
|
|
15
|
-
* Normalize a manifest-supplied route path:
|
|
16
|
-
* slashes. Incoming request paths always start with "/", so a
|
|
17
|
-
* lacking one (e.g. "hooks/x") would otherwise never match.
|
|
18
|
-
*
|
|
15
|
+
* Normalize a manifest-supplied route path: collapse leading slashes to exactly
|
|
16
|
+
* one, drop trailing slashes. Incoming request paths always start with "/", so a
|
|
17
|
+
* manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
|
|
18
|
+
* multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
|
|
19
|
+
* entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
|
|
20
|
+
* path instead of becoming a protocol-relative URL that navigates off-origin. Falls
|
|
21
|
+
* back to `fallback` when the value is missing or collapses to empty ("/").
|
|
19
22
|
*/
|
|
20
23
|
export declare function normalizeRoutePath(value: string | undefined, fallback: string): string;
|
|
21
24
|
/** Build a single HttpHandler that dispatches across the given routes. */
|
|
@@ -11,14 +11,17 @@ export function text(body, status = 200) {
|
|
|
11
11
|
return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Normalize a manifest-supplied route path:
|
|
15
|
-
* slashes. Incoming request paths always start with "/", so a
|
|
16
|
-
* lacking one (e.g. "hooks/x") would otherwise never match.
|
|
17
|
-
*
|
|
14
|
+
* Normalize a manifest-supplied route path: collapse leading slashes to exactly
|
|
15
|
+
* one, drop trailing slashes. Incoming request paths always start with "/", so a
|
|
16
|
+
* manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
|
|
17
|
+
* multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
|
|
18
|
+
* entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
|
|
19
|
+
* path instead of becoming a protocol-relative URL that navigates off-origin. Falls
|
|
20
|
+
* back to `fallback` when the value is missing or collapses to empty ("/").
|
|
18
21
|
*/
|
|
19
22
|
export function normalizeRoutePath(value, fallback) {
|
|
20
23
|
const raw = (value ?? fallback).trim();
|
|
21
|
-
const withLead = raw.
|
|
24
|
+
const withLead = `/${raw.replace(/^\/+/, "")}`;
|
|
22
25
|
const noTrail = withLead.replace(/\/+$/, "");
|
|
23
26
|
return noTrail || fallback;
|
|
24
27
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
69
|
-
"create-urban-app": "^0.
|
|
69
|
+
"create-urban-app": "^0.8.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@types/node": "^22.20.1",
|
|
@@ -5,7 +5,7 @@ import type { AppManifest } from "../manifest.ts";
|
|
|
5
5
|
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
6
6
|
import { makeRouter } from "../router.ts";
|
|
7
7
|
import { DataLayer } from "./datasource.ts";
|
|
8
|
-
import { mountApi, resolveOperationHandler, type OperationHandler } from "./api.ts";
|
|
8
|
+
import { mountApi, resolveOperationHandler, apiDocsPath, type ApiHandle, type OperationHandler } from "./api.ts";
|
|
9
9
|
|
|
10
10
|
function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
|
|
11
11
|
return {
|
|
@@ -72,9 +72,10 @@ const spec = JSON.stringify({
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
interface Built {
|
|
75
|
-
router: (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
|
|
75
|
+
router: (r: HttpRequest) => Promise<{ status?: number; body?: string; headers?: Record<string, string> }>;
|
|
76
76
|
imported: string[];
|
|
77
77
|
logs: Array<{ level: string; msg: string }>;
|
|
78
|
+
handle: ApiHandle;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function build(
|
|
@@ -108,7 +109,12 @@ function build(
|
|
|
108
109
|
const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
|
|
109
110
|
const handle = mountApi(ctx, appFixture());
|
|
110
111
|
const router = makeRouter(handle.routes);
|
|
111
|
-
return { router: (r) => Promise.resolve(router(r)), imported, logs };
|
|
112
|
+
return { router: (r) => Promise.resolve(router(r)), imported, logs, handle };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Mount `mountApi` and return the handle directly (for asserting `describe()`). */
|
|
116
|
+
function buildHandle(api: Record<string, unknown>): { handle: ApiHandle } {
|
|
117
|
+
return { handle: build(api, {}).handle };
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
test("resolveOperationHandler prefers default (function) then handler", () => {
|
|
@@ -443,3 +449,71 @@ test("an unsafe operationId never mounts (skipped) — a crafted spec can't impo
|
|
|
443
449
|
assert.equal(res.status, 404); // no route mounted for the skipped op
|
|
444
450
|
assert.equal(imported.some((p) => p.includes("evil")), false);
|
|
445
451
|
});
|
|
452
|
+
|
|
453
|
+
test("serves the OpenAPI spec at {base}-docs/openapi.json, rebased to the mounted namespace", async () => {
|
|
454
|
+
const { router } = build({ spec: "openapi.json" }, {});
|
|
455
|
+
const res = await router(req("GET", "/app/api-docs/openapi.json"));
|
|
456
|
+
assert.equal(res.status, 200);
|
|
457
|
+
assert.match(res.headers?.["content-type"] ?? "", /application\/json/);
|
|
458
|
+
const doc = JSON.parse(res.body ?? "{}");
|
|
459
|
+
// The doc is served intact...
|
|
460
|
+
assert.ok(doc.paths["/invoices"], "keeps the operation paths");
|
|
461
|
+
// ...but `servers` is rebased to `base` so Swagger UI "Try it out" hits the real routes
|
|
462
|
+
// (operations mount under /app/api, not the spec's bare /invoices).
|
|
463
|
+
assert.deepEqual(doc.servers, [{ url: "/app/api" }]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("serves the Swagger UI page at {base}-docs by default", async () => {
|
|
467
|
+
const { router } = build({ spec: "openapi.json" }, {});
|
|
468
|
+
const res = await router(req("GET", "/app/api-docs"));
|
|
469
|
+
assert.equal(res.status, 200);
|
|
470
|
+
assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
|
|
471
|
+
const body = res.body ?? "";
|
|
472
|
+
assert.match(body, /swagger-ui/);
|
|
473
|
+
// The UI reads the spec route we serve.
|
|
474
|
+
assert.match(body, /\/app\/api-docs\/openapi\.json/);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("the trailing-slash docs variant 308-redirects to the canonical path", async () => {
|
|
478
|
+
const { router } = build({ spec: "openapi.json" }, {});
|
|
479
|
+
// The router exact-matches non-prefix routes, so `${base}-docs/` (common from proxies/browsers)
|
|
480
|
+
// would 404 without an explicit alias. It must permanent-redirect to the slash-less canonical.
|
|
481
|
+
const res = await router(req("GET", "/app/api-docs/"));
|
|
482
|
+
assert.equal(res.status, 308);
|
|
483
|
+
assert.equal(res.headers?.["location"], "/app/api-docs");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("docs default on is reflected in describe()", () => {
|
|
487
|
+
const { handle } = buildHandle({ spec: "openapi.json" });
|
|
488
|
+
const d = handle.describe();
|
|
489
|
+
assert.deepEqual(d.api, {
|
|
490
|
+
spec: "openapi.json",
|
|
491
|
+
base: "/app/api",
|
|
492
|
+
dir: "operations",
|
|
493
|
+
eject: false,
|
|
494
|
+
docs: { ui: "/app/api-docs", spec: "/app/api-docs/openapi.json" },
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("docs:false disables both the UI and the spec route (no reserved paths)", async () => {
|
|
499
|
+
const { router } = build({ spec: "openapi.json", docs: false }, {});
|
|
500
|
+
// With docs off, the docs paths fall through to the operation dispatcher → no such operation.
|
|
501
|
+
assert.equal((await router(req("GET", "/app/api-docs"))).status, 404);
|
|
502
|
+
assert.equal((await router(req("GET", "/app/api-docs/openapi.json"))).status, 404);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("a string docs overrides the UI route (and the spec route beneath it)", async () => {
|
|
506
|
+
const { router } = build({ spec: "openapi.json", docs: "/help" }, {});
|
|
507
|
+
assert.equal((await router(req("GET", "/help"))).status, 200);
|
|
508
|
+
assert.equal((await router(req("GET", "/help/openapi.json"))).status, 200);
|
|
509
|
+
// The default docs path is no longer served.
|
|
510
|
+
assert.equal((await router(req("GET", "/app/api-docs"))).status, 404);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
|
|
514
|
+
assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
|
|
515
|
+
assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/x-docs");
|
|
516
|
+
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
|
|
517
|
+
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
|
|
518
|
+
assert.equal(apiDocsPath({}), undefined);
|
|
519
|
+
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
16
16
|
import { errorMessage } from "../guards.ts";
|
|
17
17
|
import type { HttpRequest, HttpResponse } from "../host.ts";
|
|
18
|
-
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
18
|
+
import { html, json, normalizeRoutePath, type Route } from "../router.ts";
|
|
19
19
|
import { resolveAppPath } from "./datasource.ts";
|
|
20
20
|
import {
|
|
21
21
|
collectOperations,
|
|
@@ -41,6 +41,48 @@ export interface ApiBinding {
|
|
|
41
41
|
base?: string;
|
|
42
42
|
validateResponses?: "dev" | "always" | "never";
|
|
43
43
|
eject?: boolean;
|
|
44
|
+
/** Human API docs (Swagger UI). `true`/omitted → on at `${base}-docs` (ADR 0058); `false` →
|
|
45
|
+
* off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
|
|
46
|
+
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
47
|
+
docs?: boolean | string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
|
|
51
|
+
* Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
|
|
52
|
+
* ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
|
|
53
|
+
const SWAGGER_UI_VERSION = "5.17.14";
|
|
54
|
+
|
|
55
|
+
/** Escape HTML-significant characters before embedding a value (the app name) in the docs markup. */
|
|
56
|
+
function escapeHtml(s: string): string {
|
|
57
|
+
return s.replace(
|
|
58
|
+
/[&<>"']/g,
|
|
59
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
|
|
64
|
+
* unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
|
|
65
|
+
* which keeps the docs route decoupled from spec-load failures. */
|
|
66
|
+
function swaggerUiPage(title: string, specUrl: string): string {
|
|
67
|
+
const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
|
|
68
|
+
const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
|
|
69
|
+
return `<!doctype html>
|
|
70
|
+
<html lang="en">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="utf-8">
|
|
73
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
74
|
+
<title>${escapeHtml(title)}</title>
|
|
75
|
+
<link rel="stylesheet" href="${css}">
|
|
76
|
+
<style>body{margin:0}</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<div id="swagger-ui"></div>
|
|
80
|
+
<script src="${bundle}" crossorigin="anonymous"></script>
|
|
81
|
+
<script>
|
|
82
|
+
window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
|
|
83
|
+
</script>
|
|
84
|
+
</body>
|
|
85
|
+
</html>`;
|
|
44
86
|
}
|
|
45
87
|
|
|
46
88
|
/** The `{ params, query, body }` contract a typed operation is generic over. */
|
|
@@ -113,6 +155,7 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
113
155
|
const base = Reflect.get(raw, "base");
|
|
114
156
|
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
115
157
|
const eject = Reflect.get(raw, "eject");
|
|
158
|
+
const docs = Reflect.get(raw, "docs");
|
|
116
159
|
// Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
|
|
117
160
|
return {
|
|
118
161
|
spec: spec.trim(),
|
|
@@ -123,9 +166,41 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
123
166
|
? validateResponses
|
|
124
167
|
: undefined,
|
|
125
168
|
eject: eject === true,
|
|
169
|
+
// Docs default ON (spec-first apps get Swagger for free); `false` disables; a non-empty
|
|
170
|
+
// string overrides the route. Anything else (including whitespace) falls back to the default.
|
|
171
|
+
docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
|
|
126
172
|
};
|
|
127
173
|
}
|
|
128
174
|
|
|
175
|
+
/** Resolve the api `base` + docs routing from a binding, in ONE place, so `mountApi` (which
|
|
176
|
+
* serves the docs) and `apiDocsPath` (which the pages surface links to) can never drift. */
|
|
177
|
+
function resolveApiRoutes(binding: ApiBinding): {
|
|
178
|
+
base: string;
|
|
179
|
+
docsEnabled: boolean;
|
|
180
|
+
docsBase: string;
|
|
181
|
+
} {
|
|
182
|
+
const base = normalizeRoutePath(binding.base, "/app/api");
|
|
183
|
+
const docsEnabled = binding.docs !== false;
|
|
184
|
+
// Swagger UI at a SIBLING of the base (`${base}-docs`, e.g. /app/api-docs), never a subpath
|
|
185
|
+
// of it — so the docs routes can't collide with, or be shadowed by, the operation dispatcher
|
|
186
|
+
// that owns the `${base}/` prefix (a string `docs` overrides the whole route).
|
|
187
|
+
const docsBase = normalizeRoutePath(
|
|
188
|
+
typeof binding.docs === "string" ? binding.docs : `${base}-docs`,
|
|
189
|
+
`${base}-docs`,
|
|
190
|
+
);
|
|
191
|
+
return { base, docsEnabled, docsBase };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
|
|
195
|
+
* or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
|
|
196
|
+
* the docs without re-deriving the path. */
|
|
197
|
+
export function apiDocsPath(manifest: unknown): string | undefined {
|
|
198
|
+
const binding = readApiBinding(manifest);
|
|
199
|
+
if (!binding) return undefined;
|
|
200
|
+
const { docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
201
|
+
return docsEnabled ? docsBase : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
129
204
|
/** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
|
|
130
205
|
* validate. A value that will not coerce is left as-is and fails the type check with a clear
|
|
131
206
|
* message. The delegate still receives the original string(s). */
|
|
@@ -167,9 +242,16 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
167
242
|
// Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
|
|
168
243
|
// ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
|
|
169
244
|
// like "app/api" would never match request paths, which always start with "/".
|
|
170
|
-
|
|
245
|
+
//
|
|
246
|
+
// The api `base` and the docs routing are derived TOGETHER by the shared helper (ADR 0058) so
|
|
247
|
+
// this module (which serves the docs) and `apiDocsPath` (which links to them from the pages
|
|
248
|
+
// shell) stay in lockstep with a single normalization of `binding.base` — no second, drifting
|
|
249
|
+
// copy of that logic here. The spec JSON is served under the docs path so the operations
|
|
250
|
+
// namespace stays purely operations.
|
|
251
|
+
const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
171
252
|
const dir = binding.dir ?? "operations";
|
|
172
253
|
const surfaceEject = binding.eject === true;
|
|
254
|
+
const specRoutePath = `${docsBase}/openapi.json`;
|
|
173
255
|
|
|
174
256
|
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
175
257
|
// pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
|
|
@@ -445,9 +527,57 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
445
527
|
handler: dispatch,
|
|
446
528
|
};
|
|
447
529
|
|
|
530
|
+
// Docs routes (ADR 0058): the Swagger UI page + the OpenAPI JSON it reads. Both are exact GET
|
|
531
|
+
// routes on the `${base}-docs` sibling namespace, so they are wholly independent of the
|
|
532
|
+
// operation dispatcher above.
|
|
533
|
+
const serveSpec = async (): Promise<HttpResponse> => {
|
|
534
|
+
let d: OpenApiDoc;
|
|
535
|
+
try {
|
|
536
|
+
d = await loadDoc();
|
|
537
|
+
} catch (e) {
|
|
538
|
+
return json({ error: errorMessage(e) }, 500);
|
|
539
|
+
}
|
|
540
|
+
// Point Swagger UI "Try it out" at the mounted namespace: operations live under `base`
|
|
541
|
+
// (e.g. /app/api/invoices), not the spec's bare paths (/invoices). Overriding `servers`
|
|
542
|
+
// makes the interactive console call the real Urban routes rather than the origin root.
|
|
543
|
+
return json({ ...d, servers: [{ url: base }] });
|
|
544
|
+
};
|
|
545
|
+
const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
|
|
546
|
+
const serveDocs = (): HttpResponse => html(swaggerUiPage(docsTitle, specRoutePath));
|
|
547
|
+
// A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
|
|
548
|
+
// exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
|
|
549
|
+
// a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
|
|
550
|
+
// (slash-less) `docsBase` that `apiDocsPath` and every internal link already use.
|
|
551
|
+
const redirectDocsSlash = (): HttpResponse => ({ status: 308, headers: { location: docsBase } });
|
|
552
|
+
const docsRoutes: Route[] = docsEnabled
|
|
553
|
+
? [
|
|
554
|
+
{ method: "GET", path: docsBase, source: `api-docs:${binding.spec}`, handler: serveDocs },
|
|
555
|
+
{
|
|
556
|
+
method: "GET",
|
|
557
|
+
path: `${docsBase}/`,
|
|
558
|
+
source: `api-docs:${binding.spec}`,
|
|
559
|
+
handler: redirectDocsSlash,
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
method: "GET",
|
|
563
|
+
path: specRoutePath,
|
|
564
|
+
source: `api-docs:${binding.spec}`,
|
|
565
|
+
handler: serveSpec,
|
|
566
|
+
},
|
|
567
|
+
]
|
|
568
|
+
: [];
|
|
569
|
+
|
|
448
570
|
return {
|
|
449
571
|
name: "api",
|
|
450
|
-
routes: [rootRoute, route],
|
|
451
|
-
describe: () => ({
|
|
572
|
+
routes: [...docsRoutes, rootRoute, route],
|
|
573
|
+
describe: () => ({
|
|
574
|
+
api: {
|
|
575
|
+
spec: binding.spec,
|
|
576
|
+
base,
|
|
577
|
+
dir,
|
|
578
|
+
eject: surfaceEject,
|
|
579
|
+
docs: docsEnabled ? { ui: docsBase, spec: specRoutePath } : false,
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
452
582
|
};
|
|
453
583
|
}
|
|
@@ -122,6 +122,34 @@ test("GET /app/runtime.js serves the renderer module", async () => {
|
|
|
122
122
|
assert.match(res.body ?? "", /function apiUrl\(u\)/);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test("the shell renders an API-docs badge only when an apiDocsPath is provided", async () => {
|
|
126
|
+
const routes = createPagesRoutes(
|
|
127
|
+
{ pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
|
|
128
|
+
{ db: fakeDb(), engine: fakeEngine().engine, readPage: async () => "{}" },
|
|
129
|
+
);
|
|
130
|
+
const withBadge = (await makeRouter(routes)(req("GET", "/"))).body ?? "";
|
|
131
|
+
assert.match(withBadge, /class="pc-apidocs"/);
|
|
132
|
+
assert.match(withBadge, /href="\/app\/api-docs"/);
|
|
133
|
+
// Opens in a new tab with a hardened rel (no reverse-tabnabbing handle).
|
|
134
|
+
assert.match(withBadge, /rel="noopener noreferrer"/);
|
|
135
|
+
|
|
136
|
+
// No `api` binding → no badge element (the CSS rule is always present in the shell styles).
|
|
137
|
+
const noBadge = (await dispatch("GET", "/")).body ?? "";
|
|
138
|
+
assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("renderActionForm supports a publishMessage action (message-started processes)", async () => {
|
|
142
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
143
|
+
const js = res.body ?? "";
|
|
144
|
+
// The form submit branches on the action kind: publishMessage posts to the message endpoint,
|
|
145
|
+
// deriving the correlation key from the named form field; startProcess keeps its old path.
|
|
146
|
+
assert.match(js, /p\.action\.kind === "publishMessage"/);
|
|
147
|
+
assert.match(js, /variables\[p\.action\.correlationKeyField\]/);
|
|
148
|
+
assert.match(js, /"\/app\/actions\/message"/);
|
|
149
|
+
// startProcess remains the default/back-compat branch.
|
|
150
|
+
assert.match(js, /"\/app\/actions\/start\/" \+ encodeURIComponent\(p\.action\.process\)/);
|
|
151
|
+
});
|
|
152
|
+
|
|
125
153
|
test("the renderer bridges the console theme over postMessage", async () => {
|
|
126
154
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
127
155
|
const js = res.body ?? "";
|
|
@@ -20,6 +20,7 @@ import { errorMessage, isRecord } from "../guards.ts";
|
|
|
20
20
|
import type { EngineClient } from "../host.ts";
|
|
21
21
|
import { html, json, type Route } from "../router.ts";
|
|
22
22
|
import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
|
|
23
|
+
import { apiDocsPath } from "./api.ts";
|
|
23
24
|
import { quoteIdent } from "./gateway.ts";
|
|
24
25
|
|
|
25
26
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
@@ -37,6 +38,12 @@ export interface PagesOptions {
|
|
|
37
38
|
rowLimit?: number;
|
|
38
39
|
/** The injected default datasource name (the alias apps bind to). Default `app`. */
|
|
39
40
|
sourceName?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The app's Swagger UI route (from the `api` binding, resolved by `apiDocsPath`). When set,
|
|
43
|
+
* the shell renders a persistent "API docs" badge linking to it — so a spec-first app surfaces
|
|
44
|
+
* its interactive docs from its own UI for free. Omitted → no badge (app declares no `api`).
|
|
45
|
+
*/
|
|
46
|
+
apiDocsPath?: string;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export interface PagesDeps {
|
|
@@ -115,7 +122,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
115
122
|
};
|
|
116
123
|
|
|
117
124
|
const routes: Route[] = [];
|
|
118
|
-
const shell = html(rendererShell(homePage));
|
|
125
|
+
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
119
126
|
|
|
120
127
|
// ── the renderer shell + module ─────────────────────────────────────────
|
|
121
128
|
routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
|
|
@@ -372,6 +379,9 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
|
|
|
372
379
|
homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
|
|
373
380
|
rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
|
|
374
381
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
382
|
+
// Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
|
|
383
|
+
// binding (resolved by the api module, so the path never drifts from where docs mount).
|
|
384
|
+
apiDocsPath: apiDocsPath(ctx.manifest),
|
|
375
385
|
};
|
|
376
386
|
const sourceName = opts.sourceName ?? "app";
|
|
377
387
|
const bindings = ctx.manifest.instanceTracking ?? [];
|
|
@@ -400,7 +410,13 @@ function escapeAttr(s: string): string {
|
|
|
400
410
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
401
411
|
}
|
|
402
412
|
|
|
403
|
-
function rendererShell(homePage: string): string {
|
|
413
|
+
function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
414
|
+
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
415
|
+
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
416
|
+
// tab a handle back to this window (reverse-tabnabbing).
|
|
417
|
+
const apiDocsBadge = apiDocsPath
|
|
418
|
+
? `\n <a class="pc-apidocs" href="${escapeAttr(apiDocsPath)}" target="_blank" rel="noopener noreferrer">API docs \u2197</a>`
|
|
419
|
+
: "";
|
|
404
420
|
return `<!doctype html>
|
|
405
421
|
<html lang="en">
|
|
406
422
|
<head>
|
|
@@ -409,7 +425,7 @@ function rendererShell(homePage: string): string {
|
|
|
409
425
|
<title>Urban App</title>
|
|
410
426
|
<style>${RENDERER_CSS}</style>
|
|
411
427
|
</head>
|
|
412
|
-
<body
|
|
428
|
+
<body>${apiDocsBadge}
|
|
413
429
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
414
430
|
<script type="module" src="./app/runtime.js"></script>
|
|
415
431
|
</body>
|
|
@@ -457,6 +473,8 @@ const RENDERER_CSS = `
|
|
|
457
473
|
* { box-sizing: border-box; }
|
|
458
474
|
body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
|
|
459
475
|
.pc-empty { color:var(--nano-text-faint); }
|
|
476
|
+
.pc-apidocs { position:fixed; top:.75rem; right:.75rem; z-index:10; font-size:.8rem; text-decoration:none; padding:.3rem .6rem; border:1px solid var(--nano-edge); border-radius:999px; background:var(--nano-panel); color:var(--nano-text-muted); }
|
|
477
|
+
.pc-apidocs:hover { color:var(--nano-text); border-color:var(--nano-accent); }
|
|
460
478
|
.pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
|
|
461
479
|
.pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
|
|
462
480
|
.pc-body { margin:.5rem 0; }
|
|
@@ -704,10 +722,28 @@ function renderActionForm(node) {
|
|
|
704
722
|
}
|
|
705
723
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
706
724
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
725
|
+
// Two action kinds: startProcess (default, back-compat) -> engine.createInstance; and
|
|
726
|
+
// publishMessage -> engine.publishMessage, which correlates a message to a message start
|
|
727
|
+
// (or intermediate catch) event. correlationKeyField names the form field that carries
|
|
728
|
+
// the correlation key; the whole form becomes the message variables.
|
|
729
|
+
if (p.action && p.action.kind === "publishMessage") {
|
|
730
|
+
const correlationKey = String(variables[p.action.correlationKeyField] ?? "").trim();
|
|
731
|
+
if (!correlationKey) {
|
|
732
|
+
msg.className = "pc-msg err";
|
|
733
|
+
msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
await getJSON("/app/actions/message", { method: "POST",
|
|
737
|
+
headers: { "content-type": "application/json" },
|
|
738
|
+
body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
|
|
739
|
+
msg.className = "pc-msg ok";
|
|
740
|
+
msg.textContent = "Message published";
|
|
741
|
+
} else {
|
|
742
|
+
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
743
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
744
|
+
msg.className = "pc-msg ok";
|
|
745
|
+
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
746
|
+
}
|
|
711
747
|
for (const input of inputs.values()) input.value = "";
|
|
712
748
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
713
749
|
} catch (e) {
|
|
@@ -9,6 +9,12 @@ test("normalizeRoutePath ensures a leading slash and trims trailing slashes", ()
|
|
|
9
9
|
assert.equal(normalizeRoutePath(" /chat ", "/chat"), "/chat");
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
test("normalizeRoutePath collapses leading slashes so a //host value stays in-app", () => {
|
|
13
|
+
assert.equal(normalizeRoutePath("//evil.com", "/app/api"), "/evil.com");
|
|
14
|
+
assert.equal(normalizeRoutePath("///app/api", "/app/api"), "/app/api");
|
|
15
|
+
assert.equal(normalizeRoutePath(" //app/api-docs ", "/app/api"), "/app/api-docs");
|
|
16
|
+
});
|
|
17
|
+
|
|
12
18
|
test("normalizeRoutePath falls back when value is missing or collapses to root", () => {
|
|
13
19
|
assert.equal(normalizeRoutePath(undefined, "/tasks"), "/tasks");
|
|
14
20
|
assert.equal(normalizeRoutePath("/", "/tasks"), "/tasks");
|
|
@@ -27,14 +27,17 @@ export function text(body: string, status = 200): HttpResponse {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Normalize a manifest-supplied route path:
|
|
31
|
-
* slashes. Incoming request paths always start with "/", so a
|
|
32
|
-
* lacking one (e.g. "hooks/x") would otherwise never match.
|
|
33
|
-
*
|
|
30
|
+
* Normalize a manifest-supplied route path: collapse leading slashes to exactly
|
|
31
|
+
* one, drop trailing slashes. Incoming request paths always start with "/", so a
|
|
32
|
+
* manifest path lacking one (e.g. "hooks/x") would otherwise never match. Collapsing
|
|
33
|
+
* multiple leading slashes also hardens derived URLs (e.g. the Swagger `servers`
|
|
34
|
+
* entry or the pages "API docs" badge): a `//host` value stays an in-app absolute
|
|
35
|
+
* path instead of becoming a protocol-relative URL that navigates off-origin. Falls
|
|
36
|
+
* back to `fallback` when the value is missing or collapses to empty ("/").
|
|
34
37
|
*/
|
|
35
38
|
export function normalizeRoutePath(value: string | undefined, fallback: string): string {
|
|
36
39
|
const raw = (value ?? fallback).trim();
|
|
37
|
-
const withLead = raw.
|
|
40
|
+
const withLead = `/${raw.replace(/^\/+/, "")}`;
|
|
38
41
|
const noTrail = withLead.replace(/\/+$/, "");
|
|
39
42
|
return noTrail || fallback;
|
|
40
43
|
}
|