@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
|
|
2
|
+
// `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
|
|
3
|
+
// half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
|
|
4
|
+
// requestBody, and first success response, plus an `ApiOperationId` union — mirroring how
|
|
5
|
+
// `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
|
|
6
|
+
// (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
|
|
7
|
+
// the shared `openapi/spec.ts`, so types and runtime never drift.
|
|
8
|
+
|
|
9
|
+
import type { DerivedArtifact, Deriver } from "../artifact.ts";
|
|
10
|
+
import { GENERATED_DIR } from "../artifact.ts";
|
|
11
|
+
import {
|
|
12
|
+
collectOperations,
|
|
13
|
+
isObjectSchema,
|
|
14
|
+
type OpenApiDoc,
|
|
15
|
+
type OpenApiSchema,
|
|
16
|
+
type OperationInfo,
|
|
17
|
+
refName,
|
|
18
|
+
} from "../../openapi/spec.ts";
|
|
19
|
+
|
|
20
|
+
/** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
|
|
21
|
+
export const API_BINDINGS_DTS = "api-io.d.ts";
|
|
22
|
+
export const API_BINDINGS_TS = "operations.ts";
|
|
23
|
+
|
|
24
|
+
/** A TS identifier form of an operationId (used for the per-op type names). */
|
|
25
|
+
function typeStem(operationId: string): string {
|
|
26
|
+
const cleaned = operationId.replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
27
|
+
const pascal = cleaned
|
|
28
|
+
.split(/\s+/)
|
|
29
|
+
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : ""))
|
|
30
|
+
.join("");
|
|
31
|
+
return /^[A-Za-z_]/.test(pascal) ? pascal : `Op${pascal}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
|
|
35
|
+
* their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
|
|
36
|
+
export function schemaToTs(schema: OpenApiSchema | undefined, depth = 0): string {
|
|
37
|
+
if (!schema || depth > 20) return "unknown";
|
|
38
|
+
if (schema.$ref) {
|
|
39
|
+
const name = refName(schema.$ref);
|
|
40
|
+
return name ? typeStem(name) : "unknown";
|
|
41
|
+
}
|
|
42
|
+
const nullable = schema.nullable === true;
|
|
43
|
+
const wrap = (t: string) => (nullable ? `${t} | null` : t);
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
46
|
+
return wrap(schema.enum.map((v) => JSON.stringify(v)).join(" | "));
|
|
47
|
+
}
|
|
48
|
+
if (schema.const !== undefined) return wrap(JSON.stringify(schema.const));
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
|
51
|
+
return wrap(schema.oneOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
54
|
+
return wrap(schema.anyOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
57
|
+
return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
switch (schema.type) {
|
|
61
|
+
case "string":
|
|
62
|
+
return wrap("string");
|
|
63
|
+
case "integer":
|
|
64
|
+
case "number":
|
|
65
|
+
return wrap("number");
|
|
66
|
+
case "boolean":
|
|
67
|
+
return wrap("boolean");
|
|
68
|
+
case "null":
|
|
69
|
+
return "null";
|
|
70
|
+
case "array":
|
|
71
|
+
return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
|
|
72
|
+
case "object":
|
|
73
|
+
return wrap(objectToTs(schema, depth));
|
|
74
|
+
default:
|
|
75
|
+
// No `type`: infer object-ness from object-only keywords (shared with the runtime
|
|
76
|
+
// validator via isObjectSchema so types and validation agree); otherwise unknown.
|
|
77
|
+
if (isObjectSchema(schema)) return wrap(objectToTs(schema, depth));
|
|
78
|
+
return "unknown";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function objectToTs(schema: OpenApiSchema, depth: number): string {
|
|
83
|
+
const props = schema.properties ?? {};
|
|
84
|
+
const required = new Set(schema.required ?? []);
|
|
85
|
+
const entries = Object.entries(props).map(([key, ps]) => {
|
|
86
|
+
const opt = required.has(key) ? "" : "?";
|
|
87
|
+
const doc = typeof ps.description === "string" ? ps.description.replace(/\*\//g, "*\\/") : undefined;
|
|
88
|
+
const jsdoc = doc ? `/** ${doc} */ ` : "";
|
|
89
|
+
return `${jsdoc}${JSON.stringify(key)}${opt}: ${schemaToTs(ps, depth + 1)}`;
|
|
90
|
+
});
|
|
91
|
+
const extra = schema.additionalProperties;
|
|
92
|
+
if (extra && typeof extra === "object") {
|
|
93
|
+
// TS requires every named property type to be assignable to the index signature. When the
|
|
94
|
+
// schema also declares properties, a narrow index type (e.g. `number`) would make the emitted
|
|
95
|
+
// type invalid (TS2411), so fall back to `unknown`; with no declared properties we can use the
|
|
96
|
+
// precise additionalProperties type (the common "map" case).
|
|
97
|
+
const hasProps = Object.keys(props).length > 0;
|
|
98
|
+
entries.push(`[key: string]: ${hasProps ? "unknown" : schemaToTs(extra, depth + 1)}`);
|
|
99
|
+
} else if (extra !== false) {
|
|
100
|
+
// OpenAPI/JSON-Schema default: additional properties are allowed. Emit an index
|
|
101
|
+
// signature so the emitted type isn't stricter than the runtime validator, which only
|
|
102
|
+
// rejects extras when additionalProperties === false (otherwise excess-property errors
|
|
103
|
+
// on object literals would reject bodies the runtime accepts).
|
|
104
|
+
entries.push("[key: string]: unknown");
|
|
105
|
+
}
|
|
106
|
+
if (entries.length > 0) return `{ ${entries.join("; ")} }`;
|
|
107
|
+
// No declared properties and additionalProperties: false → a closed, empty object.
|
|
108
|
+
return "Record<string, never>";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The `params`/`query` object type for an operation, from its parameters (strings on the wire). */
|
|
112
|
+
function paramsTs(op: OperationInfo, where: "path" | "query"): string {
|
|
113
|
+
const ps = op.parameters.filter((p) => p.in === where);
|
|
114
|
+
if (ps.length === 0) return "{}";
|
|
115
|
+
const fields = ps.map((p) => {
|
|
116
|
+
const opt = p.required ? "" : "?";
|
|
117
|
+
// Query values arrive as strings (or string[] for repeated keys); path params are single
|
|
118
|
+
// strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
|
|
119
|
+
// value type, or required query params would still accept undefined.
|
|
120
|
+
const t = where === "query" ? "string | string[]" : "string";
|
|
121
|
+
return `${JSON.stringify(p.name)}${opt}: ${t}`;
|
|
122
|
+
});
|
|
123
|
+
return `{ ${fields.join("; ")} }`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
|
|
127
|
+
export function emitApiBindings(doc: OpenApiDoc): string {
|
|
128
|
+
const ops = collectOperations(doc);
|
|
129
|
+
const components = doc.components?.schemas ?? {};
|
|
130
|
+
|
|
131
|
+
// Fail-closed on TypeScript name collisions: typeStem is lossy (non-alphanumerics collapse), so
|
|
132
|
+
// distinct component/operation names ("get-invoice" vs "get_invoice") can map to the same TS
|
|
133
|
+
// identifier and emit duplicate declarations that break typechecking. Surface it here (where
|
|
134
|
+
// `urban gen`/`urban check` reports it) rather than shipping broken generated types.
|
|
135
|
+
const emittedTypeNames = new Map<string, string>();
|
|
136
|
+
const claimTypeName = (typeName: string, source: string): void => {
|
|
137
|
+
const prior = emittedTypeNames.get(typeName);
|
|
138
|
+
if (prior !== undefined) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`OpenAPI spec produces a duplicate TypeScript type "${typeName}" from ${prior} and ${source} ` +
|
|
141
|
+
`— their names collapse to the same identifier; rename one to disambiguate (ADR 0058).`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
emittedTypeNames.set(typeName, source);
|
|
145
|
+
};
|
|
146
|
+
for (const name of Object.keys(components)) {
|
|
147
|
+
claimTypeName(typeStem(name), `component schema "${name}"`);
|
|
148
|
+
}
|
|
149
|
+
for (const op of ops) {
|
|
150
|
+
claimTypeName(`${typeStem(op.operationId)}Request`, `operationId "${op.operationId}"`);
|
|
151
|
+
claimTypeName(`${typeStem(op.operationId)}Response`, `operationId "${op.operationId}"`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const header =
|
|
155
|
+
"// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
|
|
156
|
+
"// The endpoint type map: each operationId maps to its typed request (path/query params +\n" +
|
|
157
|
+
"// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
|
|
158
|
+
"// the spec; erased to plain JS at compile.\n" +
|
|
159
|
+
"// eslint-disable\n\n";
|
|
160
|
+
|
|
161
|
+
const componentDecls = Object.entries(components)
|
|
162
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
163
|
+
.map(([name, schema]) => `export type ${typeStem(name)} = ${schemaToTs(schema)};`)
|
|
164
|
+
.join("\n");
|
|
165
|
+
|
|
166
|
+
const opDecls = ops
|
|
167
|
+
.map((op) => {
|
|
168
|
+
const stem = typeStem(op.operationId);
|
|
169
|
+
// With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
|
|
170
|
+
// required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
|
|
171
|
+
const bodyType = op.requestBodySchema
|
|
172
|
+
? schemaToTs(op.requestBodySchema)
|
|
173
|
+
: op.requestBodyRequired
|
|
174
|
+
? "unknown"
|
|
175
|
+
: "undefined";
|
|
176
|
+
// An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
|
|
177
|
+
// so mark it optional in the type rather than forcing handlers to treat it as always-present.
|
|
178
|
+
const bodyOpt = op.requestBodyRequired ? "" : "?";
|
|
179
|
+
const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
|
|
180
|
+
const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
|
|
181
|
+
return (
|
|
182
|
+
`${summary}export interface ${stem}Request {\n` +
|
|
183
|
+
` params: ${paramsTs(op, "path")};\n` +
|
|
184
|
+
` query: ${paramsTs(op, "query")};\n` +
|
|
185
|
+
` body${bodyOpt}: ${bodyType};\n` +
|
|
186
|
+
`}\n` +
|
|
187
|
+
`export type ${stem}Response = ${resp};`
|
|
188
|
+
);
|
|
189
|
+
})
|
|
190
|
+
.join("\n\n");
|
|
191
|
+
|
|
192
|
+
const idUnion = ops.length > 0
|
|
193
|
+
? ops.map((o) => JSON.stringify(o.operationId)).join(" | ")
|
|
194
|
+
: "never";
|
|
195
|
+
|
|
196
|
+
const mapEntries = ops
|
|
197
|
+
.map((op) => {
|
|
198
|
+
const stem = typeStem(op.operationId);
|
|
199
|
+
return ` ${JSON.stringify(op.operationId)}: { request: ${stem}Request; response: ${stem}Response };`;
|
|
200
|
+
})
|
|
201
|
+
.join("\n");
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
header +
|
|
205
|
+
(componentDecls ? `${componentDecls}\n\n` : "") +
|
|
206
|
+
(opDecls ? `${opDecls}\n\n` : "") +
|
|
207
|
+
`/** Every declared operationId (ADR 0058): the delegate key set. */\n` +
|
|
208
|
+
`export type ApiOperationId = ${idUnion};\n\n` +
|
|
209
|
+
`/** Request/response contract per operationId. */\n` +
|
|
210
|
+
`export interface ApiOperations {\n${mapEntries}\n}\n`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
|
|
215
|
+
* generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
|
|
216
|
+
* re-types the runtime's `defineOperation`. */
|
|
217
|
+
export function emitApiBindingsRuntime(): string {
|
|
218
|
+
return (
|
|
219
|
+
"// AUTO-GENERATED by @nanobpm/urban (ADR 0058): the typed `defineOperation`.\n" +
|
|
220
|
+
"// Re-exports the operation SDK and overrides `defineOperation` with an operationId-keyed\n" +
|
|
221
|
+
"// signature (the delegate's validated request + result typed from the spec). Erased to a\n" +
|
|
222
|
+
"// pass-through at runtime. Do not edit.\n" +
|
|
223
|
+
"// eslint-disable\n\n" +
|
|
224
|
+
`import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
|
|
225
|
+
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
226
|
+
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n\n` +
|
|
227
|
+
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
228
|
+
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
229
|
+
`/**\n` +
|
|
230
|
+
` * Typed \`defineOperation\`: \`id\` is constrained to the spec's declared operationIds\n` +
|
|
231
|
+
` * (ADR 0058) so it autocompletes and rejects unknown ops, and the handler's request\n` +
|
|
232
|
+
` * (validated params/query/body) + result are typed from the operation's schemas.\n` +
|
|
233
|
+
` */\n` +
|
|
234
|
+
`export function defineOperation<K extends ApiOperationId>(\n` +
|
|
235
|
+
` id: K,\n` +
|
|
236
|
+
` handler: OperationHandler<ReqFor<K>, ResFor<K>>,\n` +
|
|
237
|
+
`): OperationHandler<ReqFor<K>, ResFor<K>> {\n` +
|
|
238
|
+
` return defineOperationRaw(id, handler);\n` +
|
|
239
|
+
`}\n`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Derive the api artifacts from a parsed OpenAPI document. */
|
|
244
|
+
export function deriveApi(doc: OpenApiDoc): DerivedArtifact[] {
|
|
245
|
+
return [
|
|
246
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
|
|
247
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const apiDeriver: Deriver<{ doc: OpenApiDoc }> = {
|
|
252
|
+
id: "openapi->api-io",
|
|
253
|
+
describe: "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation.",
|
|
254
|
+
derive: ({ doc }) => deriveApi(doc),
|
|
255
|
+
};
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -165,3 +165,42 @@ test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
|
|
|
165
165
|
await runGen({ root: "/cf", io });
|
|
166
166
|
assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
test("collectArtifacts trims api.spec whitespace so gen matches the runtime (no gen/runtime drift)", async () => {
|
|
170
|
+
const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: " openapi.json " } });
|
|
171
|
+
const openapi = JSON.stringify({
|
|
172
|
+
openapi: "3.0.0",
|
|
173
|
+
paths: { "/ping": { get: { operationId: "ping", responses: { "200": {} } } } },
|
|
174
|
+
});
|
|
175
|
+
const io = memIO({ "/app/nano.app.json": manifest, "/app/openapi.json": openapi });
|
|
176
|
+
const res = await collectArtifacts({ root: "/app", io });
|
|
177
|
+
const paths = res.map((a) => a.path);
|
|
178
|
+
// The whitespace-padded spec path still resolved and derived the endpoint contracts.
|
|
179
|
+
assert.ok(paths.includes("nano-generated/api-io.d.ts"));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("collectArtifacts resolves an api.spec with Windows-style separators (no gen/runtime drift)", async () => {
|
|
183
|
+
const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "specs\\openapi.json" } });
|
|
184
|
+
const openapi = JSON.stringify({
|
|
185
|
+
openapi: "3.0.0",
|
|
186
|
+
paths: { "/ping": { get: { operationId: "ping", responses: { "200": {} } } } },
|
|
187
|
+
});
|
|
188
|
+
const io = memIO({ "/app/nano.app.json": manifest, "/app/specs/openapi.json": openapi });
|
|
189
|
+
const res = await collectArtifacts({ root: "/app", io });
|
|
190
|
+
assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("collectArtifacts resolves an ABSOLUTE api.spec at its own path, not root-relative (no gen/runtime drift)", async () => {
|
|
194
|
+
// An absolute manifest spec path must resolve to itself — mirroring the runtime's resolveAppPath
|
|
195
|
+
// — not be stripped to a root-relative "/app/abs/openapi.json". Otherwise gen would read/derive a
|
|
196
|
+
// different file than the runtime dispatches against.
|
|
197
|
+
const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "/abs/openapi.json" } });
|
|
198
|
+
const openapi = JSON.stringify({
|
|
199
|
+
openapi: "3.0.0",
|
|
200
|
+
paths: { "/ping": { get: { operationId: "ping", responses: { "200": {} } } } },
|
|
201
|
+
});
|
|
202
|
+
// Only the absolute location holds the spec; a root-relative read would ENOENT.
|
|
203
|
+
const io = memIO({ "/app/nano.app.json": manifest, "/abs/openapi.json": openapi });
|
|
204
|
+
const res = await collectArtifacts({ root: "/app", io });
|
|
205
|
+
assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
|
|
206
|
+
});
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
// pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
|
|
5
5
|
|
|
6
6
|
import type { DerivedArtifact } from "./artifact.ts";
|
|
7
|
-
import { sortArtifacts } from "./artifact.ts";
|
|
7
|
+
import { isAbsolutePath, sortArtifacts } from "./artifact.ts";
|
|
8
8
|
import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
|
|
9
9
|
import { deriveDomain } from "./derivers/domain.ts";
|
|
10
10
|
import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
|
|
11
11
|
import { deriveMeta } from "./derivers/meta.ts";
|
|
12
12
|
import { deriveMessageBindings } from "./derivers/messages.ts";
|
|
13
|
+
import { deriveApi } from "./derivers/api.ts";
|
|
14
|
+
import { parseSpec } from "../openapi/spec.ts";
|
|
13
15
|
import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
|
|
14
16
|
|
|
15
17
|
/** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
|
|
@@ -56,9 +58,17 @@ export interface GenResult {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
function join(root: string, rel: string): string {
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
61
|
+
// Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
|
|
62
|
+
// platforms — matching the runtime's resolveAppPath) and trim edge separators, so callers may
|
|
63
|
+
// pass either style without gen/runtime drift over where a file resolves.
|
|
64
|
+
const norm = (s: string): string => s.replace(/\\/g, "/");
|
|
65
|
+
// An absolute `rel` resolves to itself — never prefixed with `root` — mirroring the runtime's
|
|
66
|
+
// resolveAppPath (isAbsolutePath is the shared SoT in artifact.ts). Without this an absolute
|
|
67
|
+
// manifest path (e.g. "/abs/openapi.json") would be stripped to root-relative and gen would
|
|
68
|
+
// read/derive a different file than the runtime resolves. Trailing edge separators are still
|
|
69
|
+
// trimmed for stable, comparable artifact keys.
|
|
70
|
+
if (isAbsolutePath(rel)) return norm(rel).replace(/\/+$/, "");
|
|
71
|
+
return `${norm(root).replace(/\/+$/, "")}/${norm(rel).replace(/^\/+/, "")}`;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
function dirOf(p: string): string {
|
|
@@ -112,6 +122,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
112
122
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
113
123
|
const manifest: ToolkitManifest & {
|
|
114
124
|
models?: { processes?: string[]; workflows?: string[] };
|
|
125
|
+
api?: { spec?: string };
|
|
115
126
|
} = JSON.parse(await io.readText(manifestPath));
|
|
116
127
|
|
|
117
128
|
const artifacts: DerivedArtifact[] = [];
|
|
@@ -142,6 +153,15 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
142
153
|
}
|
|
143
154
|
}
|
|
144
155
|
|
|
156
|
+
// 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
|
|
157
|
+
// that is missing or malformed throws here so `urban gen`/`urban check` surfaces it. Trim the
|
|
158
|
+
// spec path so benign whitespace matches the runtime's readApiBinding (no gen/runtime drift).
|
|
159
|
+
const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
|
|
160
|
+
if (!opts.modelsOnly && specRef.length > 0) {
|
|
161
|
+
const specText = await io.readText(join(root, specRef));
|
|
162
|
+
artifacts.push(...deriveApi(parseSpec(specText)));
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
return { artifacts: sortArtifacts(artifacts), derived };
|
|
146
166
|
}
|
|
147
167
|
|
package/src/toolkit/index.ts
CHANGED
|
@@ -98,13 +98,53 @@ export {
|
|
|
98
98
|
} from "./derivers/messages.ts";
|
|
99
99
|
export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
|
|
100
100
|
|
|
101
|
+
export {
|
|
102
|
+
apiDeriver,
|
|
103
|
+
deriveApi,
|
|
104
|
+
emitApiBindings,
|
|
105
|
+
emitApiBindingsRuntime,
|
|
106
|
+
schemaToTs,
|
|
107
|
+
API_BINDINGS_DTS,
|
|
108
|
+
API_BINDINGS_TS,
|
|
109
|
+
} from "./derivers/api.ts";
|
|
110
|
+
|
|
111
|
+
// The OpenAPI machinery the deriver + runtime share (ADR 0058).
|
|
112
|
+
export {
|
|
113
|
+
collectOperations,
|
|
114
|
+
HTTP_METHODS,
|
|
115
|
+
isSafeOperationId,
|
|
116
|
+
operationsWithoutId,
|
|
117
|
+
operationsWithUnsafeId,
|
|
118
|
+
parseSpec,
|
|
119
|
+
refName,
|
|
120
|
+
resolveSchema,
|
|
121
|
+
toRouteMatcher,
|
|
122
|
+
undeclaredPathParams,
|
|
123
|
+
validateValue,
|
|
124
|
+
} from "../openapi/spec.ts";
|
|
125
|
+
export type {
|
|
126
|
+
HttpMethodLower,
|
|
127
|
+
OpenApiDoc,
|
|
128
|
+
OpenApiParameter,
|
|
129
|
+
OpenApiSchema,
|
|
130
|
+
OperationInfo,
|
|
131
|
+
} from "../openapi/spec.ts";
|
|
132
|
+
|
|
101
133
|
// The registry of all derivers (for discovery / IDE migration).
|
|
102
134
|
import { migrationsDeriver } from "./derivers/migrations.ts";
|
|
103
135
|
import { domainDeriver } from "./derivers/domain.ts";
|
|
104
136
|
import { workerIoDeriver } from "./derivers/worker-io.ts";
|
|
105
137
|
import { metaDeriver } from "./derivers/meta.ts";
|
|
106
138
|
import { messagesDeriver } from "./derivers/messages.ts";
|
|
107
|
-
|
|
139
|
+
import { apiDeriver } from "./derivers/api.ts";
|
|
140
|
+
export const DERIVERS = [
|
|
141
|
+
migrationsDeriver,
|
|
142
|
+
domainDeriver,
|
|
143
|
+
workerIoDeriver,
|
|
144
|
+
metaDeriver,
|
|
145
|
+
messagesDeriver,
|
|
146
|
+
apiDeriver,
|
|
147
|
+
] as const;
|
|
108
148
|
|
|
109
149
|
// Gen orchestrator + IO
|
|
110
150
|
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
|