@nanobpm/urban 0.34.0 → 0.36.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/cli.d.ts +0 -1
- package/dist/cli.js +32 -47
- package/dist/openapi/spec.d.ts +63 -0
- package/dist/openapi/spec.js +168 -0
- package/dist/runtime/core/modules/api.d.ts +19 -4
- package/dist/runtime/core/modules/api.js +99 -4
- package/dist/runtime/core/runtime.d.ts +10 -0
- package/dist/runtime/core/runtime.js +2 -2
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +2 -1
- package/dist/toolkit/api-dir.d.ts +11 -0
- package/dist/toolkit/api-dir.js +36 -0
- package/dist/toolkit/derivers/api.d.ts +25 -2
- package/dist/toolkit/derivers/api.js +60 -5
- package/dist/toolkit/gen.js +4 -1
- package/dist/toolkit/generate.d.ts +27 -0
- package/dist/toolkit/generate.js +47 -0
- package/dist/toolkit/index.d.ts +8 -1
- package/dist/toolkit/index.js +5 -1
- package/dist/toolkit/scaffold/operations.d.ts +21 -0
- package/dist/toolkit/scaffold/operations.js +55 -0
- package/dist/toolkit/scaffold/workers.js +1 -1
- package/dist/toolkit/scaffold.d.ts +16 -0
- package/dist/toolkit/scaffold.js +40 -0
- package/package.json +74 -74
- package/src/cli.test.ts +0 -1
- package/src/cli.ts +30 -45
- package/src/openapi/spec.test.ts +171 -0
- package/src/openapi/spec.ts +219 -1
- package/src/runtime/core/modules/api.test.ts +183 -5
- package/src/runtime/core/modules/api.ts +125 -8
- package/src/runtime/core/runtime.test.ts +53 -1
- package/src/runtime/core/runtime.ts +12 -2
- package/src/runtime/index.ts +3 -1
- package/src/toolkit/api-dir.test.ts +32 -0
- package/src/toolkit/api-dir.ts +41 -0
- package/src/toolkit/derivers/api.test.ts +41 -3
- package/src/toolkit/derivers/api.ts +75 -5
- package/src/toolkit/gen.ts +5 -2
- package/src/toolkit/generate.test.ts +135 -0
- package/src/toolkit/generate.ts +71 -0
- package/src/toolkit/index.ts +10 -0
- package/src/toolkit/scaffold/operations.test.ts +136 -0
- package/src/toolkit/scaffold/operations.ts +75 -0
- package/src/toolkit/scaffold/workers.ts +1 -1
- package/src/toolkit/scaffold.ts +56 -0
|
@@ -15,6 +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 { MODULE_EXTENSION_CANDIDATES } from "../module-path.ts";
|
|
18
19
|
import { html, json, normalizeRoutePath, type Route } from "../router.ts";
|
|
19
20
|
import { resolveAppPath } from "./datasource.ts";
|
|
20
21
|
import {
|
|
@@ -29,6 +30,8 @@ import {
|
|
|
29
30
|
resolveSchema,
|
|
30
31
|
toRouteMatcher,
|
|
31
32
|
undeclaredPathParams,
|
|
33
|
+
undeclaredSecuritySchemes,
|
|
34
|
+
evaluateSecurity,
|
|
32
35
|
validateValue,
|
|
33
36
|
type ValidationIssue,
|
|
34
37
|
} from "../../../openapi/spec.ts";
|
|
@@ -89,18 +92,24 @@ window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger
|
|
|
89
92
|
</html>`;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
/** The `{ params, query, body }` contract a typed operation is generic over.
|
|
95
|
+
/** The `{ params, query, body }` contract a typed operation is generic over. `body` is optional so
|
|
96
|
+
* a generated request type for an operation without a required requestBody (its `body?` is
|
|
97
|
+
* optional/`undefined`) still satisfies the constraint — see the generated `operations.ts` /
|
|
98
|
+
* `controller.ts`, which parameterise `OperationHandler` over the per-op request type. */
|
|
93
99
|
export interface OperationContract {
|
|
94
100
|
params: unknown;
|
|
95
101
|
query: unknown;
|
|
96
|
-
body
|
|
102
|
+
body?: unknown;
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body).
|
|
105
|
+
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). `body` is
|
|
106
|
+
* optional so a raw (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the
|
|
107
|
+
* generated registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body
|
|
108
|
+
* is optional/absent must be a subtype of the default contract (handler args are contravariant). */
|
|
100
109
|
export interface DefaultContract {
|
|
101
110
|
params: Record<string, string>;
|
|
102
111
|
query: Record<string, string | string[] | undefined>;
|
|
103
|
-
body
|
|
112
|
+
body?: unknown;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
|
|
@@ -125,6 +134,27 @@ export type OperationHandler<Req extends OperationContract = DefaultContract, Re
|
|
|
125
134
|
app: AppApi,
|
|
126
135
|
) => Promise<OperationResult<Res> | void> | OperationResult<Res> | void;
|
|
127
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Thrown by a write-once operation stub to signal the endpoint exists but has no implementation
|
|
139
|
+
* yet; the dispatcher maps it to HTTP `501 Not Implemented` (ADR 0059). Delete the throw once you
|
|
140
|
+
* implement the handler body. A brand property makes the check robust across module realms.
|
|
141
|
+
*/
|
|
142
|
+
export class NotImplemented extends Error {
|
|
143
|
+
readonly urbanNotImplemented = true as const;
|
|
144
|
+
constructor(operationId: string) {
|
|
145
|
+
super(`operation not implemented: ${operationId}`);
|
|
146
|
+
this.name = "NotImplemented";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True for a `NotImplemented` throw (instanceof, or the brand — resilient to realm/dup-copy). */
|
|
151
|
+
function isNotImplemented(e: unknown): boolean {
|
|
152
|
+
return (
|
|
153
|
+
e instanceof NotImplemented ||
|
|
154
|
+
(typeof e === "object" && e !== null && Reflect.get(e, "urbanNotImplemented") === true)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
128
158
|
/**
|
|
129
159
|
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
130
160
|
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
@@ -143,6 +173,10 @@ function isFunction(v: unknown): v is OperationHandler {
|
|
|
143
173
|
return typeof v === "function";
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
177
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
178
|
+
}
|
|
179
|
+
|
|
146
180
|
/** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
|
|
147
181
|
export function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined {
|
|
148
182
|
if (isFunction(mod.default)) return mod.default;
|
|
@@ -288,7 +322,8 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
288
322
|
return docPromise;
|
|
289
323
|
};
|
|
290
324
|
|
|
291
|
-
// Lazily import + cache each delegate module, mirroring mountActions.
|
|
325
|
+
// Lazily import + cache each delegate module, mirroring mountActions. This is the back-compat
|
|
326
|
+
// fallback for apps without a generated controller registry (see loadController below).
|
|
292
327
|
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
|
293
328
|
const loadModule = (operationId: string): Promise<Record<string, unknown>> => {
|
|
294
329
|
// Defense-in-depth at the import sink: collectOperations already skips unsafe ids, but never
|
|
@@ -308,6 +343,43 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
308
343
|
return pending;
|
|
309
344
|
};
|
|
310
345
|
|
|
346
|
+
// Prefer the generated delegate registry (ADR 0059): one statically-typed barrel the toolkit
|
|
347
|
+
// emits from the spec, so a missing/mismatched/orphan delegate is a `tsc` error, and dispatch is
|
|
348
|
+
// a deterministic map lookup rather than a per-request path import. Imported ONCE and cached.
|
|
349
|
+
// Absent (an app that hasn't re-genned) → `null`, and dispatch falls back to `loadModule`. A
|
|
350
|
+
// present-but-broken controller (a delegate that throws at import) is NOT swallowed — it retries
|
|
351
|
+
// and surfaces as a 500 with the reason, exactly like a bad delegate does today.
|
|
352
|
+
let controllerPromise: Promise<Record<string, unknown> | null> | undefined;
|
|
353
|
+
const loadController = (): Promise<Record<string, unknown> | null> => {
|
|
354
|
+
if (!controllerPromise) {
|
|
355
|
+
const rel = "nano-generated/controller";
|
|
356
|
+
controllerPromise = (async () => {
|
|
357
|
+
// Probe every supported module extension (compiled .js first, mirroring resolveModulePath),
|
|
358
|
+
// NOT just `.ts`: a published / `urban run` app ships `controller.js` with the TS sources
|
|
359
|
+
// absent, and probing only `.ts` there would wrongly report the registry missing and fall
|
|
360
|
+
// back to per-op dynamic imports — defeating deterministic dispatch in production.
|
|
361
|
+
let present = false;
|
|
362
|
+
for (const ext of MODULE_EXTENSION_CANDIDATES) {
|
|
363
|
+
if (await ctx.host.exists(resolveAppPath(ctx.root, rel + ext))) {
|
|
364
|
+
present = true;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (!present) return null;
|
|
369
|
+
const mod = await ctx.host.importModule(resolveAppPath(ctx.root, rel));
|
|
370
|
+
const ops = mod.operations;
|
|
371
|
+
if (!isRecord(ops)) {
|
|
372
|
+
throw new Error("nano-generated/controller must export an `operations` registry");
|
|
373
|
+
}
|
|
374
|
+
return ops;
|
|
375
|
+
})().catch((e) => {
|
|
376
|
+
controllerPromise = undefined; // don't cache the failure — let a later request retry
|
|
377
|
+
throw e;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return controllerPromise;
|
|
381
|
+
};
|
|
382
|
+
|
|
311
383
|
// Build the operation table lazily from the loaded doc (cached).
|
|
312
384
|
let opsPromise: Promise<MountedOp[]> | undefined;
|
|
313
385
|
const loadOps = (): Promise<MountedOp[]> => {
|
|
@@ -336,6 +408,14 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
336
408
|
{ unsafe },
|
|
337
409
|
);
|
|
338
410
|
}
|
|
411
|
+
const undeclaredSchemes = undeclaredSecuritySchemes(d);
|
|
412
|
+
if (undeclaredSchemes.length > 0) {
|
|
413
|
+
ctx.host.log(
|
|
414
|
+
"warn",
|
|
415
|
+
"OpenAPI operations require a security scheme not declared in components.securitySchemes — those requests are rejected with 500 (ADR 0059)",
|
|
416
|
+
{ undeclared: undeclaredSchemes },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
339
419
|
const mounted = collectOperations(d).map((op) => {
|
|
340
420
|
const { pattern, paramNames } = toRouteMatcher(base, op.path);
|
|
341
421
|
return { op, pattern, paramNames };
|
|
@@ -384,6 +464,26 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
384
464
|
const { op } = match;
|
|
385
465
|
const ejected = surfaceEject || op.eject;
|
|
386
466
|
|
|
467
|
+
// Enforce the operation's security requirements (ADR 0059) before touching the body or the
|
|
468
|
+
// delegate: an unauthorized request should do no work and reveal nothing. Enforced even for
|
|
469
|
+
// ejected ops (eject changes body handling, not who may call the operation).
|
|
470
|
+
const authz = evaluateSecurity(
|
|
471
|
+
d,
|
|
472
|
+
op,
|
|
473
|
+
(name) => req.headers.get(name) ?? undefined,
|
|
474
|
+
(name) => req.query.get(name) ?? undefined,
|
|
475
|
+
(envVar) => ctx.host.env(envVar),
|
|
476
|
+
);
|
|
477
|
+
if (!authz.ok) {
|
|
478
|
+
if (authz.status === 500) {
|
|
479
|
+
ctx.host.log("error", "OpenAPI operation security is misconfigured (ADR 0059)", {
|
|
480
|
+
operationId: op.operationId,
|
|
481
|
+
reason: authz.error,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return json({ error: authz.error ?? "unauthorized" }, authz.status ?? 401);
|
|
485
|
+
}
|
|
486
|
+
|
|
387
487
|
// Path params from the capture groups; query as a single-or-array map.
|
|
388
488
|
const captures = match.pattern.exec(req.path) ?? [];
|
|
389
489
|
const params: Record<string, string> = {};
|
|
@@ -466,7 +566,21 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
466
566
|
|
|
467
567
|
let handler: OperationHandler | undefined;
|
|
468
568
|
try {
|
|
469
|
-
|
|
569
|
+
const controller = await loadController();
|
|
570
|
+
if (controller) {
|
|
571
|
+
// Deterministic dispatch: the generated registry guarantees (at `tsc` time) one delegate
|
|
572
|
+
// per operationId. A missing/non-function key here means the controller is stale vs the spec.
|
|
573
|
+
const candidate = controller[op.operationId];
|
|
574
|
+
if (!isFunction(candidate)) {
|
|
575
|
+
ctx.host.log("error", "operation not registered in the generated controller (run `urban gen`)", {
|
|
576
|
+
operationId: op.operationId,
|
|
577
|
+
});
|
|
578
|
+
return json({ error: `operation ${op.operationId} is not registered — run \`urban gen\`` }, 500);
|
|
579
|
+
}
|
|
580
|
+
handler = candidate;
|
|
581
|
+
} else {
|
|
582
|
+
handler = resolveOperationHandler(await loadModule(op.operationId));
|
|
583
|
+
}
|
|
470
584
|
} catch (e) {
|
|
471
585
|
ctx.host.log("error", "operation delegate failed to load", {
|
|
472
586
|
operationId: op.operationId,
|
|
@@ -511,11 +625,14 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
511
625
|
body: result.body === undefined ? undefined : JSON.stringify(result.body),
|
|
512
626
|
};
|
|
513
627
|
} catch (e) {
|
|
628
|
+
// A write-once stub (or any delegate) can signal "endpoint exists, not implemented yet" with
|
|
629
|
+
// a NotImplemented throw → 501, distinct from an unexpected fault (500).
|
|
630
|
+
if (isNotImplemented(e)) {
|
|
631
|
+
return json({ error: errorMessage(e), operationId: op.operationId }, 501);
|
|
632
|
+
}
|
|
514
633
|
return json({ error: errorMessage(e) }, 500);
|
|
515
634
|
}
|
|
516
635
|
};
|
|
517
|
-
|
|
518
|
-
// Categorical safety net: any unexpected throw during dispatch — e.g. a malformed spec
|
|
519
636
|
// `pattern` surfacing from validation — becomes a controlled 500 rather than escaping into
|
|
520
637
|
// the host request handler, which has no top-level catch of its own.
|
|
521
638
|
const dispatch = async (req: HttpRequest): Promise<HttpResponse> => {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createNodeHost } from "../adapters/node.ts";
|
|
7
7
|
import { createUrbanApp, resolvePort } from "./runtime.ts";
|
|
8
|
+
import type { SchedulerDeps } from "./modules/scheduler.ts";
|
|
8
9
|
import { runFromEnv } from "../run.ts";
|
|
9
10
|
import { isRecord } from "./guards.ts";
|
|
10
11
|
import type {
|
|
@@ -405,3 +406,54 @@ test("treats NANOBPMN_APP_HANDSHAKE other than \"1\" as opt-out", async () => {
|
|
|
405
406
|
await rm(dir, { recursive: true, force: true });
|
|
406
407
|
}
|
|
407
408
|
});
|
|
409
|
+
|
|
410
|
+
// A minimal deterministic scheduler seam: records armed timers so a test can assert the
|
|
411
|
+
// injected scheduler — not the live one — drives createUrbanApp's background loops.
|
|
412
|
+
function countingScheduler(): SchedulerDeps & { armed: () => number } {
|
|
413
|
+
let clock = 0;
|
|
414
|
+
let armed = 0;
|
|
415
|
+
return {
|
|
416
|
+
now: () => clock,
|
|
417
|
+
setTimer: (_fn, _ms) => {
|
|
418
|
+
armed += 1;
|
|
419
|
+
return armed;
|
|
420
|
+
},
|
|
421
|
+
clearTimer: () => {},
|
|
422
|
+
armed: () => armed,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function makeTrackingFixture(): Promise<string> {
|
|
427
|
+
const dir = await makeFixture();
|
|
428
|
+
const manifestPath = join(dir, "nano.app.json");
|
|
429
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
430
|
+
manifest.instanceTracking = [
|
|
431
|
+
{
|
|
432
|
+
table: "crew_tasks",
|
|
433
|
+
keyField: "id",
|
|
434
|
+
statusField: "status",
|
|
435
|
+
activeStatuses: ["claimed"],
|
|
436
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
437
|
+
pollMs: 1000,
|
|
438
|
+
},
|
|
439
|
+
];
|
|
440
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
441
|
+
return dir;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
test("threads an injected scheduler into the instance-tracking reconciler loop", async () => {
|
|
445
|
+
const dir = await makeTrackingFixture();
|
|
446
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
447
|
+
const engine = new FakeEngine();
|
|
448
|
+
const sched = countingScheduler();
|
|
449
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0, scheduler: sched });
|
|
450
|
+
try {
|
|
451
|
+
await app.start();
|
|
452
|
+
// The reconciler arms its first poll on the injected scheduler, not the live one — so the
|
|
453
|
+
// whole-app settle loop can drive it deterministically over a virtual clock.
|
|
454
|
+
assert.ok(sched.armed() >= 1, "injected scheduler armed the reconciler poll");
|
|
455
|
+
} finally {
|
|
456
|
+
await app.stop();
|
|
457
|
+
await rm(dir, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
@@ -18,6 +18,7 @@ import { mountConnectors } from "./modules/connectors.ts";
|
|
|
18
18
|
import { mountSurfaces } from "./modules/surfaces.ts";
|
|
19
19
|
import { mountTriggers } from "./modules/triggers.ts";
|
|
20
20
|
import { mountInstanceTracking } from "./modules/instance-tracking.ts";
|
|
21
|
+
import type { SchedulerDeps } from "./modules/scheduler.ts";
|
|
21
22
|
import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
|
|
22
23
|
|
|
23
24
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
@@ -76,6 +77,15 @@ export interface CreateUrbanAppOptions {
|
|
|
76
77
|
* `name → content` map). Merged over the manifest's `models.templates` (this wins on
|
|
77
78
|
* collision). See `deployModels`. */
|
|
78
79
|
templates?: TemplateSource;
|
|
80
|
+
/**
|
|
81
|
+
* Injectable timer + clock seam driving every background loop (the cron trigger loops
|
|
82
|
+
* and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
|
|
83
|
+
* wall clock). The e2e test kit injects a manual scheduler so those loops advance
|
|
84
|
+
* deterministically over a virtual clock — the single seam that makes a whole-app
|
|
85
|
+
* `settle()` possible. Threaded verbatim into `mountTriggers` and `mountInstanceTracking`
|
|
86
|
+
* so one injected scheduler drives them both (no per-module wiring, no drift).
|
|
87
|
+
*/
|
|
88
|
+
scheduler?: SchedulerDeps;
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
export interface UrbanApp {
|
|
@@ -210,13 +220,13 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
210
220
|
describe.surfaces = s.describe();
|
|
211
221
|
}
|
|
212
222
|
if (flags.triggers) {
|
|
213
|
-
const t = mountTriggers(ctx, api);
|
|
223
|
+
const t = mountTriggers(ctx, api, opts.scheduler);
|
|
214
224
|
routes.push(...t.routes);
|
|
215
225
|
mounted.push(t);
|
|
216
226
|
describe.triggers = t.describe?.();
|
|
217
227
|
}
|
|
218
228
|
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
219
|
-
const it = mountInstanceTracking(ctx, api);
|
|
229
|
+
const it = mountInstanceTracking(ctx, api, opts.scheduler);
|
|
220
230
|
mounted.push(it);
|
|
221
231
|
describe.instanceTracking = it.describe?.();
|
|
222
232
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -87,7 +87,7 @@ export { cancelInstanceReconciling } from "./core/modules/cancel.ts";
|
|
|
87
87
|
export type { CancelInstanceResult, CancelInstanceState } from "./core/modules/cancel.ts";
|
|
88
88
|
export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
|
|
89
89
|
export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
|
|
90
|
-
export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.ts";
|
|
90
|
+
export { defineOperation, mountApi, resolveOperationHandler, NotImplemented } from "./core/modules/api.ts";
|
|
91
91
|
export type {
|
|
92
92
|
ApiBinding,
|
|
93
93
|
ApiHandle,
|
|
@@ -119,6 +119,8 @@ export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
|
119
119
|
export { deployModels } from "./core/modules/deploy.ts";
|
|
120
120
|
export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
|
|
121
121
|
export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
|
|
122
|
+
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
|
|
123
|
+
export type { SchedulerDeps } from "./core/modules/scheduler.ts";
|
|
122
124
|
|
|
123
125
|
// Adapters + engine + run entrypoint
|
|
124
126
|
export { createNodeHost } from "./adapters/node.ts";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "./api-dir.ts";
|
|
5
|
+
|
|
6
|
+
test("normalizeApiDir: empty/whitespace falls back to the default", () => {
|
|
7
|
+
assert.equal(normalizeApiDir(""), DEFAULT_OPERATIONS_DIR);
|
|
8
|
+
assert.equal(normalizeApiDir(" "), DEFAULT_OPERATIONS_DIR);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("normalizeApiDir: strips leading ./ and surrounding slashes, folds backslashes", () => {
|
|
12
|
+
assert.equal(normalizeApiDir("ops"), "ops");
|
|
13
|
+
assert.equal(normalizeApiDir("./ops/"), "ops");
|
|
14
|
+
assert.equal(normalizeApiDir("api\\ops"), "api/ops");
|
|
15
|
+
assert.equal(normalizeApiDir("nested/ops/"), "nested/ops");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("normalizeApiDir: rejects absolute POSIX paths", () => {
|
|
19
|
+
assert.throws(() => normalizeApiDir("/ops"), /app-relative/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("normalizeApiDir: rejects absolute Windows paths", () => {
|
|
23
|
+
assert.throws(() => normalizeApiDir("C:\\ops"), /app-relative/);
|
|
24
|
+
assert.throws(() => normalizeApiDir("C:/ops"), /app-relative/);
|
|
25
|
+
assert.throws(() => normalizeApiDir("\\ops"), /app-relative/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("normalizeApiDir: rejects .. traversal segments", () => {
|
|
29
|
+
assert.throws(() => normalizeApiDir("../ops"), /\.\./);
|
|
30
|
+
assert.throws(() => normalizeApiDir("ops/../escape"), /\.\./);
|
|
31
|
+
assert.throws(() => normalizeApiDir("a/../../b"), /\.\./);
|
|
32
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Shared normalization + validation for the OpenAPI surface's `api.dir` (the directory operation
|
|
2
|
+
// delegates live in). Used by BOTH the deriver (which bakes `<dir>/<operationId>` into the generated
|
|
3
|
+
// controller's static import paths) and the scaffolder (which writes stubs to `<dir>/<operationId>.ts`)
|
|
4
|
+
// so the two can never disagree on where a delegate lives — a single rule, one place.
|
|
5
|
+
//
|
|
6
|
+
// The rule is intentionally strict: `api.dir` must be a safe app-relative directory. An absolute path
|
|
7
|
+
// or a `..` segment is REJECTED with a clear error rather than silently rewritten, because a silent
|
|
8
|
+
// rewrite would let `urban gen` scaffold into one directory while the runtime resolves delegates
|
|
9
|
+
// from another (drift), and a `..` segment could escape the app root when later joined.
|
|
10
|
+
|
|
11
|
+
/** The default directory operation delegates live in (mirrors the runtime's `api.dir` default). */
|
|
12
|
+
export const DEFAULT_OPERATIONS_DIR = "operations";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize an `api.dir` to a clean, app-relative directory segment (no leading/trailing slashes,
|
|
16
|
+
* backslashes folded to `/`). An empty value falls back to {@link DEFAULT_OPERATIONS_DIR}.
|
|
17
|
+
*
|
|
18
|
+
* Throws if `dir` is absolute (POSIX `/…` or Windows `C:\…`/`\…`) or contains a `..` traversal
|
|
19
|
+
* segment — both would let the generated controller's import paths or the scaffolder's write target
|
|
20
|
+
* diverge from (or escape) the app root.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeApiDir(dir: string): string {
|
|
23
|
+
const raw = dir.trim();
|
|
24
|
+
if (raw.length === 0) return DEFAULT_OPERATIONS_DIR;
|
|
25
|
+
|
|
26
|
+
if (isAbsolutePath(raw)) {
|
|
27
|
+
throw new Error(`api.dir must be an app-relative directory, not an absolute path: ${dir}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cleaned = raw.replace(/\\/g, "/").replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
31
|
+
if (cleaned.split("/").some((seg) => seg === "..")) {
|
|
32
|
+
throw new Error(`api.dir must not contain ".." path segments: ${dir}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return cleaned.length > 0 ? cleaned : DEFAULT_OPERATIONS_DIR;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True for a POSIX absolute path (`/…`) or a Windows absolute/drive path (`C:\…`, `C:/…`, `\…`). */
|
|
39
|
+
function isAbsolutePath(path: string): boolean {
|
|
40
|
+
return /^([/\\]|[A-Za-z]:[/\\])/.test(path);
|
|
41
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS } from "./api.ts";
|
|
3
|
+
import { deriveApi, emitApiBindings, emitApiBindingsRuntime, emitApiController, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, API_CONTROLLER_TS } from "./api.ts";
|
|
4
4
|
import type { OpenApiDoc } from "../../openapi/spec.ts";
|
|
5
5
|
|
|
6
6
|
const doc: OpenApiDoc = {
|
|
@@ -55,10 +55,48 @@ test("emitApiBindingsRuntime re-types the runtime defineOperation", () => {
|
|
|
55
55
|
assert.match(out, /return defineOperationRaw\(id, handler\);/);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
test("deriveApi emits
|
|
58
|
+
test("deriveApi emits the type map, typed defineOperation, and the controller under nano-generated/", () => {
|
|
59
59
|
const arts = deriveApi(doc);
|
|
60
60
|
const paths = arts.map((a) => a.path).sort();
|
|
61
|
-
assert.deepEqual(paths, [
|
|
61
|
+
assert.deepEqual(paths, [
|
|
62
|
+
`nano-generated/${API_BINDINGS_DTS}`,
|
|
63
|
+
`nano-generated/${API_BINDINGS_TS}`,
|
|
64
|
+
`nano-generated/${API_CONTROLLER_TS}`,
|
|
65
|
+
].sort());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("emitApiController statically imports each delegate + asserts the set with satisfies", () => {
|
|
69
|
+
const out = emitApiController(doc);
|
|
70
|
+
// one default import per operation, from the default operations/ dir, with an explicit .ts ext
|
|
71
|
+
assert.match(out, /import op0 from "\.\.\/operations\/getInvoice\.ts";/);
|
|
72
|
+
// the registry maps the real operationId to the imported delegate
|
|
73
|
+
assert.match(out, /"getInvoice": op0,/);
|
|
74
|
+
// the load-bearing completeness assertion
|
|
75
|
+
assert.match(out, /satisfies \{\s*\[K in ApiOperationId\]: OperationHandler<ReqFor<K>, ResFor<K>>;\s*\}/);
|
|
76
|
+
assert.match(out, /import type \{ OperationHandler \} from "@nanobpm\/urban";/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("emitApiController honors a custom api.dir for the delegate import path", () => {
|
|
80
|
+
const out = emitApiController(doc, "handlers");
|
|
81
|
+
assert.match(out, /import op0 from "\.\.\/handlers\/getInvoice\.ts";/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("emitApiController on an empty spec is a valid empty registry (never = {})", () => {
|
|
85
|
+
const empty: OpenApiDoc = { openapi: "3.0.0", paths: {} };
|
|
86
|
+
const out = emitApiController(empty);
|
|
87
|
+
assert.doesNotMatch(out, /^import op\d/m); // no delegate imports
|
|
88
|
+
assert.match(out, /export const operations = \{\s*\} satisfies \{/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("emitApiController throws on a duplicate operationId (would silently overwrite a map key)", () => {
|
|
92
|
+
const dupe: OpenApiDoc = {
|
|
93
|
+
openapi: "3.0.0",
|
|
94
|
+
paths: {
|
|
95
|
+
"/a": { get: { operationId: "same", responses: { "200": {} } } },
|
|
96
|
+
"/b": { get: { operationId: "same", responses: { "200": {} } } },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
assert.throws(() => emitApiController(dupe), /duplicate operationId\(s\): same/);
|
|
62
100
|
});
|
|
63
101
|
|
|
64
102
|
test("emitted object types mirror runtime additionalProperties semantics", () => {
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import type { DerivedArtifact, Deriver } from "../artifact.ts";
|
|
10
10
|
import { GENERATED_DIR } from "../artifact.ts";
|
|
11
|
+
import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.ts";
|
|
11
12
|
import {
|
|
12
13
|
collectOperations,
|
|
14
|
+
assertUniqueOperationIds,
|
|
13
15
|
isObjectSchema,
|
|
14
16
|
type OpenApiDoc,
|
|
15
17
|
type OpenApiSchema,
|
|
@@ -20,6 +22,13 @@ import {
|
|
|
20
22
|
/** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
|
|
21
23
|
export const API_BINDINGS_DTS = "api-io.d.ts";
|
|
22
24
|
export const API_BINDINGS_TS = "operations.ts";
|
|
25
|
+
/** The generated delegate registry: statically imports every operation delegate and asserts the
|
|
26
|
+
* complete, correctly-typed set via `satisfies` — so a missing/mismatched/orphan delegate fails
|
|
27
|
+
* `tsc` (ADR 0059). The runtime dispatches through this map instead of per-op path imports. */
|
|
28
|
+
export const API_CONTROLLER_TS = "controller.ts";
|
|
29
|
+
|
|
30
|
+
export { DEFAULT_OPERATIONS_DIR };
|
|
31
|
+
|
|
23
32
|
|
|
24
33
|
/** A TS identifier form of an operationId (used for the per-op type names). */
|
|
25
34
|
function typeStem(operationId: string): string {
|
|
@@ -240,16 +249,77 @@ export function emitApiBindingsRuntime(): string {
|
|
|
240
249
|
);
|
|
241
250
|
}
|
|
242
251
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
252
|
+
/** Normalize an `api.dir` to a clean relative segment (see `normalizeApiDir`); rejects absolute
|
|
253
|
+
* paths and `..` segments so the generated import paths stay app-relative and can't drift. */
|
|
254
|
+
function normalizeOperationsDir(dir: string): string {
|
|
255
|
+
return normalizeApiDir(dir);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Emit `controller.ts`: the generated delegate registry (ADR 0059). It statically imports every
|
|
260
|
+
* operation delegate (`<dir>/<operationId>.ts`) and asserts the whole set against the spec-derived
|
|
261
|
+
* contract with a single `satisfies { [K in ApiOperationId]: OperationHandler<ReqFor<K>, ResFor<K>> }`.
|
|
262
|
+
* That one assertion gives four compile-time drift guarantees, all surfaced by `tsc`:
|
|
263
|
+
* • a missing delegate file → the static `import` fails ("Cannot find module");
|
|
264
|
+
* • an op in the spec, missing here → the mapped type requires key `K` ("Property is missing");
|
|
265
|
+
* • a delegate with the wrong signature → not assignable to `OperationHandler<Req, Res>`;
|
|
266
|
+
* • an orphan delegate (no spec op) → an excess property vs the mapped type.
|
|
267
|
+
* The runtime imports this ONE barrel and dispatches via `operations[operationId]`, so existing
|
|
268
|
+
* endpoints cannot drift from the contract. Delegates are keyed by their real operationId; the
|
|
269
|
+
* import binding is index-based (`op0`, `op1`, …) because an operationId is a safe path segment but
|
|
270
|
+
* not necessarily a valid TS identifier. Requires each delegate to be a default export.
|
|
271
|
+
*/
|
|
272
|
+
export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAULT_OPERATIONS_DIR): string {
|
|
273
|
+
assertUniqueOperationIds(doc);
|
|
274
|
+
const ops = collectOperations(doc);
|
|
275
|
+
const dir = normalizeOperationsDir(operationsDir);
|
|
276
|
+
|
|
277
|
+
const header =
|
|
278
|
+
"// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0059): the delegate\n" +
|
|
279
|
+
"// registry. Statically imports every operation delegate and asserts the complete, correctly-\n" +
|
|
280
|
+
"// typed set via `satisfies` — a missing, mismatched, or orphan delegate fails `tsc`. The\n" +
|
|
281
|
+
"// runtime dispatches through this map, so endpoints cannot drift from the spec. Do not edit —\n" +
|
|
282
|
+
"// regenerated from the spec.\n" +
|
|
283
|
+
"// eslint-disable\n\n";
|
|
284
|
+
|
|
285
|
+
const typeImports =
|
|
286
|
+
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
287
|
+
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n`;
|
|
288
|
+
|
|
289
|
+
const delegateImports = ops
|
|
290
|
+
.map((op, i) => `import op${i} from ${JSON.stringify(`../${dir}/${op.operationId}.ts`)};`)
|
|
291
|
+
.join("\n");
|
|
292
|
+
|
|
293
|
+
const mapEntries = ops.map((op, i) => ` ${JSON.stringify(op.operationId)}: op${i},`).join("\n");
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
header +
|
|
297
|
+
typeImports +
|
|
298
|
+
(delegateImports ? `${delegateImports}\n` : "") +
|
|
299
|
+
`\n` +
|
|
300
|
+
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
301
|
+
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
302
|
+
`/** Every operationId → its default-exported delegate. The \`satisfies\` clause makes \`tsc\`\n` +
|
|
303
|
+
` * reject a missing, wrongly-typed, or orphan delegate (ADR 0059). */\n` +
|
|
304
|
+
`export const operations = {\n${mapEntries}${mapEntries ? "\n" : ""}} satisfies {\n` +
|
|
305
|
+
` [K in ApiOperationId]: OperationHandler<ReqFor<K>, ResFor<K>>;\n` +
|
|
306
|
+
`};\n`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
|
|
311
|
+
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it. */
|
|
312
|
+
export function deriveApi(doc: OpenApiDoc, operationsDir: string = DEFAULT_OPERATIONS_DIR): DerivedArtifact[] {
|
|
245
313
|
return [
|
|
246
314
|
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
|
|
247
315
|
{ path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
|
|
316
|
+
{ path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
|
|
248
317
|
];
|
|
249
318
|
}
|
|
250
319
|
|
|
251
|
-
export const apiDeriver: Deriver<{ doc: OpenApiDoc }> = {
|
|
320
|
+
export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string }> = {
|
|
252
321
|
id: "openapi->api-io",
|
|
253
|
-
describe:
|
|
254
|
-
|
|
322
|
+
describe:
|
|
323
|
+
"Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
|
|
324
|
+
derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
|
|
255
325
|
};
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -122,7 +122,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
122
122
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
123
123
|
const manifest: ToolkitManifest & {
|
|
124
124
|
models?: { processes?: string[]; workflows?: string[] };
|
|
125
|
-
api?: { spec?: string };
|
|
125
|
+
api?: { spec?: string; dir?: string };
|
|
126
126
|
} = JSON.parse(await io.readText(manifestPath));
|
|
127
127
|
|
|
128
128
|
const artifacts: DerivedArtifact[] = [];
|
|
@@ -159,7 +159,10 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
159
159
|
const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
|
|
160
160
|
if (!opts.modelsOnly && specRef.length > 0) {
|
|
161
161
|
const specText = await io.readText(join(root, specRef));
|
|
162
|
-
|
|
162
|
+
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
163
|
+
? manifest.api.dir.trim()
|
|
164
|
+
: undefined;
|
|
165
|
+
artifacts.push(...deriveApi(parseSpec(specText), opsDir));
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
return { artifacts: sortArtifacts(artifacts), derived };
|