@nanobpm/urban 0.29.0 → 0.31.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/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/datasource.d.ts +5 -2
- package/dist/runtime/core/modules/datasource.js +5 -4
- package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
- package/dist/runtime/core/modules/instance-tracking.js +28 -11
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +43 -7
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/engine/nanosdk.js +41 -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 +1 -1
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -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/datasource.ts +5 -4
- package/src/runtime/core/modules/instance-tracking.ts +33 -11
- package/src/runtime/core/modules/pages.test.ts +63 -2
- package/src/runtime/core/modules/pages.ts +49 -7
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/engine/nanosdk.test.ts +45 -5
- package/src/runtime/engine/nanosdk.ts +39 -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,453 @@
|
|
|
1
|
+
// api — the OpenAPI endpoint surface (ADR 0058). The contract-first counterpart to `actions[]`:
|
|
2
|
+
// instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
|
|
3
|
+
// writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
|
|
4
|
+
// toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
|
|
5
|
+
// `api.base` namespace, and for each request: parses → validates params/query/body against the spec →
|
|
6
|
+
// 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
|
|
7
|
+
// the injected AppApi, exactly like a worker/action handler) → serializes the result.
|
|
8
|
+
//
|
|
9
|
+
// Coexistence + eject (ADR 0058): this mounts AFTER `mountActions` (so an exact `actions[]` route
|
|
10
|
+
// still shadows) and BEFORE the generic pages routes. Ejecting skips generated validation and hands
|
|
11
|
+
// the delegate the raw request — whole-surface via `api.eject`, or per-operation via the
|
|
12
|
+
// `x-urban-eject: true` OpenAPI vendor extension. The delegate is always additively imperative: it
|
|
13
|
+
// gets the raw `req` and full `AppApi` on top of the validated input.
|
|
14
|
+
|
|
15
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
16
|
+
import { errorMessage } from "../guards.ts";
|
|
17
|
+
import type { HttpRequest, HttpResponse } from "../host.ts";
|
|
18
|
+
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
19
|
+
import { resolveAppPath } from "./datasource.ts";
|
|
20
|
+
import {
|
|
21
|
+
collectOperations,
|
|
22
|
+
isSafeOperationId,
|
|
23
|
+
type OpenApiDoc,
|
|
24
|
+
type OpenApiSchema,
|
|
25
|
+
type OperationInfo,
|
|
26
|
+
operationsWithoutId,
|
|
27
|
+
operationsWithUnsafeId,
|
|
28
|
+
parseSpec,
|
|
29
|
+
resolveSchema,
|
|
30
|
+
toRouteMatcher,
|
|
31
|
+
undeclaredPathParams,
|
|
32
|
+
validateValue,
|
|
33
|
+
type ValidationIssue,
|
|
34
|
+
} from "../../../openapi/spec.ts";
|
|
35
|
+
|
|
36
|
+
/** The manifest `api` binding (ADR 0058). Mirrored locally until `@nanobpm/nano-app-schema`
|
|
37
|
+
* republishes with the `api` field folded into `AppManifest` (the schema PR lands first). */
|
|
38
|
+
export interface ApiBinding {
|
|
39
|
+
spec: string;
|
|
40
|
+
dir?: string;
|
|
41
|
+
base?: string;
|
|
42
|
+
validateResponses?: "dev" | "always" | "never";
|
|
43
|
+
eject?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `{ params, query, body }` contract a typed operation is generic over. */
|
|
47
|
+
export interface OperationContract {
|
|
48
|
+
params: unknown;
|
|
49
|
+
query: unknown;
|
|
50
|
+
body: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). */
|
|
54
|
+
export interface DefaultContract {
|
|
55
|
+
params: Record<string, string>;
|
|
56
|
+
query: Record<string, string | string[] | undefined>;
|
|
57
|
+
body: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
|
|
61
|
+
* validated) path params, query, and JSON body. */
|
|
62
|
+
export interface OperationInput<Req extends OperationContract = DefaultContract> {
|
|
63
|
+
req: HttpRequest;
|
|
64
|
+
params: Req["params"];
|
|
65
|
+
query: Req["query"];
|
|
66
|
+
body: Req["body"];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What a delegate returns; the runtime serializes `body` as JSON. Empty return → `204`. */
|
|
70
|
+
export interface OperationResult<Res = unknown> {
|
|
71
|
+
status?: number;
|
|
72
|
+
headers?: Record<string, string>;
|
|
73
|
+
body?: Res;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** An app-authored operation delegate: validated/typed input + the injected AppApi. */
|
|
77
|
+
export type OperationHandler<Req extends OperationContract = DefaultContract, Res = unknown> = (
|
|
78
|
+
input: OperationInput<Req>,
|
|
79
|
+
app: AppApi,
|
|
80
|
+
) => Promise<OperationResult<Res> | void> | OperationResult<Res> | void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
84
|
+
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
85
|
+
* — the handler's first argument is the validated `OperationInput` (params/query/body/req), not the
|
|
86
|
+
* raw request. The runtime resolves a delegate by its module path (`<dir>/<operationId>`), so the id
|
|
87
|
+
* here is a documentation + typing aid; it is returned unchanged.
|
|
88
|
+
*/
|
|
89
|
+
export function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(
|
|
90
|
+
_id: string,
|
|
91
|
+
handler: OperationHandler<Req, Res>,
|
|
92
|
+
): OperationHandler<Req, Res> {
|
|
93
|
+
return handler;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isFunction(v: unknown): v is OperationHandler {
|
|
97
|
+
return typeof v === "function";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
|
|
101
|
+
export function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined {
|
|
102
|
+
if (isFunction(mod.default)) return mod.default;
|
|
103
|
+
if (isFunction(mod.handler)) return mod.handler;
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readApiBinding(manifest: unknown): ApiBinding | undefined {
|
|
108
|
+
const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "api") : undefined;
|
|
109
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
110
|
+
const spec = Reflect.get(raw, "spec");
|
|
111
|
+
if (typeof spec !== "string" || spec.trim().length === 0) return undefined;
|
|
112
|
+
const dir = Reflect.get(raw, "dir");
|
|
113
|
+
const base = Reflect.get(raw, "base");
|
|
114
|
+
const validateResponses = Reflect.get(raw, "validateResponses");
|
|
115
|
+
const eject = Reflect.get(raw, "eject");
|
|
116
|
+
// Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
|
|
117
|
+
return {
|
|
118
|
+
spec: spec.trim(),
|
|
119
|
+
dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
|
|
120
|
+
base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
|
|
121
|
+
validateResponses:
|
|
122
|
+
validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
|
|
123
|
+
? validateResponses
|
|
124
|
+
: undefined,
|
|
125
|
+
eject: eject === true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
|
|
130
|
+
* validate. A value that will not coerce is left as-is and fails the type check with a clear
|
|
131
|
+
* message. The delegate still receives the original string(s). */
|
|
132
|
+
function coerceParam(doc: OpenApiDoc, schema: OpenApiSchema | undefined, raw: string): unknown {
|
|
133
|
+
const s = resolveSchema(doc, schema);
|
|
134
|
+
if (!s) return raw;
|
|
135
|
+
if (s.type === "integer" || s.type === "number") {
|
|
136
|
+
const n = Number(raw);
|
|
137
|
+
return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
|
|
138
|
+
}
|
|
139
|
+
if (s.type === "boolean") {
|
|
140
|
+
if (raw === "true") return true;
|
|
141
|
+
if (raw === "false") return false;
|
|
142
|
+
}
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface MountedOp {
|
|
147
|
+
op: OperationInfo;
|
|
148
|
+
pattern: RegExp;
|
|
149
|
+
paramNames: string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ApiHandle {
|
|
153
|
+
readonly name: string;
|
|
154
|
+
routes: Route[];
|
|
155
|
+
describe(): Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Mount the OpenAPI endpoint surface. Returns no routes when the app declares no `api` binding, so
|
|
160
|
+
* it is a no-op for apps that use only `actions[]`. A malformed spec (bad JSON / not an object)
|
|
161
|
+
* mounts a single route under the base that returns a clear 500, rather than failing app boot.
|
|
162
|
+
*/
|
|
163
|
+
export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
164
|
+
const binding = readApiBinding(ctx.manifest);
|
|
165
|
+
if (!binding) return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
|
|
166
|
+
|
|
167
|
+
// Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
|
|
168
|
+
// ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
|
|
169
|
+
// like "app/api" would never match request paths, which always start with "/".
|
|
170
|
+
const base = normalizeRoutePath(binding.base, "/app/api");
|
|
171
|
+
const dir = binding.dir ?? "operations";
|
|
172
|
+
const surfaceEject = binding.eject === true;
|
|
173
|
+
|
|
174
|
+
// Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
|
|
175
|
+
// pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
|
|
176
|
+
// than failing app boot.
|
|
177
|
+
let docPromise: Promise<OpenApiDoc> | undefined;
|
|
178
|
+
const loadDoc = (): Promise<OpenApiDoc> => {
|
|
179
|
+
if (!docPromise) {
|
|
180
|
+
docPromise = ctx.host
|
|
181
|
+
.readTextFile(resolveAppPath(ctx.root, binding.spec))
|
|
182
|
+
.then((text) => parseSpec(text))
|
|
183
|
+
.catch((e) => {
|
|
184
|
+
docPromise = undefined; // let a later request retry (e.g. after the file is fixed)
|
|
185
|
+
throw new Error(`OpenAPI spec ${binding.spec} failed to load: ${errorMessage(e)}`);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return docPromise;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Lazily import + cache each delegate module, mirroring mountActions.
|
|
192
|
+
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
|
193
|
+
const loadModule = (operationId: string): Promise<Record<string, unknown>> => {
|
|
194
|
+
// Defense-in-depth at the import sink: collectOperations already skips unsafe ids, but never
|
|
195
|
+
// build an import path from an operationId that isn't a single safe segment.
|
|
196
|
+
if (!isSafeOperationId(operationId)) {
|
|
197
|
+
return Promise.reject(new Error(`unsafe operationId (not a single path segment): ${operationId}`));
|
|
198
|
+
}
|
|
199
|
+
const key = resolveAppPath(ctx.root, `${dir.replace(/[/\\]+$/, "")}/${operationId}`);
|
|
200
|
+
let pending = moduleCache.get(key);
|
|
201
|
+
if (!pending) {
|
|
202
|
+
pending = ctx.host.importModule(key).catch((err) => {
|
|
203
|
+
moduleCache.delete(key);
|
|
204
|
+
throw err;
|
|
205
|
+
});
|
|
206
|
+
moduleCache.set(key, pending);
|
|
207
|
+
}
|
|
208
|
+
return pending;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Build the operation table lazily from the loaded doc (cached).
|
|
212
|
+
let opsPromise: Promise<MountedOp[]> | undefined;
|
|
213
|
+
const loadOps = (): Promise<MountedOp[]> => {
|
|
214
|
+
if (!opsPromise) {
|
|
215
|
+
opsPromise = loadDoc()
|
|
216
|
+
.then((d) => {
|
|
217
|
+
const missing = operationsWithoutId(d);
|
|
218
|
+
if (missing.length > 0) {
|
|
219
|
+
ctx.host.log("warn", "OpenAPI operations without operationId are skipped (ADR 0058)", {
|
|
220
|
+
missing,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const undeclared = undeclaredPathParams(d);
|
|
224
|
+
if (undeclared.length > 0) {
|
|
225
|
+
ctx.host.log(
|
|
226
|
+
"warn",
|
|
227
|
+
"OpenAPI path-template params are not declared as parameters — captured at runtime but neither typed nor validated (ADR 0058)",
|
|
228
|
+
{ undeclared },
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const unsafe = operationsWithUnsafeId(d);
|
|
232
|
+
if (unsafe.length > 0) {
|
|
233
|
+
ctx.host.log(
|
|
234
|
+
"warn",
|
|
235
|
+
"OpenAPI operationIds that are not a safe path segment are skipped (ADR 0058)",
|
|
236
|
+
{ unsafe },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const mounted = collectOperations(d).map((op) => {
|
|
240
|
+
const { pattern, paramNames } = toRouteMatcher(base, op.path);
|
|
241
|
+
return { op, pattern, paramNames };
|
|
242
|
+
});
|
|
243
|
+
// Dispatch matches the first route in order, so sort most-specific first: fewer path-template
|
|
244
|
+
// params (a static segment beats a `{param}` capture), then longer path. This keeps a static
|
|
245
|
+
// route (e.g. /x/active) from being shadowed by a templated one (e.g. /x/{id}).
|
|
246
|
+
mounted.sort((a, b) => {
|
|
247
|
+
if (a.paramNames.length !== b.paramNames.length) {
|
|
248
|
+
return a.paramNames.length - b.paramNames.length;
|
|
249
|
+
}
|
|
250
|
+
return b.op.path.length - a.op.path.length;
|
|
251
|
+
});
|
|
252
|
+
return mounted;
|
|
253
|
+
})
|
|
254
|
+
.catch((e) => {
|
|
255
|
+
opsPromise = undefined; // don't cache the rejection — let a later request retry (mirrors loadDoc)
|
|
256
|
+
throw e;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return opsPromise;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const dispatchInner = async (req: HttpRequest): Promise<HttpResponse> => {
|
|
263
|
+
let d: OpenApiDoc;
|
|
264
|
+
let ops: MountedOp[];
|
|
265
|
+
try {
|
|
266
|
+
d = await loadDoc();
|
|
267
|
+
ops = await loadOps();
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return json({ error: errorMessage(e) }, 500);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const method = req.method.toUpperCase();
|
|
273
|
+
const match = ops.find(
|
|
274
|
+
(m) => m.op.method.toUpperCase() === method && m.pattern.test(req.path),
|
|
275
|
+
);
|
|
276
|
+
if (!match) {
|
|
277
|
+
// Path is in our namespace but no operation matches: is it a known path on another method?
|
|
278
|
+
const pathKnown = ops.some((m) => m.pattern.test(req.path));
|
|
279
|
+
return json(
|
|
280
|
+
{ error: pathKnown ? "method not allowed" : "no such operation" },
|
|
281
|
+
pathKnown ? 405 : 404,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const { op } = match;
|
|
285
|
+
const ejected = surfaceEject || op.eject;
|
|
286
|
+
|
|
287
|
+
// Path params from the capture groups; query as a single-or-array map.
|
|
288
|
+
const captures = match.pattern.exec(req.path) ?? [];
|
|
289
|
+
const params: Record<string, string> = {};
|
|
290
|
+
try {
|
|
291
|
+
match.paramNames.forEach((name, i) => {
|
|
292
|
+
const v = captures[i + 1];
|
|
293
|
+
if (v !== undefined) params[name] = decodeURIComponent(v);
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
// Malformed percent-encoding is client input, not a server fault — a clear 400 beats a 500.
|
|
297
|
+
return json({ error: "malformed path parameter encoding" }, 400);
|
|
298
|
+
}
|
|
299
|
+
const query: Record<string, string | string[] | undefined> = {};
|
|
300
|
+
for (const key of new Set(req.query.keys())) {
|
|
301
|
+
const all = req.query.getAll(key);
|
|
302
|
+
query[key] = all.length > 1 ? all : all[0];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Parse the JSON body (empty body → undefined). Invalid JSON is a 400 unless the op is ejected
|
|
306
|
+
// (then the delegate reads the raw request itself).
|
|
307
|
+
const raw = await req.text();
|
|
308
|
+
let body: unknown = {};
|
|
309
|
+
let bodyParseFailed = false;
|
|
310
|
+
if (raw) {
|
|
311
|
+
try {
|
|
312
|
+
body = JSON.parse(raw);
|
|
313
|
+
} catch {
|
|
314
|
+
bodyParseFailed = true;
|
|
315
|
+
body = undefined;
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
body = undefined;
|
|
319
|
+
}
|
|
320
|
+
if (bodyParseFailed && !ejected) {
|
|
321
|
+
return json({ error: "request body must be JSON" }, 400);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!ejected) {
|
|
325
|
+
const issues: ValidationIssue[] = [];
|
|
326
|
+
for (const p of op.parameters) {
|
|
327
|
+
if (p.in === "path") {
|
|
328
|
+
const val = params[p.name];
|
|
329
|
+
if (val === undefined) {
|
|
330
|
+
if (p.required) issues.push({ path: `params/${p.name}`, message: "is required" });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, val), `params/${p.name}`));
|
|
334
|
+
} else if (p.in === "query") {
|
|
335
|
+
const val = query[p.name];
|
|
336
|
+
if (val === undefined) {
|
|
337
|
+
if (p.required) issues.push({ path: `query/${p.name}`, message: "is required" });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const values = Array.isArray(val) ? val : [val];
|
|
341
|
+
const ps = resolveSchema(d, p.schema);
|
|
342
|
+
if (ps?.type === "array") {
|
|
343
|
+
// Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
|
|
344
|
+
// whole array against the schema's array constraints (minItems, items, ...).
|
|
345
|
+
const items = ps.items ? values.map((v) => coerceParam(d, ps.items, v)) : values;
|
|
346
|
+
issues.push(...validateValue(d, ps, items, `query/${p.name}`));
|
|
347
|
+
} else {
|
|
348
|
+
// Scalar param: validate EVERY provided value, not just the first, so extra repeated
|
|
349
|
+
// values can't bypass validation.
|
|
350
|
+
values.forEach((v, i) => {
|
|
351
|
+
const at = values.length > 1 ? `query/${p.name}[${i}]` : `query/${p.name}`;
|
|
352
|
+
issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, v), at));
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (op.requestBodyRequired && (raw === "" || body === undefined)) {
|
|
358
|
+
issues.push({ path: "body", message: "request body is required" });
|
|
359
|
+
} else if (op.requestBodySchema && body !== undefined) {
|
|
360
|
+
issues.push(...validateValue(d, op.requestBodySchema, body, "body"));
|
|
361
|
+
}
|
|
362
|
+
if (issues.length > 0) {
|
|
363
|
+
return json({ error: "request validation failed", issues }, 400);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let handler: OperationHandler | undefined;
|
|
368
|
+
try {
|
|
369
|
+
handler = resolveOperationHandler(await loadModule(op.operationId));
|
|
370
|
+
} catch (e) {
|
|
371
|
+
ctx.host.log("error", "operation delegate failed to load", {
|
|
372
|
+
operationId: op.operationId,
|
|
373
|
+
dir,
|
|
374
|
+
error: errorMessage(e),
|
|
375
|
+
});
|
|
376
|
+
return json({ error: `operation ${op.operationId} delegate failed to load` }, 500);
|
|
377
|
+
}
|
|
378
|
+
if (!handler) {
|
|
379
|
+
return json(
|
|
380
|
+
{
|
|
381
|
+
error: `operation ${op.operationId} delegate exports no default function or named \`handler\``,
|
|
382
|
+
},
|
|
383
|
+
500,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Keep the runtime body in lockstep with the derived type: the deriver types `body` as
|
|
388
|
+
// `undefined` exactly when the op declares no usable request body (no JSON schema and not
|
|
389
|
+
// required), so pass `undefined` to those handlers rather than leaking a parsed value through
|
|
390
|
+
// a body-less type.
|
|
391
|
+
const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
|
|
392
|
+
try {
|
|
393
|
+
const result = await handler({ req, params, query, body: handlerBody }, app);
|
|
394
|
+
if (!result) return { status: 204 };
|
|
395
|
+
// Optional response validation (dev by default): warn-only, never blocks the response.
|
|
396
|
+
const mode = binding.validateResponses ?? "dev";
|
|
397
|
+
const doValidate =
|
|
398
|
+
mode === "always" || (mode === "dev" && ctx.host.env("NODE_ENV") !== "production");
|
|
399
|
+
if (doValidate && op.responseSchema && result.body !== undefined) {
|
|
400
|
+
const rIssues = validateValue(d, op.responseSchema, result.body, "response");
|
|
401
|
+
if (rIssues.length > 0) {
|
|
402
|
+
ctx.host.log("warn", "operation response failed schema validation (ADR 0058)", {
|
|
403
|
+
operationId: op.operationId,
|
|
404
|
+
issues: rIssues,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
status: result.status ?? 200,
|
|
410
|
+
headers: { "content-type": "application/json", ...(result.headers ?? {}) },
|
|
411
|
+
body: result.body === undefined ? undefined : JSON.stringify(result.body),
|
|
412
|
+
};
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return json({ error: errorMessage(e) }, 500);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Categorical safety net: any unexpected throw during dispatch — e.g. a malformed spec
|
|
419
|
+
// `pattern` surfacing from validation — becomes a controlled 500 rather than escaping into
|
|
420
|
+
// the host request handler, which has no top-level catch of its own.
|
|
421
|
+
const dispatch = async (req: HttpRequest): Promise<HttpResponse> => {
|
|
422
|
+
try {
|
|
423
|
+
return await dispatchInner(req);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
return json({ error: errorMessage(e) }, 500);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// One prefix route over the base namespace does regex dispatch across all operations — the shared
|
|
430
|
+
// router only does exact/prefix matches, so path-param templates are handled here. A trailing
|
|
431
|
+
// slash keeps the prefix boundary-safe.
|
|
432
|
+
const route: Route = {
|
|
433
|
+
method: "*",
|
|
434
|
+
path: `${base}/`,
|
|
435
|
+
prefix: true,
|
|
436
|
+
source: `api:${binding.spec}`,
|
|
437
|
+
handler: dispatch,
|
|
438
|
+
};
|
|
439
|
+
// Also serve the base path exactly (an operation could live at the namespace root).
|
|
440
|
+
const rootRoute: Route = {
|
|
441
|
+
method: "*",
|
|
442
|
+
path: base,
|
|
443
|
+
prefix: false,
|
|
444
|
+
source: `api:${binding.spec}`,
|
|
445
|
+
handler: dispatch,
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
name: "api",
|
|
450
|
+
routes: [rootRoute, route],
|
|
451
|
+
describe: () => ({ api: { spec: binding.spec, base, dir, eject: surfaceEject } }),
|
|
452
|
+
};
|
|
453
|
+
}
|