@nanobpm/urban 0.28.0 → 0.30.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/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- package/dist/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +23 -3
- package/dist/runtime/core/modules/datasource.js +61 -6
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
- package/dist/runtime/core/modules/instance-tracking.js +158 -0
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +17 -1
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +90 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +77 -6
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +180 -0
- package/src/runtime/core/modules/pages.test.ts +47 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +118 -5
- package/src/runtime/engine/nanosdk.ts +102 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- package/src/toolkit/index.ts +41 -1
package/src/runtime/core/host.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface WatchHandle {
|
|
|
35
35
|
close(): void;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** A minimal ambient async-scoped store — the subset of `AsyncLocalStorage` the runtime needs
|
|
39
|
+
* to thread a job's execution context to DataLayer writes for write-provenance capture. Backed
|
|
40
|
+
* by `node:async_hooks` in both the Node and Deno adapters (both provide it); core depends only
|
|
41
|
+
* on this interface, never on `node:*`. */
|
|
42
|
+
export interface AsyncStore<T> {
|
|
43
|
+
/** Run `fn` with `value` as the current store value, restoring the prior value afterwards. */
|
|
44
|
+
run<R>(value: T, fn: () => R): R;
|
|
45
|
+
/** The current store value, or undefined outside any `run`. */
|
|
46
|
+
current(): T | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** A tiny synchronous SQLite handle — the subset the runtime needs. */
|
|
39
50
|
export interface SqliteDb {
|
|
40
51
|
/** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
|
|
@@ -89,6 +100,12 @@ export interface HostContext {
|
|
|
89
100
|
watch?(onChange: (path: string) => void): WatchHandle;
|
|
90
101
|
/** Current wall-clock time in ms since epoch (seam for tests). */
|
|
91
102
|
now(): number;
|
|
103
|
+
/**
|
|
104
|
+
* Create an ambient async-scoped store (backed by `AsyncLocalStorage`). Used to thread the
|
|
105
|
+
* active job's execution context to DataLayer writes for write-provenance capture. Optional:
|
|
106
|
+
* a host that cannot provide one leaves provenance capture disabled (absent-safe).
|
|
107
|
+
*/
|
|
108
|
+
createAsyncStore?<T>(): AsyncStore<T>;
|
|
92
109
|
/** Structured log sink. */
|
|
93
110
|
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
94
111
|
}
|
|
@@ -146,6 +163,21 @@ export interface WorkerSubscription {
|
|
|
146
163
|
unsubscribe(): Promise<void>;
|
|
147
164
|
}
|
|
148
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A process instance's terminal-relevant lifecycle state, as the engine reports it.
|
|
168
|
+
* `ACTIVE` (still running), `COMPLETED` (ended normally at an end event), or
|
|
169
|
+
* `TERMINATED` (cancelled/terminated — via a row-cancel, an operator, or a crash).
|
|
170
|
+
* Deliberately the small set the instance-tracking reconciler keys on, not the richer
|
|
171
|
+
* element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
|
|
172
|
+
*/
|
|
173
|
+
export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
174
|
+
|
|
175
|
+
/** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
|
|
176
|
+
export interface ProcessInstanceSnapshot {
|
|
177
|
+
readonly processInstanceKey: string;
|
|
178
|
+
readonly state: ProcessInstanceState;
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
|
|
151
183
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
@@ -177,6 +209,18 @@ export interface EngineClient {
|
|
|
177
209
|
}): Promise<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]>;
|
|
178
210
|
/** Complete a user task. */
|
|
179
211
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* Search process instances by key and/or lifecycle state. The instance-tracking
|
|
214
|
+
* reconciler uses this to detect a tracked instance reaching a terminal state
|
|
215
|
+
* (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
|
|
216
|
+
* read-model row it backs would otherwise stay "active" forever. An eventually
|
|
217
|
+
* consistent read (zero-wait); pass the keys currently marked active in the
|
|
218
|
+
* read model and, optionally, a `state` to narrow the result.
|
|
219
|
+
*/
|
|
220
|
+
searchProcessInstances(filter?: {
|
|
221
|
+
processInstanceKeys?: string[];
|
|
222
|
+
state?: ProcessInstanceState;
|
|
223
|
+
}): Promise<ProcessInstanceSnapshot[]>;
|
|
180
224
|
/** Register a push worker for a job type. Draining is handled by the adapter. */
|
|
181
225
|
registerWorker(
|
|
182
226
|
jobType: string,
|
|
@@ -29,6 +29,7 @@ const engine: EngineClient = {
|
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
31
|
completeUserTask: async () => {},
|
|
32
|
+
searchProcessInstances: async () => [],
|
|
32
33
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
33
34
|
close: async () => {},
|
|
34
35
|
};
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
+
import type { AppManifest } from "../manifest.ts";
|
|
5
|
+
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
6
|
+
import { makeRouter } from "../router.ts";
|
|
7
|
+
import { DataLayer } from "./datasource.ts";
|
|
8
|
+
import { mountApi, resolveOperationHandler, type OperationHandler } from "./api.ts";
|
|
9
|
+
|
|
10
|
+
function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
|
|
11
|
+
return {
|
|
12
|
+
method,
|
|
13
|
+
path,
|
|
14
|
+
query: new URLSearchParams(opts.query ?? ""),
|
|
15
|
+
headers: new Headers(),
|
|
16
|
+
text: () => Promise.resolve(opts.body ?? ""),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const engine: EngineClient = {
|
|
21
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
22
|
+
createInstance: async () => ({ processInstanceKey: "pi" }),
|
|
23
|
+
cancelInstance: async () => {},
|
|
24
|
+
publishMessage: async () => {},
|
|
25
|
+
searchUserTasks: async () => [],
|
|
26
|
+
searchProcessInstances: async () => [],
|
|
27
|
+
completeUserTask: async () => {},
|
|
28
|
+
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
29
|
+
close: async () => {},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const data = new DataLayer(new Map(), undefined, {});
|
|
33
|
+
|
|
34
|
+
function appFixture(): AppApi {
|
|
35
|
+
return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: () => {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const spec = JSON.stringify({
|
|
39
|
+
openapi: "3.0.0",
|
|
40
|
+
components: {
|
|
41
|
+
schemas: {
|
|
42
|
+
Invoice: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
|
|
45
|
+
required: ["id", "amount"],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
paths: {
|
|
51
|
+
"/invoices": {
|
|
52
|
+
post: {
|
|
53
|
+
operationId: "createInvoice",
|
|
54
|
+
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
|
|
55
|
+
responses: { "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } } },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"/invoices/{id}": {
|
|
59
|
+
get: {
|
|
60
|
+
operationId: "getInvoice",
|
|
61
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
62
|
+
responses: { "200": {} },
|
|
63
|
+
},
|
|
64
|
+
delete: {
|
|
65
|
+
operationId: "rawDelete",
|
|
66
|
+
"x-urban-eject": true,
|
|
67
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
68
|
+
responses: { "204": {} },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
interface Built {
|
|
75
|
+
router: (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
|
|
76
|
+
imported: string[];
|
|
77
|
+
logs: Array<{ level: string; msg: string }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function build(
|
|
81
|
+
api: Record<string, unknown> | undefined,
|
|
82
|
+
modules: Record<string, Record<string, unknown>>,
|
|
83
|
+
specText = spec,
|
|
84
|
+
): Built {
|
|
85
|
+
const imported: string[] = [];
|
|
86
|
+
const logs: Array<{ level: string; msg: string }> = [];
|
|
87
|
+
const host: HostContext = {
|
|
88
|
+
runtime: "node",
|
|
89
|
+
env: () => undefined,
|
|
90
|
+
readTextFile: async () => specText,
|
|
91
|
+
listDir: async () => [],
|
|
92
|
+
exists: async () => false,
|
|
93
|
+
openSqlite: () => {
|
|
94
|
+
throw new Error("sqlite not used in this test");
|
|
95
|
+
},
|
|
96
|
+
importModule: (path: string) => {
|
|
97
|
+
imported.push(path);
|
|
98
|
+
const mod = modules[path];
|
|
99
|
+
if (!mod) return Promise.reject(new Error(`no module at ${path}`));
|
|
100
|
+
return Promise.resolve(mod);
|
|
101
|
+
},
|
|
102
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
103
|
+
now: () => 0,
|
|
104
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
105
|
+
};
|
|
106
|
+
const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
|
|
107
|
+
if (api) Reflect.set(manifest, "api", api);
|
|
108
|
+
const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
|
|
109
|
+
const handle = mountApi(ctx, appFixture());
|
|
110
|
+
const router = makeRouter(handle.routes);
|
|
111
|
+
return { router: (r) => Promise.resolve(router(r)), imported, logs };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test("resolveOperationHandler prefers default (function) then handler", () => {
|
|
115
|
+
const fn = () => ({});
|
|
116
|
+
assert.equal(resolveOperationHandler({ default: fn }), fn);
|
|
117
|
+
assert.equal(resolveOperationHandler({ handler: fn }), fn);
|
|
118
|
+
assert.equal(resolveOperationHandler({ nope: fn }), undefined);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no api binding → no routes (no-op for actions-only apps)", () => {
|
|
122
|
+
const { router } = build(undefined, {});
|
|
123
|
+
// makeRouter over an empty route table 404s everything.
|
|
124
|
+
return router(req("GET", "/app/api/invoices/1")).then((res) => assert.equal(res.status, 404));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("routes an operation to its delegate with validated params + body", async () => {
|
|
128
|
+
const seen: unknown[] = [];
|
|
129
|
+
const handler: OperationHandler = (input) => {
|
|
130
|
+
seen.push({ params: input.params, body: input.body });
|
|
131
|
+
return { status: 201, body: { id: "i1", amount: 5 } };
|
|
132
|
+
};
|
|
133
|
+
const { router, imported } = build(
|
|
134
|
+
{ spec: "openapi.json", dir: "operations" },
|
|
135
|
+
{ "/app/operations/createInvoice": { default: handler } },
|
|
136
|
+
);
|
|
137
|
+
const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ id: "i1", amount: 5 }) }));
|
|
138
|
+
assert.equal(res.status, 201);
|
|
139
|
+
assert.deepEqual(JSON.parse(res.body!), { id: "i1", amount: 5 });
|
|
140
|
+
assert.deepEqual(seen, [{ params: {}, body: { id: "i1", amount: 5 } }]);
|
|
141
|
+
assert.deepEqual(imported, ["/app/operations/createInvoice"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("captures path params and passes them to the delegate", async () => {
|
|
145
|
+
let got: Record<string, string> | undefined;
|
|
146
|
+
const { router } = build(
|
|
147
|
+
{ spec: "openapi.json" },
|
|
148
|
+
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
149
|
+
);
|
|
150
|
+
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
151
|
+
assert.equal(res.status, 200);
|
|
152
|
+
assert.deepEqual(got, { id: "42" });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("body failing schema validation is a structured 400 before the delegate loads", async () => {
|
|
156
|
+
const { router, imported } = build(
|
|
157
|
+
{ spec: "openapi.json" },
|
|
158
|
+
{ "/app/operations/createInvoice": { default: () => ({ body: {} }) } },
|
|
159
|
+
);
|
|
160
|
+
const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ amount: 0 }) }));
|
|
161
|
+
assert.equal(res.status, 400);
|
|
162
|
+
const parsed = JSON.parse(res.body!);
|
|
163
|
+
assert.equal(parsed.error, "request validation failed");
|
|
164
|
+
assert.ok(Array.isArray(parsed.issues) && parsed.issues.length >= 1);
|
|
165
|
+
assert.deepEqual(imported, []); // delegate never loaded
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("missing required body is a 400", async () => {
|
|
169
|
+
const { router } = build({ spec: "openapi.json" }, { "/app/operations/createInvoice": { default: () => ({}) } });
|
|
170
|
+
const res = await router(req("POST", "/app/api/invoices"));
|
|
171
|
+
assert.equal(res.status, 400);
|
|
172
|
+
assert.match(JSON.parse(res.body!).error, /validation failed/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("invalid JSON body is a 400 (non-ejected)", async () => {
|
|
176
|
+
const { router } = build({ spec: "openapi.json" }, { "/app/operations/createInvoice": { default: () => ({}) } });
|
|
177
|
+
const res = await router(req("POST", "/app/api/invoices", { body: "{not json" }));
|
|
178
|
+
assert.equal(res.status, 400);
|
|
179
|
+
assert.match(JSON.parse(res.body!).error, /must be JSON/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("ejected operation skips validation and still routes to its delegate", async () => {
|
|
183
|
+
let called = false;
|
|
184
|
+
const { router } = build(
|
|
185
|
+
{ spec: "openapi.json" },
|
|
186
|
+
{ "/app/operations/rawDelete": { default: () => { called = true; return { status: 204 }; } } },
|
|
187
|
+
);
|
|
188
|
+
// No validation on an ejected op even though body is not schema-checked.
|
|
189
|
+
const res = await router(req("DELETE", "/app/api/invoices/9", { body: "{not json" }));
|
|
190
|
+
assert.equal(res.status, 204);
|
|
191
|
+
assert.equal(called, true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("whole-surface eject skips validation for every operation", async () => {
|
|
195
|
+
let seenBody: unknown;
|
|
196
|
+
const { router } = build(
|
|
197
|
+
{ spec: "openapi.json", eject: true },
|
|
198
|
+
{ "/app/operations/createInvoice": { default: (i: { body: unknown }) => { seenBody = i.body; return { body: { ok: true } }; } } },
|
|
199
|
+
);
|
|
200
|
+
const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ amount: 0 }) }));
|
|
201
|
+
assert.equal(res.status, 200); // amount:0 would fail validation, but eject skips it
|
|
202
|
+
assert.deepEqual(seenBody, { amount: 0 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("known path on the wrong method is 405; unknown path is 404", async () => {
|
|
206
|
+
const { router } = build({ spec: "openapi.json" }, { "/app/operations/getInvoice": { default: () => ({}) } });
|
|
207
|
+
assert.equal((await router(req("PUT", "/app/api/invoices/1"))).status, 405);
|
|
208
|
+
assert.equal((await router(req("GET", "/app/api/nope"))).status, 404);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a delegate that fails to load is a 500 on that route", async () => {
|
|
212
|
+
const { router } = build({ spec: "openapi.json" }, {}); // no modules registered
|
|
213
|
+
const res = await router(req("GET", "/app/api/invoices/1"));
|
|
214
|
+
assert.equal(res.status, 500);
|
|
215
|
+
assert.match(JSON.parse(res.body!).error, /delegate failed to load/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a malformed spec surfaces as a 500, not a boot failure", async () => {
|
|
219
|
+
const { router } = build({ spec: "openapi.json" }, {}, "{not json");
|
|
220
|
+
const res = await router(req("GET", "/app/api/invoices/1"));
|
|
221
|
+
assert.equal(res.status, 500);
|
|
222
|
+
assert.match(JSON.parse(res.body!).error, /failed to load/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a manifest api.base without a leading slash is normalized so routes still match", async () => {
|
|
226
|
+
let got: Record<string, string> | undefined;
|
|
227
|
+
const { router } = build(
|
|
228
|
+
{ spec: "openapi.json", base: "app/api" }, // no leading slash
|
|
229
|
+
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
230
|
+
);
|
|
231
|
+
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
232
|
+
assert.equal(res.status, 200);
|
|
233
|
+
assert.deepEqual(got, { id: "42" });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a malformed spec pattern surfaces as a controlled 500, not a crash", async () => {
|
|
237
|
+
const badPatternSpec = JSON.stringify({
|
|
238
|
+
openapi: "3.0.0",
|
|
239
|
+
paths: {
|
|
240
|
+
"/things": {
|
|
241
|
+
post: {
|
|
242
|
+
operationId: "createThing",
|
|
243
|
+
requestBody: {
|
|
244
|
+
required: true,
|
|
245
|
+
content: {
|
|
246
|
+
"application/json": {
|
|
247
|
+
schema: { type: "object", properties: { name: { type: "string", pattern: "([" } }, required: ["name"] },
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
responses: { "200": {} },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const { router } = build(
|
|
257
|
+
{ spec: "openapi.json" },
|
|
258
|
+
{ "/app/operations/createThing": { default: () => ({ body: {} }) } },
|
|
259
|
+
badPatternSpec,
|
|
260
|
+
);
|
|
261
|
+
const res = await router(req("POST", "/app/api/things", { body: JSON.stringify({ name: "x" }) }));
|
|
262
|
+
assert.equal(res.status, 500);
|
|
263
|
+
assert.match(JSON.parse(res.body!).error, /invalid pattern/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("an empty api.dir falls back to the default operations dir, not an absolute path", async () => {
|
|
267
|
+
let called = false;
|
|
268
|
+
const { router, imported } = build(
|
|
269
|
+
{ spec: "openapi.json", dir: "" }, // misconfigured empty dir
|
|
270
|
+
{ "/app/operations/getInvoice": { default: () => { called = true; return { body: { ok: true } }; } } },
|
|
271
|
+
);
|
|
272
|
+
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
273
|
+
assert.equal(res.status, 200);
|
|
274
|
+
assert.equal(called, true);
|
|
275
|
+
assert.deepEqual(imported, ["/app/operations/getInvoice"]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("malformed percent-encoding in a path param is a 400, not a 500", async () => {
|
|
279
|
+
const { router } = build(
|
|
280
|
+
{ spec: "openapi.json" },
|
|
281
|
+
{ "/app/operations/getInvoice": { default: () => ({ body: { ok: true } }) } },
|
|
282
|
+
);
|
|
283
|
+
const res = await router(req("GET", "/app/api/invoices/%E0%A4%A")); // invalid UTF-8 escape
|
|
284
|
+
assert.equal(res.status, 400);
|
|
285
|
+
assert.match(JSON.parse(res.body!).error, /malformed path parameter encoding/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("repeated query keys are all validated (extra values don't bypass validation)", async () => {
|
|
289
|
+
const arraySpec = JSON.stringify({
|
|
290
|
+
openapi: "3.0.0",
|
|
291
|
+
paths: {
|
|
292
|
+
"/items": {
|
|
293
|
+
get: {
|
|
294
|
+
operationId: "listItems",
|
|
295
|
+
parameters: [{ name: "n", in: "query", schema: { type: "integer", minimum: 1 } }],
|
|
296
|
+
responses: { "200": {} },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const { router } = build(
|
|
302
|
+
{ spec: "openapi.json" },
|
|
303
|
+
{ "/app/operations/listItems": { default: () => ({ body: { ok: true } }) } },
|
|
304
|
+
arraySpec,
|
|
305
|
+
);
|
|
306
|
+
// First value valid, second (0) violates minimum:1 — must still be caught.
|
|
307
|
+
const res = await router(req("GET", "/app/api/items", { query: "n=5&n=0" }));
|
|
308
|
+
assert.equal(res.status, 400);
|
|
309
|
+
assert.match(JSON.parse(res.body!).error, /validation failed/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("array-typed query params validate the whole array (items + array constraints)", async () => {
|
|
313
|
+
const arraySpec = JSON.stringify({
|
|
314
|
+
openapi: "3.0.0",
|
|
315
|
+
paths: {
|
|
316
|
+
"/items": {
|
|
317
|
+
get: {
|
|
318
|
+
operationId: "listItems",
|
|
319
|
+
parameters: [
|
|
320
|
+
{ name: "tag", in: "query", schema: { type: "array", minItems: 2, items: { type: "string", enum: ["a", "b"] } } },
|
|
321
|
+
],
|
|
322
|
+
responses: { "200": {} },
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
const build2 = (q: string) =>
|
|
328
|
+
build(
|
|
329
|
+
{ spec: "openapi.json" },
|
|
330
|
+
{ "/app/operations/listItems": { default: () => ({ body: { ok: true } }) } },
|
|
331
|
+
arraySpec,
|
|
332
|
+
).router(req("GET", "/app/api/items", { query: q }));
|
|
333
|
+
assert.equal((await build2("tag=a&tag=b")).status, 200); // valid array
|
|
334
|
+
assert.equal((await build2("tag=a")).status, 400); // fewer than minItems
|
|
335
|
+
assert.equal((await build2("tag=a&tag=c")).status, 400); // "c" not in enum
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a failed initial spec load is retried, not cached as a permanent 500", async () => {
|
|
339
|
+
let attempts = 0;
|
|
340
|
+
const host: HostContext = {
|
|
341
|
+
runtime: "node",
|
|
342
|
+
env: () => undefined,
|
|
343
|
+
readTextFile: async () => {
|
|
344
|
+
attempts++;
|
|
345
|
+
if (attempts === 1) throw new Error("ENOENT: spec missing");
|
|
346
|
+
return spec;
|
|
347
|
+
},
|
|
348
|
+
listDir: async () => [],
|
|
349
|
+
exists: async () => false,
|
|
350
|
+
openSqlite: () => {
|
|
351
|
+
throw new Error("sqlite not used in this test");
|
|
352
|
+
},
|
|
353
|
+
importModule: () => Promise.resolve({ default: () => ({ body: { ok: true } }) }),
|
|
354
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
355
|
+
now: () => 0,
|
|
356
|
+
log: () => {},
|
|
357
|
+
};
|
|
358
|
+
const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
|
|
359
|
+
Reflect.set(manifest, "api", { spec: "openapi.json" });
|
|
360
|
+
const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
|
|
361
|
+
const router = makeRouter(mountApi(ctx, appFixture()).routes);
|
|
362
|
+
const first = await Promise.resolve(router(req("GET", "/app/api/invoices/42")));
|
|
363
|
+
assert.equal(first.status, 500); // initial read fails
|
|
364
|
+
const second = await Promise.resolve(router(req("GET", "/app/api/invoices/42")));
|
|
365
|
+
assert.equal(second.status, 200); // retried (opsPromise not cached as a rejection)
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("an op declaring no requestBody gets body:undefined at runtime (matches derived body-less type)", async () => {
|
|
369
|
+
// The deriver types `body` as `undefined` for an op with no requestBody, so the runtime must not
|
|
370
|
+
// leak a parsed JSON value into such a body-less handler.
|
|
371
|
+
const noBodySpec = JSON.stringify({
|
|
372
|
+
openapi: "3.0.0",
|
|
373
|
+
paths: {
|
|
374
|
+
"/ping": {
|
|
375
|
+
post: { operationId: "ping", responses: { "200": {} } },
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
let seenBody: unknown = "unset";
|
|
380
|
+
const { router } = build(
|
|
381
|
+
{ spec: "openapi.json" },
|
|
382
|
+
{ "/app/operations/ping": { default: (i: { body: unknown }) => { seenBody = i.body; return { body: { ok: true } }; } } },
|
|
383
|
+
noBodySpec,
|
|
384
|
+
);
|
|
385
|
+
const res = await router(req("POST", "/app/api/ping", { body: JSON.stringify({ sneaky: true }) }));
|
|
386
|
+
assert.equal(res.status, 200);
|
|
387
|
+
assert.equal(seenBody, undefined);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("readApiBinding trims whitespace on base/dir/spec so benign formatting can't break resolution", async () => {
|
|
391
|
+
let got: Record<string, string> | undefined;
|
|
392
|
+
const { router, imported } = build(
|
|
393
|
+
{ spec: " openapi.json ", base: " app/api ", dir: " operations " },
|
|
394
|
+
{ "/app/operations/getInvoice": { default: (i: { params: Record<string, string> }) => { got = i.params; return { body: { ok: true } }; } } },
|
|
395
|
+
);
|
|
396
|
+
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
397
|
+
assert.equal(res.status, 200);
|
|
398
|
+
assert.deepEqual(got, { id: "42" });
|
|
399
|
+
// dir was trimmed → the delegate resolved at the clean path, not "operations /getInvoice".
|
|
400
|
+
assert.ok(imported.includes("/app/operations/getInvoice"));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("loadModule strips a trailing backslash from api.dir (Windows-style path)", async () => {
|
|
404
|
+
const { router, imported } = build(
|
|
405
|
+
{ spec: "openapi.json", dir: "operations\\" },
|
|
406
|
+
{ "/app/operations/getInvoice": { default: () => ({ body: { ok: true } }) } },
|
|
407
|
+
);
|
|
408
|
+
const res = await router(req("GET", "/app/api/invoices/42"));
|
|
409
|
+
assert.equal(res.status, 200);
|
|
410
|
+
assert.ok(imported.includes("/app/operations/getInvoice"));
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("a static route is not shadowed by a templated one regardless of spec order", async () => {
|
|
414
|
+
// Lexicographically "/items/{id}" sorts before "/items/active"; specificity ordering must still
|
|
415
|
+
// route the static path to its own delegate.
|
|
416
|
+
const specText = JSON.stringify({
|
|
417
|
+
openapi: "3.0.0",
|
|
418
|
+
paths: {
|
|
419
|
+
"/items/{id}": { get: { operationId: "getItem", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": {} } } },
|
|
420
|
+
"/items/active": { get: { operationId: "listActive", responses: { "200": {} } } },
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
const { router } = build(
|
|
424
|
+
{ spec: "openapi.json" },
|
|
425
|
+
{
|
|
426
|
+
"/app/operations/getItem": { default: () => ({ body: { which: "template" } }) },
|
|
427
|
+
"/app/operations/listActive": { default: () => ({ body: { which: "static" } }) },
|
|
428
|
+
},
|
|
429
|
+
specText,
|
|
430
|
+
);
|
|
431
|
+
const res = await router(req("GET", "/app/api/items/active"));
|
|
432
|
+
assert.equal(res.status, 200);
|
|
433
|
+
assert.equal(JSON.parse(res.body ?? "{}").which, "static");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("an unsafe operationId never mounts (skipped) — a crafted spec can't import outside operations", async () => {
|
|
437
|
+
const specText = JSON.stringify({
|
|
438
|
+
openapi: "3.0.0",
|
|
439
|
+
paths: { "/x": { get: { operationId: "../../evil", responses: { "200": {} } } } },
|
|
440
|
+
});
|
|
441
|
+
const { router, imported } = build({ spec: "openapi.json" }, {}, specText);
|
|
442
|
+
const res = await router(req("GET", "/app/api/x"));
|
|
443
|
+
assert.equal(res.status, 404); // no route mounted for the skipped op
|
|
444
|
+
assert.equal(imported.some((p) => p.includes("evil")), false);
|
|
445
|
+
});
|