@nanobpm/urban 0.32.2 → 0.33.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 +1 -2
- package/dist/runtime/core/modules/api.js +17 -18
- package/dist/runtime/core/modules/pages.js +78 -53
- package/dist/runtime/core/modules/surfaces.js +1 -1
- package/package.json +2 -2
- package/src/runtime/core/modules/api.test.ts +6 -5
- package/src/runtime/core/modules/api.ts +19 -20
- package/src/runtime/core/modules/pages.test.ts +15 -14
- package/src/runtime/core/modules/pages.ts +78 -53
- package/src/runtime/core/modules/surfaces.ts +1 -1
|
@@ -6,10 +6,9 @@ import { type Route } from "../router.ts";
|
|
|
6
6
|
export interface ApiBinding {
|
|
7
7
|
spec: string;
|
|
8
8
|
dir?: string;
|
|
9
|
-
base?: string;
|
|
10
9
|
validateResponses?: "dev" | "always" | "never";
|
|
11
10
|
eject?: boolean;
|
|
12
|
-
/** Human API docs (Swagger UI). `true`/omitted → on at
|
|
11
|
+
/** Human API docs (Swagger UI). `true`/omitted → on at `/app/api-docs` (ADR 0058); `false` →
|
|
13
12
|
* off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
|
|
14
13
|
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
15
14
|
docs?: boolean | string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
|
|
3
3
|
// writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
|
|
4
4
|
// toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
|
|
5
|
-
//
|
|
5
|
+
// fixed `/app/api` namespace, and for each request: parses → validates params/query/body against the spec →
|
|
6
6
|
// 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
|
|
7
7
|
// the injected AppApi, exactly like a worker/action handler) → serializes the result.
|
|
8
8
|
//
|
|
@@ -15,6 +15,10 @@ import { errorMessage } from "../guards.js";
|
|
|
15
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
|
+
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
19
|
+
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
20
|
+
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
21
|
+
const API_BASE = "/app/api";
|
|
18
22
|
/** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
|
|
19
23
|
* Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
|
|
20
24
|
* ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
|
|
@@ -76,7 +80,6 @@ function readApiBinding(manifest) {
|
|
|
76
80
|
if (typeof spec !== "string" || spec.trim().length === 0)
|
|
77
81
|
return undefined;
|
|
78
82
|
const dir = Reflect.get(raw, "dir");
|
|
79
|
-
const base = Reflect.get(raw, "base");
|
|
80
83
|
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
81
84
|
const eject = Reflect.get(raw, "eject");
|
|
82
85
|
const docs = Reflect.get(raw, "docs");
|
|
@@ -84,7 +87,6 @@ function readApiBinding(manifest) {
|
|
|
84
87
|
return {
|
|
85
88
|
spec: spec.trim(),
|
|
86
89
|
dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
|
|
87
|
-
base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
|
|
88
90
|
validateResponses: validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
|
|
89
91
|
? validateResponses
|
|
90
92
|
: undefined,
|
|
@@ -94,14 +96,15 @@ function readApiBinding(manifest) {
|
|
|
94
96
|
docs: docs === false ? false : typeof docs === "string" && docs.trim().length > 0 ? docs.trim() : true,
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
|
-
/** Resolve the api `base` + docs routing
|
|
98
|
-
*
|
|
99
|
+
/** Resolve the api `base` + docs routing, in ONE place, so `mountApi` (which serves the docs) and
|
|
100
|
+
* `apiDocsPath` (which the pages surface links to) can never drift. The operation base is the fixed
|
|
101
|
+
* `/app/api` constant; only the docs route remains manifest-configurable. */
|
|
99
102
|
function resolveApiRoutes(binding) {
|
|
100
|
-
const base =
|
|
103
|
+
const base = API_BASE;
|
|
101
104
|
const docsEnabled = binding.docs !== false;
|
|
102
|
-
// Swagger UI at a SIBLING of the base (
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
+
// Swagger UI at a SIBLING of the base (`/app/api-docs`), never a subpath of it — so the docs
|
|
106
|
+
// routes can't collide with, or be shadowed by, the operation dispatcher that owns the
|
|
107
|
+
// `${base}/` prefix (a string `docs` overrides the whole route).
|
|
105
108
|
const docsBase = normalizeRoutePath(typeof binding.docs === "string" ? binding.docs : `${base}-docs`, `${base}-docs`);
|
|
106
109
|
return { base, docsEnabled, docsBase };
|
|
107
110
|
}
|
|
@@ -143,15 +146,11 @@ export function mountApi(ctx, app) {
|
|
|
143
146
|
const binding = readApiBinding(ctx.manifest);
|
|
144
147
|
if (!binding)
|
|
145
148
|
return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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.
|
|
149
|
+
// The api `base` is the fixed `/app/api` constant; only the docs routing is derived here. Both
|
|
150
|
+
// come from the shared helper (ADR 0058) so this module (which serves the docs) and `apiDocsPath`
|
|
151
|
+
// (which links to them from the pages shell) stay in lockstep — no second, drifting copy of that
|
|
152
|
+
// logic here. The spec JSON is served under the docs path so the operations namespace stays
|
|
153
|
+
// purely operations.
|
|
155
154
|
const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
156
155
|
const dir = binding.dir ?? "operations";
|
|
157
156
|
const surfaceEject = binding.eject === true;
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
15
15
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
16
16
|
// POST /app/actions/message → engine.publishMessage
|
|
17
|
+
//
|
|
18
|
+
// Page actions are ROUTE-DRIVEN: an `actionForm`/rowAction/detail-form `action` is just
|
|
19
|
+
// `{ path, method?, body?, successLabel? }`. The client POSTs the resolved body (default
|
|
20
|
+
// `{ variables: <form> }`) to `path`, templating `{{form.KEY}}` / `{{row.KEY}}` (and the
|
|
21
|
+
// whole-object `{{form}}` / `{{row}}`) tokens. There are NO bespoke action kinds — a form
|
|
22
|
+
// reaches start/cancel/message above, an OpenAPI operation under /app/api, or any app route
|
|
23
|
+
// uniformly by naming its path.
|
|
17
24
|
import { errorMessage, isRecord } from "../guards.js";
|
|
18
25
|
import { html, json } from "../router.js";
|
|
19
26
|
import { cancelInstanceReconciling } from "./cancel.js";
|
|
@@ -586,6 +593,59 @@ async function getJSON(url, opts) {
|
|
|
586
593
|
return body;
|
|
587
594
|
}
|
|
588
595
|
|
|
596
|
+
// ── route-driven actions ────────────────────────────────────────────────
|
|
597
|
+
// Every page action is a single primitive: POST (or any method) the resolved
|
|
598
|
+
// body to an app route path. There are NO bespoke action kinds — a form/row
|
|
599
|
+
// reaches process start, message publish, cancel, an OpenAPI operation, a
|
|
600
|
+
// webhook, or any app route uniformly by naming its path. Path + body may
|
|
601
|
+
// interpolate the current form fields and (for grid rows/detail forms) the row
|
|
602
|
+
// via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
|
|
603
|
+
// {{row}} splice the object itself (type-preserving), so a body of
|
|
604
|
+
// "variables": "{{form}}" sends the form object as the variable map.
|
|
605
|
+
function lookupToken(path, ctx) {
|
|
606
|
+
const segs = String(path).split(".");
|
|
607
|
+
const head = segs[0];
|
|
608
|
+
let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
|
|
609
|
+
for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
|
|
610
|
+
return base;
|
|
611
|
+
}
|
|
612
|
+
function resolveTemplate(node, ctx) {
|
|
613
|
+
if (typeof node === "string") {
|
|
614
|
+
const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
|
|
615
|
+
if (whole) return lookupToken(whole[1], ctx);
|
|
616
|
+
return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
617
|
+
const v = lookupToken(k, ctx);
|
|
618
|
+
return v == null ? "" : String(v);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
|
|
622
|
+
if (node && typeof node === "object") {
|
|
623
|
+
// Null-proto target so a template key like "__proto__" can't trigger prototype
|
|
624
|
+
// mutation (prototype-pollution class) when copied into the resolved object.
|
|
625
|
+
const out = Object.create(null);
|
|
626
|
+
for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
return node;
|
|
630
|
+
}
|
|
631
|
+
async function runRoute(action, ctx) {
|
|
632
|
+
if (!action || typeof action.path !== "string" || action.path === "") {
|
|
633
|
+
throw new Error("This action has no route configured (action.path is missing or blank)");
|
|
634
|
+
}
|
|
635
|
+
const path = action.path.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
636
|
+
const v = lookupToken(k, ctx);
|
|
637
|
+
return v == null ? "" : encodeURIComponent(String(v));
|
|
638
|
+
});
|
|
639
|
+
const method = String(action.method || "POST").toUpperCase();
|
|
640
|
+
let body;
|
|
641
|
+
if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
|
|
642
|
+
else if (ctx.form) body = { variables: ctx.form };
|
|
643
|
+
else body = {};
|
|
644
|
+
const opts = { method, headers: { "content-type": "application/json" } };
|
|
645
|
+
if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
|
|
646
|
+
return getJSON(path, opts);
|
|
647
|
+
}
|
|
648
|
+
|
|
589
649
|
function el(tag, attrs = {}, ...kids) {
|
|
590
650
|
const n = document.createElement(tag);
|
|
591
651
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -686,37 +746,14 @@ function renderActionForm(node) {
|
|
|
686
746
|
}
|
|
687
747
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
688
748
|
try {
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
// Send the trimmed key back as the variable too, so the value a worker
|
|
701
|
-
// reads matches the key the message was correlated on (no leading/trailing
|
|
702
|
-
// whitespace divergence between correlationKey and variables[field]).
|
|
703
|
-
variables[p.action.correlationKeyField] = correlationKey;
|
|
704
|
-
await getJSON("/app/actions/message", { method: "POST",
|
|
705
|
-
headers: { "content-type": "application/json" },
|
|
706
|
-
body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
|
|
707
|
-
msg.className = "pc-msg ok";
|
|
708
|
-
msg.textContent = "Message published";
|
|
709
|
-
} else {
|
|
710
|
-
if (!p.action || !p.action.process) {
|
|
711
|
-
msg.className = "pc-msg err";
|
|
712
|
-
msg.textContent = "This form has no action configured";
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
716
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
717
|
-
msg.className = "pc-msg ok";
|
|
718
|
-
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
719
|
-
}
|
|
749
|
+
// Route-driven: POST the form (as { variables } by default, or the action's
|
|
750
|
+
// own body template) to the action's route path. correlationKey / process /
|
|
751
|
+
// message are expressed by the path + body template the page author supplies,
|
|
752
|
+
// e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
|
|
753
|
+
const res = await runRoute(p.action, { form: variables });
|
|
754
|
+
msg.className = "pc-msg ok";
|
|
755
|
+
msg.textContent = (p.action && p.action.successLabel) ||
|
|
756
|
+
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
720
757
|
for (const input of inputs.values()) input.value = "";
|
|
721
758
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
722
759
|
} catch (e) {
|
|
@@ -791,23 +828,9 @@ function renderDataGrid(node) {
|
|
|
791
828
|
}
|
|
792
829
|
|
|
793
830
|
async function fireAction(action, row) {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
|
|
798
|
-
}
|
|
799
|
-
if (action.kind === "publishMessage") {
|
|
800
|
-
return getJSON("/app/actions/message", { method: "POST",
|
|
801
|
-
headers: { "content-type": "application/json" },
|
|
802
|
-
body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
|
|
803
|
-
variables: { ...(action.variables || {}) } }) });
|
|
804
|
-
}
|
|
805
|
-
if (action.kind === "startProcess") {
|
|
806
|
-
return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
|
|
807
|
-
headers: { "content-type": "application/json" },
|
|
808
|
-
body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
|
|
809
|
-
}
|
|
810
|
-
throw new Error("unknown action");
|
|
831
|
+
// Route-driven row action: POST the action's body template (default {})
|
|
832
|
+
// to its route path, with {{row.KEY}} tokens resolved from this row.
|
|
833
|
+
return runRoute(action, { row });
|
|
811
834
|
}
|
|
812
835
|
|
|
813
836
|
function rowActionButton(row, ra) {
|
|
@@ -842,11 +865,13 @@ function renderDataGrid(node) {
|
|
|
842
865
|
btn.addEventListener("click", async () => {
|
|
843
866
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
844
867
|
try {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
868
|
+
// The textarea value is the form's single field (f.inputKey); route + body
|
|
869
|
+
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
870
|
+
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
871
|
+
// Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
|
|
872
|
+
const form = Object.create(null); form[f.inputKey] = input.value;
|
|
873
|
+
await runRoute(f.action, { form, row });
|
|
874
|
+
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
850
875
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
851
876
|
} catch (e) {
|
|
852
877
|
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -95,7 +95,7 @@ export function mountSurfaces(ctx, app) {
|
|
|
95
95
|
enabled.push(`actions(${actions.routes.length})`);
|
|
96
96
|
}
|
|
97
97
|
// The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
|
|
98
|
-
// still shadows an operation) and BEFORE the generic pages routes. It owns the
|
|
98
|
+
// still shadows an operation) and BEFORE the generic pages routes. It owns the fixed `/app/api`
|
|
99
99
|
// namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
|
|
100
100
|
const api = mountApi(ctx, app);
|
|
101
101
|
if (api.routes.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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.9.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@types/node": "^22.20.1",
|
|
@@ -228,12 +228,13 @@ test("a malformed spec surfaces as a 500, not a boot failure", async () => {
|
|
|
228
228
|
assert.match(JSON.parse(res.body!).error, /failed to load/);
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
test("a manifest api.base
|
|
231
|
+
test("a leftover manifest api.base is ignored — operations always mount at the fixed /app/api", async () => {
|
|
232
232
|
let got: Record<string, string> | undefined;
|
|
233
233
|
const { router } = build(
|
|
234
|
-
{ spec: "openapi.json", base: "
|
|
234
|
+
{ spec: "openapi.json", base: "/somewhere/else" }, // base is no longer honoured
|
|
235
235
|
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
236
236
|
);
|
|
237
|
+
assert.equal((await router(req("GET", "/somewhere/else/invoices/42"))).status, 404);
|
|
237
238
|
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
238
239
|
assert.equal(res.status, 200);
|
|
239
240
|
assert.deepEqual(got, { id: "42" });
|
|
@@ -393,10 +394,10 @@ test("an op declaring no requestBody gets body:undefined at runtime (matches der
|
|
|
393
394
|
assert.equal(seenBody, undefined);
|
|
394
395
|
});
|
|
395
396
|
|
|
396
|
-
test("readApiBinding trims whitespace on
|
|
397
|
+
test("readApiBinding trims whitespace on dir/spec so benign formatting can't break resolution", async () => {
|
|
397
398
|
let got: Record<string, string> | undefined;
|
|
398
399
|
const { router, imported } = build(
|
|
399
|
-
{ spec: " openapi.json ",
|
|
400
|
+
{ spec: " openapi.json ", dir: " operations " },
|
|
400
401
|
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
401
402
|
);
|
|
402
403
|
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
@@ -515,7 +516,7 @@ test("a string docs overrides the UI route (and the spec route beneath it)", asy
|
|
|
515
516
|
|
|
516
517
|
test("apiDocsPath resolves the UI route (and honours disable / override)", () => {
|
|
517
518
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json" } }), "/app/api-docs");
|
|
518
|
-
assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/
|
|
519
|
+
assert.equal(apiDocsPath({ api: { spec: "openapi.json", base: "/x" } }), "/app/api-docs"); // base ignored
|
|
519
520
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: "/help" } }), "/help");
|
|
520
521
|
assert.equal(apiDocsPath({ api: { spec: "openapi.json", docs: false } }), undefined);
|
|
521
522
|
assert.equal(apiDocsPath({}), undefined);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
|
|
3
3
|
// writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
|
|
4
4
|
// toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
|
|
5
|
-
//
|
|
5
|
+
// fixed `/app/api` namespace, and for each request: parses → validates params/query/body against the spec →
|
|
6
6
|
// 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
|
|
7
7
|
// the injected AppApi, exactly like a worker/action handler) → serializes the result.
|
|
8
8
|
//
|
|
@@ -38,15 +38,19 @@ import {
|
|
|
38
38
|
export interface ApiBinding {
|
|
39
39
|
spec: string;
|
|
40
40
|
dir?: string;
|
|
41
|
-
base?: string;
|
|
42
41
|
validateResponses?: "dev" | "always" | "never";
|
|
43
42
|
eject?: boolean;
|
|
44
|
-
/** Human API docs (Swagger UI). `true`/omitted → on at
|
|
43
|
+
/** Human API docs (Swagger UI). `true`/omitted → on at `/app/api-docs` (ADR 0058); `false` →
|
|
45
44
|
* off; a string → a custom absolute route to mount the UI under. Spec-first apps get docs
|
|
46
45
|
* for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
|
|
47
46
|
docs?: boolean | string;
|
|
48
47
|
}
|
|
49
48
|
|
|
49
|
+
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
50
|
+
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
51
|
+
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
52
|
+
const API_BASE = "/app/api";
|
|
53
|
+
|
|
50
54
|
/** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
|
|
51
55
|
* Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
|
|
52
56
|
* ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
|
|
@@ -152,7 +156,6 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
152
156
|
const spec = Reflect.get(raw, "spec");
|
|
153
157
|
if (typeof spec !== "string" || spec.trim().length === 0) return undefined;
|
|
154
158
|
const dir = Reflect.get(raw, "dir");
|
|
155
|
-
const base = Reflect.get(raw, "base");
|
|
156
159
|
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
157
160
|
const eject = Reflect.get(raw, "eject");
|
|
158
161
|
const docs = Reflect.get(raw, "docs");
|
|
@@ -160,7 +163,6 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
160
163
|
return {
|
|
161
164
|
spec: spec.trim(),
|
|
162
165
|
dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
|
|
163
|
-
base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
|
|
164
166
|
validateResponses:
|
|
165
167
|
validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
|
|
166
168
|
? validateResponses
|
|
@@ -172,18 +174,19 @@ function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
|
172
174
|
};
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
/** Resolve the api `base` + docs routing
|
|
176
|
-
*
|
|
177
|
+
/** Resolve the api `base` + docs routing, in ONE place, so `mountApi` (which serves the docs) and
|
|
178
|
+
* `apiDocsPath` (which the pages surface links to) can never drift. The operation base is the fixed
|
|
179
|
+
* `/app/api` constant; only the docs route remains manifest-configurable. */
|
|
177
180
|
function resolveApiRoutes(binding: ApiBinding): {
|
|
178
181
|
base: string;
|
|
179
182
|
docsEnabled: boolean;
|
|
180
183
|
docsBase: string;
|
|
181
184
|
} {
|
|
182
|
-
const base =
|
|
185
|
+
const base = API_BASE;
|
|
183
186
|
const docsEnabled = binding.docs !== false;
|
|
184
|
-
// Swagger UI at a SIBLING of the base (
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
+
// Swagger UI at a SIBLING of the base (`/app/api-docs`), never a subpath of it — so the docs
|
|
188
|
+
// routes can't collide with, or be shadowed by, the operation dispatcher that owns the
|
|
189
|
+
// `${base}/` prefix (a string `docs` overrides the whole route).
|
|
187
190
|
const docsBase = normalizeRoutePath(
|
|
188
191
|
typeof binding.docs === "string" ? binding.docs : `${base}-docs`,
|
|
189
192
|
`${base}-docs`,
|
|
@@ -239,15 +242,11 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
239
242
|
const binding = readApiBinding(ctx.manifest);
|
|
240
243
|
if (!binding) return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
|
|
241
244
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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.
|
|
245
|
+
// The api `base` is the fixed `/app/api` constant; only the docs routing is derived here. Both
|
|
246
|
+
// come from the shared helper (ADR 0058) so this module (which serves the docs) and `apiDocsPath`
|
|
247
|
+
// (which links to them from the pages shell) stay in lockstep — no second, drifting copy of that
|
|
248
|
+
// logic here. The spec JSON is served under the docs path so the operations namespace stays
|
|
249
|
+
// purely operations.
|
|
251
250
|
const { base, docsEnabled, docsBase } = resolveApiRoutes(binding);
|
|
252
251
|
const dir = binding.dir ?? "operations";
|
|
253
252
|
const surfaceEject = binding.eject === true;
|
|
@@ -161,22 +161,23 @@ test("the shell renders an API-docs badge only when an apiDocsPath is provided",
|
|
|
161
161
|
assert.doesNotMatch(noBadge, /class="pc-apidocs"/);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
test("
|
|
164
|
+
test("page actions are route-driven: the runtime ships a single callRoute dispatcher, no bespoke kinds", async () => {
|
|
165
165
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
166
166
|
const js = res.body ?? "";
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
assert.match(js, /
|
|
170
|
-
assert.match(js, /
|
|
171
|
-
assert.match(js, /
|
|
172
|
-
// The
|
|
173
|
-
//
|
|
174
|
-
assert.match(js, /variables
|
|
175
|
-
//
|
|
176
|
-
assert.match(js, /
|
|
177
|
-
//
|
|
178
|
-
assert.
|
|
179
|
-
assert.
|
|
167
|
+
// A single primitive resolves { path, method?, body? } and POSTs it — there is no per-kind
|
|
168
|
+
// branching. The template resolver splices {{form}} / {{row}} and {{form.KEY}} / {{row.KEY}}.
|
|
169
|
+
assert.match(js, /async function runRoute\(action, ctx\)/);
|
|
170
|
+
assert.match(js, /function resolveTemplate\(node, ctx\)/);
|
|
171
|
+
assert.match(js, /function lookupToken\(path, ctx\)/);
|
|
172
|
+
// The form default body is the { variables } envelope, so plain forms hit start operations
|
|
173
|
+
// without any per-form config; row/detail forms carry an explicit body template instead.
|
|
174
|
+
assert.match(js, /body = \{ variables: ctx\.form \}/);
|
|
175
|
+
// Path tokens are URL-encoded (real keys like owner/repo#123 must not break the path).
|
|
176
|
+
assert.match(js, /encodeURIComponent\(String\(v\)\)/);
|
|
177
|
+
// The removed bespoke action kinds no longer appear anywhere in the client runtime.
|
|
178
|
+
assert.doesNotMatch(js, /p\.action\.kind/);
|
|
179
|
+
assert.doesNotMatch(js, /correlationKeyField/);
|
|
180
|
+
assert.doesNotMatch(js, /action\.kind === "(startProcess|publishMessage|cancelProcess)"/);
|
|
180
181
|
});
|
|
181
182
|
|
|
182
183
|
test("the renderer bridges the console theme over postMessage", async () => {
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// POST /app/actions/start/<process> → engine.createInstance
|
|
15
15
|
// POST /app/actions/cancel → engine.cancelInstance
|
|
16
16
|
// POST /app/actions/message → engine.publishMessage
|
|
17
|
+
//
|
|
18
|
+
// Page actions are ROUTE-DRIVEN: an `actionForm`/rowAction/detail-form `action` is just
|
|
19
|
+
// `{ path, method?, body?, successLabel? }`. The client POSTs the resolved body (default
|
|
20
|
+
// `{ variables: <form> }`) to `path`, templating `{{form.KEY}}` / `{{row.KEY}}` (and the
|
|
21
|
+
// whole-object `{{form}}` / `{{row}}`) tokens. There are NO bespoke action kinds — a form
|
|
22
|
+
// reaches start/cancel/message above, an OpenAPI operation under /app/api, or any app route
|
|
23
|
+
// uniformly by naming its path.
|
|
17
24
|
|
|
18
25
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
19
26
|
import { errorMessage, isRecord } from "../guards.ts";
|
|
@@ -634,6 +641,59 @@ async function getJSON(url, opts) {
|
|
|
634
641
|
return body;
|
|
635
642
|
}
|
|
636
643
|
|
|
644
|
+
// ── route-driven actions ────────────────────────────────────────────────
|
|
645
|
+
// Every page action is a single primitive: POST (or any method) the resolved
|
|
646
|
+
// body to an app route path. There are NO bespoke action kinds — a form/row
|
|
647
|
+
// reaches process start, message publish, cancel, an OpenAPI operation, a
|
|
648
|
+
// webhook, or any app route uniformly by naming its path. Path + body may
|
|
649
|
+
// interpolate the current form fields and (for grid rows/detail forms) the row
|
|
650
|
+
// via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
|
|
651
|
+
// {{row}} splice the object itself (type-preserving), so a body of
|
|
652
|
+
// "variables": "{{form}}" sends the form object as the variable map.
|
|
653
|
+
function lookupToken(path, ctx) {
|
|
654
|
+
const segs = String(path).split(".");
|
|
655
|
+
const head = segs[0];
|
|
656
|
+
let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
|
|
657
|
+
for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
|
|
658
|
+
return base;
|
|
659
|
+
}
|
|
660
|
+
function resolveTemplate(node, ctx) {
|
|
661
|
+
if (typeof node === "string") {
|
|
662
|
+
const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
|
|
663
|
+
if (whole) return lookupToken(whole[1], ctx);
|
|
664
|
+
return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
665
|
+
const v = lookupToken(k, ctx);
|
|
666
|
+
return v == null ? "" : String(v);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
|
|
670
|
+
if (node && typeof node === "object") {
|
|
671
|
+
// Null-proto target so a template key like "__proto__" can't trigger prototype
|
|
672
|
+
// mutation (prototype-pollution class) when copied into the resolved object.
|
|
673
|
+
const out = Object.create(null);
|
|
674
|
+
for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
return node;
|
|
678
|
+
}
|
|
679
|
+
async function runRoute(action, ctx) {
|
|
680
|
+
if (!action || typeof action.path !== "string" || action.path === "") {
|
|
681
|
+
throw new Error("This action has no route configured (action.path is missing or blank)");
|
|
682
|
+
}
|
|
683
|
+
const path = action.path.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
|
|
684
|
+
const v = lookupToken(k, ctx);
|
|
685
|
+
return v == null ? "" : encodeURIComponent(String(v));
|
|
686
|
+
});
|
|
687
|
+
const method = String(action.method || "POST").toUpperCase();
|
|
688
|
+
let body;
|
|
689
|
+
if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
|
|
690
|
+
else if (ctx.form) body = { variables: ctx.form };
|
|
691
|
+
else body = {};
|
|
692
|
+
const opts = { method, headers: { "content-type": "application/json" } };
|
|
693
|
+
if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
|
|
694
|
+
return getJSON(path, opts);
|
|
695
|
+
}
|
|
696
|
+
|
|
637
697
|
function el(tag, attrs = {}, ...kids) {
|
|
638
698
|
const n = document.createElement(tag);
|
|
639
699
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -734,37 +794,14 @@ function renderActionForm(node) {
|
|
|
734
794
|
}
|
|
735
795
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
736
796
|
try {
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
msg.textContent = "Field '" + p.action.correlationKeyField + "' is required";
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
// Send the trimmed key back as the variable too, so the value a worker
|
|
749
|
-
// reads matches the key the message was correlated on (no leading/trailing
|
|
750
|
-
// whitespace divergence between correlationKey and variables[field]).
|
|
751
|
-
variables[p.action.correlationKeyField] = correlationKey;
|
|
752
|
-
await getJSON("/app/actions/message", { method: "POST",
|
|
753
|
-
headers: { "content-type": "application/json" },
|
|
754
|
-
body: JSON.stringify({ name: p.action.message, correlationKey, variables }) });
|
|
755
|
-
msg.className = "pc-msg ok";
|
|
756
|
-
msg.textContent = "Message published";
|
|
757
|
-
} else {
|
|
758
|
-
if (!p.action || !p.action.process) {
|
|
759
|
-
msg.className = "pc-msg err";
|
|
760
|
-
msg.textContent = "This form has no action configured";
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
764
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
765
|
-
msg.className = "pc-msg ok";
|
|
766
|
-
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
767
|
-
}
|
|
797
|
+
// Route-driven: POST the form (as { variables } by default, or the action's
|
|
798
|
+
// own body template) to the action's route path. correlationKey / process /
|
|
799
|
+
// message are expressed by the path + body template the page author supplies,
|
|
800
|
+
// e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
|
|
801
|
+
const res = await runRoute(p.action, { form: variables });
|
|
802
|
+
msg.className = "pc-msg ok";
|
|
803
|
+
msg.textContent = (p.action && p.action.successLabel) ||
|
|
804
|
+
(res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
|
|
768
805
|
for (const input of inputs.values()) input.value = "";
|
|
769
806
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
770
807
|
} catch (e) {
|
|
@@ -839,23 +876,9 @@ function renderDataGrid(node) {
|
|
|
839
876
|
}
|
|
840
877
|
|
|
841
878
|
async function fireAction(action, row) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
body: JSON.stringify({ processInstanceKey: row[action.keyField] }) });
|
|
846
|
-
}
|
|
847
|
-
if (action.kind === "publishMessage") {
|
|
848
|
-
return getJSON("/app/actions/message", { method: "POST",
|
|
849
|
-
headers: { "content-type": "application/json" },
|
|
850
|
-
body: JSON.stringify({ name: action.message, correlationKey: row[action.correlationKeyField],
|
|
851
|
-
variables: { ...(action.variables || {}) } }) });
|
|
852
|
-
}
|
|
853
|
-
if (action.kind === "startProcess") {
|
|
854
|
-
return getJSON("/app/actions/start/" + encodeURIComponent(action.process), { method: "POST",
|
|
855
|
-
headers: { "content-type": "application/json" },
|
|
856
|
-
body: JSON.stringify({ variables: { ...(action.variables || {}) } }) });
|
|
857
|
-
}
|
|
858
|
-
throw new Error("unknown action");
|
|
879
|
+
// Route-driven row action: POST the action's body template (default {})
|
|
880
|
+
// to its route path, with {{row.KEY}} tokens resolved from this row.
|
|
881
|
+
return runRoute(action, { row });
|
|
859
882
|
}
|
|
860
883
|
|
|
861
884
|
function rowActionButton(row, ra) {
|
|
@@ -890,11 +913,13 @@ function renderDataGrid(node) {
|
|
|
890
913
|
btn.addEventListener("click", async () => {
|
|
891
914
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
|
|
892
915
|
try {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
916
|
+
// The textarea value is the form's single field (f.inputKey); route + body
|
|
917
|
+
// template come from f.action, e.g. { path: "/app/actions/message",
|
|
918
|
+
// body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
|
|
919
|
+
// Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
|
|
920
|
+
const form = Object.create(null); form[f.inputKey] = input.value;
|
|
921
|
+
await runRoute(f.action, { form, row });
|
|
922
|
+
msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
|
|
898
923
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
899
924
|
} catch (e) {
|
|
900
925
|
btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -111,7 +111,7 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
// The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
|
|
114
|
-
// still shadows an operation) and BEFORE the generic pages routes. It owns the
|
|
114
|
+
// still shadows an operation) and BEFORE the generic pages routes. It owns the fixed `/app/api`
|
|
115
115
|
// namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
|
|
116
116
|
const api = mountApi(ctx, app);
|
|
117
117
|
if (api.routes.length > 0) {
|