@nanobpm/urban 0.35.0 → 0.37.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 +9 -0
- package/dist/openapi/spec.js +22 -0
- package/dist/runtime/core/modules/api.d.ts +19 -4
- package/dist/runtime/core/modules/api.js +81 -3
- 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.d.ts +7 -5
- package/dist/toolkit/scaffold/workers.js +12 -5
- package/dist/toolkit/scaffold.d.ts +16 -0
- package/dist/toolkit/scaffold.js +41 -1
- package/package.json +74 -74
- package/src/cli.test.ts +0 -1
- package/src/cli.ts +30 -45
- package/src/openapi/spec.ts +24 -0
- package/src/runtime/core/modules/api.test.ts +104 -2
- package/src/runtime/core/modules/api.ts +95 -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.test.ts +35 -0
- package/src/toolkit/scaffold/workers.ts +13 -5
- package/src/toolkit/scaffold.ts +63 -1
|
@@ -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 };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generate } from "./generate.ts";
|
|
4
|
+
import type { GenIO } from "./gen.ts";
|
|
5
|
+
|
|
6
|
+
/** In-memory filesystem mirroring gen.test.ts's memIO (IO-free, deterministic). */
|
|
7
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
8
|
+
return {
|
|
9
|
+
files,
|
|
10
|
+
async readText(p) {
|
|
11
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
12
|
+
return files[p];
|
|
13
|
+
},
|
|
14
|
+
async writeText(p, c) {
|
|
15
|
+
files[p] = c;
|
|
16
|
+
},
|
|
17
|
+
async listDir(p) {
|
|
18
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
19
|
+
const names = new Set<string>();
|
|
20
|
+
for (const f of Object.keys(files)) {
|
|
21
|
+
if (f.startsWith(prefix)) {
|
|
22
|
+
const rest = f.slice(prefix.length);
|
|
23
|
+
if (!rest.includes("/")) names.add(rest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...names];
|
|
27
|
+
},
|
|
28
|
+
async exists(p) {
|
|
29
|
+
return p in files;
|
|
30
|
+
},
|
|
31
|
+
async remove(p) {
|
|
32
|
+
delete files[p];
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MANIFEST = JSON.stringify({
|
|
38
|
+
id: "demo",
|
|
39
|
+
data: { default: "app" },
|
|
40
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
41
|
+
types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
|
|
42
|
+
api: { spec: "openapi.json" },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const BPMN = `<bpmn:process id="p" xmlns:bpmn="x" xmlns:zeebe="y">
|
|
46
|
+
<bpmn:serviceTask id="T"><bpmn:extensionElements>
|
|
47
|
+
<zeebe:taskDefinition type="demo.do" />
|
|
48
|
+
</bpmn:extensionElements></bpmn:serviceTask>
|
|
49
|
+
</bpmn:process>`;
|
|
50
|
+
|
|
51
|
+
const OPENAPI = JSON.stringify({
|
|
52
|
+
openapi: "3.0.0",
|
|
53
|
+
paths: {
|
|
54
|
+
"/ping": { get: { operationId: "ping", responses: { "200": {} } } },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function fixture(): Record<string, string> {
|
|
59
|
+
return {
|
|
60
|
+
"/app/nano.app.json": MANIFEST,
|
|
61
|
+
"/app/processes/p.bpmn": BPMN,
|
|
62
|
+
"/app/openapi.json": OPENAPI,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("generate write mode scaffolds worker + operation stubs alongside derived artifacts", async () => {
|
|
67
|
+
const io = memIO(fixture());
|
|
68
|
+
const res = await generate({ root: "/app", io });
|
|
69
|
+
|
|
70
|
+
// Derived artifacts landed (the runGen half).
|
|
71
|
+
assert.ok(io.files["/app/nano-generated/worker-io.d.ts"], "expected derived worker-io");
|
|
72
|
+
assert.ok(io.files["/app/nano-generated/app.schema.sql"], "expected derived migrations");
|
|
73
|
+
|
|
74
|
+
// Worker stub created + wired into the manifest.
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
res.workerStubs.map((s) => s.status),
|
|
77
|
+
["created"],
|
|
78
|
+
);
|
|
79
|
+
assert.ok(io.files["/app/workers/demo-do/worker.ts"], "expected a worker stub on disk");
|
|
80
|
+
assert.ok(res.manifestPatched, "expected workers[] to be patched");
|
|
81
|
+
assert.equal(res.wiredWorkers.length, 1);
|
|
82
|
+
|
|
83
|
+
// Operation stub created.
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
res.operationStubs.map((s) => s.status),
|
|
86
|
+
["created"],
|
|
87
|
+
);
|
|
88
|
+
assert.ok(io.files["/app/operations/ping.ts"], "expected an operation stub on disk");
|
|
89
|
+
|
|
90
|
+
// Write mode never reports missing stubs.
|
|
91
|
+
assert.deepEqual(res.missingStubs, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("generate --check writes nothing and reports would-create stubs as missing", async () => {
|
|
95
|
+
const io = memIO(fixture());
|
|
96
|
+
// Pre-derive so the only drift under --check comes from the missing stubs.
|
|
97
|
+
await generate({ root: "/app", io });
|
|
98
|
+
const before = { ...io.files };
|
|
99
|
+
|
|
100
|
+
const res = await generate({ root: "/app", io, check: true });
|
|
101
|
+
|
|
102
|
+
// Nothing changed on disk (both derivation and scaffolding are write-free under --check).
|
|
103
|
+
assert.deepEqual(io.files, before, "--check must not write");
|
|
104
|
+
|
|
105
|
+
// The already-created stubs are kept, so nothing is missing this run.
|
|
106
|
+
assert.deepEqual(res.missingStubs, []);
|
|
107
|
+
assert.deepEqual(res.drift, []);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("generate --check surfaces an uncommitted stub via missingStubs (the CI drift catch)", async () => {
|
|
111
|
+
const io = memIO(fixture());
|
|
112
|
+
const before = { ...io.files };
|
|
113
|
+
|
|
114
|
+
const res = await generate({ root: "/app", io, check: true });
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(io.files, before, "--check must not write");
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
res.missingStubs.sort(),
|
|
119
|
+
["operations/ping.ts", "workers/demo-do/worker.ts"],
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("generate keeps existing human-owned stubs (write-once, never clobbered)", async () => {
|
|
124
|
+
const io = memIO(fixture());
|
|
125
|
+
io.files["/app/workers/demo-do/worker.ts"] = "// hand-authored body";
|
|
126
|
+
io.files["/app/operations/ping.ts"] = "// hand-authored delegate";
|
|
127
|
+
|
|
128
|
+
const res = await generate({ root: "/app", io });
|
|
129
|
+
|
|
130
|
+
assert.equal(io.files["/app/workers/demo-do/worker.ts"], "// hand-authored body");
|
|
131
|
+
assert.equal(io.files["/app/operations/ping.ts"], "// hand-authored delegate");
|
|
132
|
+
assert.deepEqual(res.workerStubs.map((s) => s.status), ["kept"]);
|
|
133
|
+
assert.deepEqual(res.operationStubs.map((s) => s.status), ["kept"]);
|
|
134
|
+
assert.deepEqual(res.missingStubs, []);
|
|
135
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `urban gen`: the whole generation concern in one pass — derive artifacts (migrations, worker-I/O,
|
|
2
|
+
// code-first models, OpenAPI contracts + controller) AND scaffold the write-once handler stubs those
|
|
3
|
+
// artifacts reference (worker stubs from the model, operation-delegate stubs from the OpenAPI spec).
|
|
4
|
+
//
|
|
5
|
+
// This composes the pure derivation (`runGen`, gen.ts) with the write-once scaffolders (scaffold.ts)
|
|
6
|
+
// WITHOUT a cycle: scaffold.ts already depends on gen.ts, so this leaf module depends on both and
|
|
7
|
+
// neither depends back. Scaffolding is write-once (an existing, human-owned stub is never clobbered),
|
|
8
|
+
// so folding it into `gen` is idempotent. `--check` writes nothing: a missing stub surfaces in
|
|
9
|
+
// `missingStubs` exactly like a drifted artifact surfaces in `drift`, so CI fails when a stub the
|
|
10
|
+
// generated controller imports isn't committed.
|
|
11
|
+
|
|
12
|
+
import type { GenOptions, GenResult } from "./gen.ts";
|
|
13
|
+
import { runGen } from "./gen.ts";
|
|
14
|
+
import type { OperationStubOutcome, StubOutcome } from "./scaffold.ts";
|
|
15
|
+
import { scaffoldOperations, scaffoldWorkers } from "./scaffold.ts";
|
|
16
|
+
import type { StubManifestEntry } from "./scaffold/workers.ts";
|
|
17
|
+
|
|
18
|
+
export interface GenerateResult extends GenResult {
|
|
19
|
+
/** Worker-stub scaffolding outcomes (created / kept / would-create). */
|
|
20
|
+
workerStubs: StubOutcome[];
|
|
21
|
+
/** Operation-delegate scaffolding outcomes (created / kept / would-create). */
|
|
22
|
+
operationStubs: OperationStubOutcome[];
|
|
23
|
+
/** Worker manifest entries wired into `workers[]` (write mode only). */
|
|
24
|
+
wiredWorkers: StubManifestEntry[];
|
|
25
|
+
/** True when `workers[]` was patched (write mode, unwired workers present). */
|
|
26
|
+
manifestPatched: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* App-relative paths of stubs that would be created — the scaffolding equivalent of `drift`.
|
|
29
|
+
* Populated in `check` mode (nothing is written); a caller should fail CI when non-empty.
|
|
30
|
+
*/
|
|
31
|
+
missingStubs: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derive artifacts and scaffold the write-once handler stubs they reference, in one pass.
|
|
36
|
+
* In `check` mode nothing is written and any would-be-created stub is reported in `missingStubs`.
|
|
37
|
+
* Scaffolding is skipped when derivation is `incomplete` (never scaffold from a half-derived model);
|
|
38
|
+
* the caller reports the derivation failure first.
|
|
39
|
+
*/
|
|
40
|
+
export async function generate(opts: GenOptions & { check?: boolean }): Promise<GenerateResult> {
|
|
41
|
+
const res = await runGen(opts);
|
|
42
|
+
|
|
43
|
+
const empty: GenerateResult = {
|
|
44
|
+
...res,
|
|
45
|
+
workerStubs: [],
|
|
46
|
+
operationStubs: [],
|
|
47
|
+
wiredWorkers: [],
|
|
48
|
+
manifestPatched: false,
|
|
49
|
+
missingStubs: [],
|
|
50
|
+
};
|
|
51
|
+
if (res.incomplete) return empty;
|
|
52
|
+
|
|
53
|
+
const write = !opts.check;
|
|
54
|
+
const scaffoldOpts = { root: opts.root, io: opts.io, manifestFile: opts.manifestFile, write };
|
|
55
|
+
const workers = await scaffoldWorkers(scaffoldOpts);
|
|
56
|
+
const operations = await scaffoldOperations(scaffoldOpts);
|
|
57
|
+
|
|
58
|
+
const missingStubs = [
|
|
59
|
+
...workers.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
|
|
60
|
+
...operations.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...res,
|
|
65
|
+
workerStubs: workers.outcomes,
|
|
66
|
+
operationStubs: operations.outcomes,
|
|
67
|
+
wiredWorkers: workers.wired,
|
|
68
|
+
manifestPatched: workers.manifestPatched,
|
|
69
|
+
missingStubs,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/toolkit/index.ts
CHANGED
|
@@ -103,9 +103,11 @@ export {
|
|
|
103
103
|
deriveApi,
|
|
104
104
|
emitApiBindings,
|
|
105
105
|
emitApiBindingsRuntime,
|
|
106
|
+
emitApiController,
|
|
106
107
|
schemaToTs,
|
|
107
108
|
API_BINDINGS_DTS,
|
|
108
109
|
API_BINDINGS_TS,
|
|
110
|
+
API_CONTROLLER_TS,
|
|
109
111
|
} from "./derivers/api.ts";
|
|
110
112
|
|
|
111
113
|
// The OpenAPI machinery the deriver + runtime share (ADR 0058).
|
|
@@ -149,6 +151,8 @@ export const DERIVERS = [
|
|
|
149
151
|
// Gen orchestrator + IO
|
|
150
152
|
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
|
|
151
153
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
154
|
+
export { generate } from "./generate.ts";
|
|
155
|
+
export type { GenerateResult } from "./generate.ts";
|
|
152
156
|
export { createNodeGenIO } from "./fsio.ts";
|
|
153
157
|
|
|
154
158
|
// Code-first model derivation (workflows/*.ts → BPMN)
|
|
@@ -180,5 +184,11 @@ export type {
|
|
|
180
184
|
export { scaffoldWorkers } from "./scaffold.ts";
|
|
181
185
|
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
|
182
186
|
|
|
187
|
+
// Operation-delegate scaffolder (ADR 0059): write-once typed delegate stubs from the OpenAPI spec.
|
|
188
|
+
export { planOperationScaffold, renderOperationStub } from "./scaffold/operations.ts";
|
|
189
|
+
export type { OperationStubPlan } from "./scaffold/operations.ts";
|
|
190
|
+
export { scaffoldOperations } from "./scaffold.ts";
|
|
191
|
+
export type { OperationScaffoldRun, OperationStubOutcome } from "./scaffold.ts";
|
|
192
|
+
|
|
183
193
|
export { addConnector } from "./addConnector.ts";
|
|
184
194
|
export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { planOperationScaffold, renderOperationStub } from "./operations.ts";
|
|
4
|
+
import { scaffoldOperations } from "../scaffold.ts";
|
|
5
|
+
import type { GenIO } from "../gen.ts";
|
|
6
|
+
import type { OpenApiDoc } from "../../openapi/spec.ts";
|
|
7
|
+
|
|
8
|
+
const doc: OpenApiDoc = {
|
|
9
|
+
openapi: "3.0.0",
|
|
10
|
+
paths: {
|
|
11
|
+
"/invoices/{id}": {
|
|
12
|
+
get: {
|
|
13
|
+
operationId: "getInvoice",
|
|
14
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
15
|
+
responses: { "200": {} },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"/invoices": {
|
|
19
|
+
post: { operationId: "createInvoice", responses: { "201": {} } },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test("renderOperationStub is a typed default-exported delegate that throws NotImplemented", () => {
|
|
25
|
+
const stub = renderOperationStub("getInvoice");
|
|
26
|
+
assert.match(stub, /import \{ NotImplemented \} from "@nanobpm\/urban";/);
|
|
27
|
+
assert.match(stub, /import \{ defineOperation \} from "\.\.\/nano-generated\/operations\.ts";/);
|
|
28
|
+
assert.match(stub, /export default defineOperation\("getInvoice", async \(input, app\) =>/);
|
|
29
|
+
assert.match(stub, /throw new NotImplemented\("getInvoice"\);/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("renderOperationStub adjusts the generated-import depth for a nested api.dir", () => {
|
|
33
|
+
const stub = renderOperationStub("getInvoice", "api/handlers");
|
|
34
|
+
assert.match(stub, /import \{ defineOperation \} from "\.\.\/\.\.\/nano-generated\/operations\.ts";/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("planOperationScaffold plans one stub per declared operationId", () => {
|
|
38
|
+
const plans = planOperationScaffold(doc);
|
|
39
|
+
const byId = Object.fromEntries(plans.map((p) => [p.operationId, p.handlerPath]));
|
|
40
|
+
assert.deepEqual(byId, {
|
|
41
|
+
getInvoice: "operations/getInvoice.ts",
|
|
42
|
+
createInvoice: "operations/createInvoice.ts",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("planOperationScaffold honors a custom api.dir", () => {
|
|
47
|
+
const plans = planOperationScaffold(doc, "handlers");
|
|
48
|
+
assert.ok(plans.every((p) => p.handlerPath.startsWith("handlers/")));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("planOperationScaffold throws on a duplicate operationId (incoherent spec)", () => {
|
|
52
|
+
const dupe: OpenApiDoc = {
|
|
53
|
+
openapi: "3.0.0",
|
|
54
|
+
paths: {
|
|
55
|
+
"/a": { get: { operationId: "same", responses: { "200": {} } } },
|
|
56
|
+
"/b": { get: { operationId: "same", responses: { "200": {} } } },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
assert.throws(() => planOperationScaffold(dupe), /duplicate operationId\(s\): same/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
63
|
+
return {
|
|
64
|
+
files,
|
|
65
|
+
async readText(p) {
|
|
66
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
67
|
+
return files[p];
|
|
68
|
+
},
|
|
69
|
+
async writeText(p, c) {
|
|
70
|
+
files[p] = c;
|
|
71
|
+
},
|
|
72
|
+
async listDir(p) {
|
|
73
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
74
|
+
const names = new Set<string>();
|
|
75
|
+
for (const f of Object.keys(files)) {
|
|
76
|
+
if (f.startsWith(prefix)) {
|
|
77
|
+
const rest = f.slice(prefix.length);
|
|
78
|
+
if (!rest.includes("/")) names.add(rest);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...names];
|
|
82
|
+
},
|
|
83
|
+
async exists(p) {
|
|
84
|
+
return p in files;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function appFixture(): Record<string, string> {
|
|
90
|
+
return {
|
|
91
|
+
"/app/nano.app.json": JSON.stringify({ id: "demo", api: { spec: "openapi.json" } }),
|
|
92
|
+
"/app/openapi.json": JSON.stringify(doc),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("scaffoldOperations is a no-op when the app declares no api binding", async () => {
|
|
97
|
+
const io = memIO({ "/app/nano.app.json": JSON.stringify({ id: "demo" }) });
|
|
98
|
+
const run = await scaffoldOperations({ root: "/app", io, write: true });
|
|
99
|
+
assert.deepEqual(run.outcomes, []);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("scaffoldOperations dry-run writes nothing and reports would-create per op", async () => {
|
|
103
|
+
const io = memIO(appFixture());
|
|
104
|
+
const before = Object.keys(io.files).length;
|
|
105
|
+
const run = await scaffoldOperations({ root: "/app", io });
|
|
106
|
+
assert.equal(run.write, false);
|
|
107
|
+
assert.equal(Object.keys(io.files).length, before, "dry-run must not write");
|
|
108
|
+
assert.deepEqual(run.outcomes.map((o) => o.status).sort(), ["would-create", "would-create"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("scaffoldOperations --write creates one typed stub per op", async () => {
|
|
112
|
+
const io = memIO(appFixture());
|
|
113
|
+
const run = await scaffoldOperations({ root: "/app", io, write: true });
|
|
114
|
+
assert.ok(run.outcomes.every((o) => o.status === "created"));
|
|
115
|
+
assert.match(io.files["/app/operations/getInvoice.ts"], /NotImplemented\("getInvoice"\)/);
|
|
116
|
+
assert.match(io.files["/app/operations/createInvoice.ts"], /NotImplemented\("createInvoice"\)/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("scaffoldOperations never clobbers an existing delegate (keeps it)", async () => {
|
|
120
|
+
const files = appFixture();
|
|
121
|
+
files["/app/operations/getInvoice.ts"] = "// hand-written, do not touch\n";
|
|
122
|
+
const io = memIO(files);
|
|
123
|
+
const run = await scaffoldOperations({ root: "/app", io, write: true });
|
|
124
|
+
assert.equal(io.files["/app/operations/getInvoice.ts"], "// hand-written, do not touch\n");
|
|
125
|
+
assert.equal(run.outcomes.find((o) => o.operationId === "getInvoice")?.status, "kept");
|
|
126
|
+
assert.equal(run.outcomes.find((o) => o.operationId === "createInvoice")?.status, "created");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("scaffoldOperations is idempotent: a second --write run keeps everything", async () => {
|
|
130
|
+
const io = memIO(appFixture());
|
|
131
|
+
await scaffoldOperations({ root: "/app", io, write: true });
|
|
132
|
+
const snapshot = JSON.stringify(io.files);
|
|
133
|
+
const run2 = await scaffoldOperations({ root: "/app", io, write: true });
|
|
134
|
+
assert.ok(run2.outcomes.every((o) => o.status === "kept"));
|
|
135
|
+
assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
|
|
136
|
+
});
|