@nanobpm/urban 0.36.0 → 0.38.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 +18 -2
- package/dist/openapi/spec.js +35 -12
- package/dist/runtime/core/modules/api.d.ts +10 -6
- package/dist/runtime/core/modules/api.js +62 -17
- package/dist/toolkit/derivers/api.d.ts +9 -4
- package/dist/toolkit/derivers/api.js +60 -24
- package/dist/toolkit/gen.js +1 -1
- package/dist/toolkit/index.d.ts +1 -0
- package/dist/toolkit/scaffold/workers.d.ts +7 -5
- package/dist/toolkit/scaffold/workers.js +11 -4
- package/dist/toolkit/scaffold.js +1 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +44 -0
- package/src/openapi/spec.ts +49 -12
- package/src/runtime/core/modules/api.test.ts +151 -1
- package/src/runtime/core/modules/api.ts +66 -22
- package/src/toolkit/derivers/api.test.ts +160 -3
- package/src/toolkit/derivers/api.ts +65 -25
- package/src/toolkit/gen.ts +2 -2
- package/src/toolkit/scaffold/workers.test.ts +35 -0
- package/src/toolkit/scaffold/workers.ts +12 -4
- package/src/toolkit/scaffold.ts +7 -1
package/dist/openapi/spec.d.ts
CHANGED
|
@@ -87,6 +87,14 @@ export interface OpenApiDoc {
|
|
|
87
87
|
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
88
88
|
export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
89
89
|
export type HttpMethodLower = (typeof HTTP_METHODS)[number];
|
|
90
|
+
/** A single documented response: its status code ("200"), status range ("2XX"), or "default", and
|
|
91
|
+
* its JSON body schema when it documents one. `schema` is absent when the response is documented
|
|
92
|
+
* but carries no JSON body — recorded so an exact-status match can suppress the `default` fallback
|
|
93
|
+
* rather than mis-validating a bodyless status against the default (typically error) schema. */
|
|
94
|
+
export interface ResponseSchemaEntry {
|
|
95
|
+
status: string;
|
|
96
|
+
schema?: OpenApiSchema;
|
|
97
|
+
}
|
|
90
98
|
/** One resolved operation: its id, method, path, parameters, request body schema, and responses.
|
|
91
99
|
* This is the shared unit both the deriver (→ types) and the runtime (→ routes) consume. */
|
|
92
100
|
export interface OperationInfo {
|
|
@@ -97,8 +105,10 @@ export interface OperationInfo {
|
|
|
97
105
|
parameters: OpenApiParameter[];
|
|
98
106
|
requestBodySchema?: OpenApiSchema;
|
|
99
107
|
requestBodyRequired: boolean;
|
|
100
|
-
/**
|
|
101
|
-
|
|
108
|
+
/** Every documented JSON response, keyed by status code ("200","400",…,"default"), in a stable
|
|
109
|
+
* order. The type layer unions these into the operation's response type; the runtime validates a
|
|
110
|
+
* handler result against the entry matching its status (else "default"). */
|
|
111
|
+
responseSchemas: ResponseSchemaEntry[];
|
|
102
112
|
/** Effective security requirements (op-level if declared — including an explicit empty list —
|
|
103
113
|
* else the document-level default, else `[]`). An empty list means the operation is open. */
|
|
104
114
|
security: OpenApiSecurityRequirement[];
|
|
@@ -172,6 +182,12 @@ export declare function evaluateSecurity(doc: OpenApiDoc, op: OperationInfo, get
|
|
|
172
182
|
* components.securitySchemes — an app-drift the runtime rejects at request time (500) and the
|
|
173
183
|
* `--check` gate can surface ahead of time. */
|
|
174
184
|
export declare function undeclaredSecuritySchemes(doc: OpenApiDoc): string[];
|
|
185
|
+
/** Select the response schema to validate a handler result against: exact status → status range
|
|
186
|
+
* ("2XX") → "default". A documented-but-bodyless exact status (or range) returns undefined (no
|
|
187
|
+
* validation) and does NOT fall through to `default` — the status IS documented, it just carries no
|
|
188
|
+
* JSON body, so validating it against an unrelated (e.g. error `default`) schema would be wrong. An
|
|
189
|
+
* UNdocumented status falls back to the `default` entry when present, else undefined. */
|
|
190
|
+
export declare function responseSchemaForStatus(entries: ResponseSchemaEntry[], status: number): OpenApiSchema | undefined;
|
|
175
191
|
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
176
192
|
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
177
193
|
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
package/dist/openapi/spec.js
CHANGED
|
@@ -139,7 +139,7 @@ export function collectOperations(doc) {
|
|
|
139
139
|
parameters,
|
|
140
140
|
requestBodySchema,
|
|
141
141
|
requestBodyRequired: requestBody?.required === true,
|
|
142
|
-
|
|
142
|
+
responseSchemas: collectResponseSchemas(responses),
|
|
143
143
|
security,
|
|
144
144
|
eject: opRaw["x-urban-eject"] === true,
|
|
145
145
|
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
@@ -399,17 +399,40 @@ function normalizeParameters(raw) {
|
|
|
399
399
|
}
|
|
400
400
|
return order.map((k) => byKey.get(k));
|
|
401
401
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
402
|
+
/** Every documented response, in a stable order: numeric status codes ascending, then status
|
|
403
|
+
* ranges ("1XX".."5XX"), then "default" last. A response with no JSON body schema is still
|
|
404
|
+
* recorded (with `schema` undefined) so an exact-status match suppresses the `default` fallback
|
|
405
|
+
* instead of the bodyless status being mis-validated against the default (error) schema. */
|
|
406
|
+
function collectResponseSchemas(responses) {
|
|
407
|
+
const isExact = (c) => /^\d{3}$/.test(c);
|
|
408
|
+
const isRange = (c) => /^[1-5]XX$/i.test(c);
|
|
409
|
+
const keys = Object.keys(responses).filter((c) => isExact(c) || isRange(c) || c === "default");
|
|
410
|
+
const rank = (c) => (c === "default" ? 2 : isRange(c) ? 1 : 0);
|
|
411
|
+
keys.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
|
|
412
|
+
const out = [];
|
|
413
|
+
for (const key of keys) {
|
|
414
|
+
const resp = responses[key];
|
|
415
|
+
if (!isRecord(resp))
|
|
416
|
+
continue;
|
|
417
|
+
const status = isRange(key) ? key.toUpperCase() : key;
|
|
418
|
+
const schema = firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
|
|
419
|
+
out.push(schema ? { status, schema } : { status });
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
/** Select the response schema to validate a handler result against: exact status → status range
|
|
424
|
+
* ("2XX") → "default". A documented-but-bodyless exact status (or range) returns undefined (no
|
|
425
|
+
* validation) and does NOT fall through to `default` — the status IS documented, it just carries no
|
|
426
|
+
* JSON body, so validating it against an unrelated (e.g. error `default`) schema would be wrong. An
|
|
427
|
+
* UNdocumented status falls back to the `default` entry when present, else undefined. */
|
|
428
|
+
export function responseSchemaForStatus(entries, status) {
|
|
429
|
+
const exact = entries.find((e) => e.status === String(status));
|
|
430
|
+
if (exact)
|
|
431
|
+
return exact.schema;
|
|
432
|
+
const range = entries.find((e) => e.status === `${Math.floor(status / 100)}XX`);
|
|
433
|
+
if (range)
|
|
434
|
+
return range.schema;
|
|
435
|
+
return entries.find((e) => e.status === "default")?.schema;
|
|
413
436
|
}
|
|
414
437
|
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
415
438
|
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
@@ -22,13 +22,17 @@ export interface OperationContract {
|
|
|
22
22
|
query: unknown;
|
|
23
23
|
body?: unknown;
|
|
24
24
|
}
|
|
25
|
-
/** The loose contract an untyped delegate sees
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
/** The loose contract an untyped delegate sees. Params/query values are `unknown` (coerced to their
|
|
26
|
+
* declared schema type before the delegate runs — the runtime validates then forwards the coerced
|
|
27
|
+
* value — but a schemaless/undeclared key, or any param of an ejected op, is forwarded raw, hence
|
|
28
|
+
* `unknown` rather than the coerced type); the JSON body is `unknown`. `body` is optional so a raw
|
|
29
|
+
* (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the generated
|
|
30
|
+
* registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body is
|
|
31
|
+
* optional/absent must be a subtype of the default contract (handler args are contravariant), and a
|
|
32
|
+
* schema-typed param object (e.g. `{ count: number }`) must be assignable to it. */
|
|
29
33
|
export interface DefaultContract {
|
|
30
|
-
params: Record<string,
|
|
31
|
-
query: Record<string,
|
|
34
|
+
params: Record<string, unknown>;
|
|
35
|
+
query: Record<string, unknown>;
|
|
32
36
|
body?: unknown;
|
|
33
37
|
}
|
|
34
38
|
/** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
|
|
@@ -15,7 +15,7 @@ import { errorMessage } from "../guards.js";
|
|
|
15
15
|
import { MODULE_EXTENSION_CANDIDATES } from "../module-path.js";
|
|
16
16
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
17
17
|
import { resolveAppPath } from "./datasource.js";
|
|
18
|
-
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
|
|
18
|
+
import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, responseSchemaForStatus, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
|
|
19
19
|
/** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
|
|
20
20
|
* live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
|
|
21
21
|
* stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
|
|
@@ -140,17 +140,34 @@ export function apiDocsPath(manifest) {
|
|
|
140
140
|
return docsEnabled ? docsBase : undefined;
|
|
141
141
|
}
|
|
142
142
|
/** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
|
|
143
|
-
* validate
|
|
144
|
-
*
|
|
143
|
+
* validate and the delegate receives the coerced value (not the raw string). A value that will not
|
|
144
|
+
* coerce is left as-is and fails the type check with a clear message. */
|
|
145
145
|
function coerceParam(doc, schema, raw) {
|
|
146
146
|
const s = resolveSchema(doc, schema);
|
|
147
147
|
if (!s)
|
|
148
148
|
return raw;
|
|
149
|
-
|
|
149
|
+
// Effective scalar type: the explicit `type`, else inferred from a typeless `const`/`enum` whose
|
|
150
|
+
// values are all numbers or all booleans. Without this, a typeless numeric/boolean const/enum
|
|
151
|
+
// param (whose generated type is a number/boolean literal) could never be satisfied by the wire
|
|
152
|
+
// string — every request would 400 against a type the runtime otherwise never produces.
|
|
153
|
+
let t = s.type;
|
|
154
|
+
if (t === undefined) {
|
|
155
|
+
if (typeof s.const === "number")
|
|
156
|
+
t = "number";
|
|
157
|
+
else if (typeof s.const === "boolean")
|
|
158
|
+
t = "boolean";
|
|
159
|
+
else if (Array.isArray(s.enum) && s.enum.length > 0) {
|
|
160
|
+
if (s.enum.every((v) => typeof v === "number"))
|
|
161
|
+
t = "number";
|
|
162
|
+
else if (s.enum.every((v) => typeof v === "boolean"))
|
|
163
|
+
t = "boolean";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (t === "integer" || t === "number") {
|
|
150
167
|
const n = Number(raw);
|
|
151
168
|
return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
|
|
152
169
|
}
|
|
153
|
-
if (
|
|
170
|
+
if (t === "boolean") {
|
|
154
171
|
if (raw === "true")
|
|
155
172
|
return true;
|
|
156
173
|
if (raw === "false")
|
|
@@ -365,6 +382,11 @@ export function mountApi(ctx, app) {
|
|
|
365
382
|
const all = req.query.getAll(key);
|
|
366
383
|
query[key] = all.length > 1 ? all : all[0];
|
|
367
384
|
}
|
|
385
|
+
// What the delegate actually receives: params/query coerced to their declared schema type. Seed
|
|
386
|
+
// from the raw wire values (used verbatim for ejected ops and for undeclared/schemaless keys);
|
|
387
|
+
// the validation loop below overwrites each declared parameter with its coerced value.
|
|
388
|
+
const coercedParams = { ...params };
|
|
389
|
+
const coercedQuery = { ...query };
|
|
368
390
|
// Parse the JSON body (empty body → undefined). Invalid JSON is a 400 unless the op is ejected
|
|
369
391
|
// (then the delegate reads the raw request itself).
|
|
370
392
|
const raw = await req.text();
|
|
@@ -395,7 +417,11 @@ export function mountApi(ctx, app) {
|
|
|
395
417
|
issues.push({ path: `params/${p.name}`, message: "is required" });
|
|
396
418
|
continue;
|
|
397
419
|
}
|
|
398
|
-
|
|
420
|
+
// Coerce once, forward the coerced value to the delegate (so a `count` param is a number
|
|
421
|
+
// in the delegate's typed input, matching the generated schema type), and validate it.
|
|
422
|
+
const coerced = coerceParam(d, p.schema, val);
|
|
423
|
+
coercedParams[p.name] = coerced;
|
|
424
|
+
issues.push(...validateValue(d, p.schema, coerced, `params/${p.name}`));
|
|
399
425
|
}
|
|
400
426
|
else if (p.in === "query") {
|
|
401
427
|
const val = query[p.name];
|
|
@@ -404,21 +430,33 @@ export function mountApi(ctx, app) {
|
|
|
404
430
|
issues.push({ path: `query/${p.name}`, message: "is required" });
|
|
405
431
|
continue;
|
|
406
432
|
}
|
|
433
|
+
// A declared but schemaless query param has no shape to coerce/validate against — its
|
|
434
|
+
// generated type is the raw wire `string | string[]`, so preserve raw semantics
|
|
435
|
+
// (including repeated keys) and forward the value verbatim (already seeded in coercedQuery
|
|
436
|
+
// from the raw wire). Don't route it through the scalar-repeat rejection below.
|
|
437
|
+
if (!p.schema)
|
|
438
|
+
continue;
|
|
407
439
|
const values = Array.isArray(val) ? val : [val];
|
|
408
440
|
const ps = resolveSchema(d, p.schema);
|
|
409
441
|
if (ps?.type === "array") {
|
|
410
442
|
// Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
|
|
411
|
-
// whole array against the schema's array constraints (minItems, items, ...).
|
|
443
|
+
// whole array against the schema's array constraints (minItems, items, ...). The coerced
|
|
444
|
+
// array is what the delegate receives.
|
|
412
445
|
const items = ps.items ? values.map((v) => coerceParam(d, ps.items, v)) : values;
|
|
446
|
+
coercedQuery[p.name] = items;
|
|
413
447
|
issues.push(...validateValue(d, ps, items, `query/${p.name}`));
|
|
414
448
|
}
|
|
449
|
+
else if (values.length > 1) {
|
|
450
|
+
// A scalar (non-array) param that's repeated (?x=a&x=b) can't be forwarded as the
|
|
451
|
+
// single schema-typed scalar the delegate's generated type expects — reject it (400)
|
|
452
|
+
// rather than hand the delegate an array the type says is impossible.
|
|
453
|
+
issues.push({ path: `query/${p.name}`, message: "expected a single value" });
|
|
454
|
+
}
|
|
415
455
|
else {
|
|
416
|
-
// Scalar param:
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, v), at));
|
|
421
|
-
});
|
|
456
|
+
// Scalar param: coerce the single value, forward it, and validate it.
|
|
457
|
+
const coerced = coerceParam(d, p.schema, values[0]);
|
|
458
|
+
coercedQuery[p.name] = coerced;
|
|
459
|
+
issues.push(...validateValue(d, p.schema, coerced, `query/${p.name}`));
|
|
422
460
|
}
|
|
423
461
|
}
|
|
424
462
|
}
|
|
@@ -470,14 +508,21 @@ export function mountApi(ctx, app) {
|
|
|
470
508
|
// a body-less type.
|
|
471
509
|
const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
|
|
472
510
|
try {
|
|
473
|
-
const result = await handler({ req, params, query, body: handlerBody }, app);
|
|
511
|
+
const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody }, app);
|
|
474
512
|
if (!result)
|
|
475
513
|
return { status: 204 };
|
|
514
|
+
const status = result.status ?? 200;
|
|
476
515
|
// Optional response validation (dev by default): warn-only, never blocks the response.
|
|
477
516
|
const mode = binding.validateResponses ?? "dev";
|
|
478
517
|
const doValidate = mode === "always" || (mode === "dev" && ctx.host.env("NODE_ENV") !== "production");
|
|
479
|
-
|
|
480
|
-
|
|
518
|
+
// Validate against the response schema for THIS status — exact status, else a matching status
|
|
519
|
+
// range ("2XX"), else the "default" entry — so a documented error body (e.g. a 400 `{ error }`)
|
|
520
|
+
// is checked against its own schema, not spuriously against the success response. A
|
|
521
|
+
// documented-but-bodyless status carries no schema, so it (like a status with no `default`
|
|
522
|
+
// fallback) is left unvalidated.
|
|
523
|
+
const responseSchema = responseSchemaForStatus(op.responseSchemas, status);
|
|
524
|
+
if (doValidate && responseSchema && result.body !== undefined) {
|
|
525
|
+
const rIssues = validateValue(d, responseSchema, result.body, "response");
|
|
481
526
|
if (rIssues.length > 0) {
|
|
482
527
|
ctx.host.log("warn", "operation response failed schema validation (ADR 0058)", {
|
|
483
528
|
operationId: op.operationId,
|
|
@@ -486,7 +531,7 @@ export function mountApi(ctx, app) {
|
|
|
486
531
|
}
|
|
487
532
|
}
|
|
488
533
|
return {
|
|
489
|
-
status
|
|
534
|
+
status,
|
|
490
535
|
headers: { "content-type": "application/json", ...(result.headers ?? {}) },
|
|
491
536
|
body: result.body === undefined ? undefined : JSON.stringify(result.body),
|
|
492
537
|
};
|
|
@@ -12,8 +12,10 @@ export { DEFAULT_OPERATIONS_DIR };
|
|
|
12
12
|
/** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
|
|
13
13
|
* their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
|
|
14
14
|
export declare function schemaToTs(schema: OpenApiSchema | undefined, depth?: number): string;
|
|
15
|
-
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
16
|
-
|
|
15
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
16
|
+
* `surfaceEject` reflects the manifest's whole-surface `api.eject` (which the deriver otherwise
|
|
17
|
+
* can't see): when set, EVERY operation is treated as ejected (raw params/query, `unknown` body). */
|
|
18
|
+
export declare function emitApiBindings(doc: OpenApiDoc, surfaceEject?: boolean): string;
|
|
17
19
|
/** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
|
|
18
20
|
* generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
|
|
19
21
|
* re-types the runtime's `defineOperation`. */
|
|
@@ -34,9 +36,12 @@ export declare function emitApiBindingsRuntime(): string;
|
|
|
34
36
|
*/
|
|
35
37
|
export declare function emitApiController(doc: OpenApiDoc, operationsDir?: string): string;
|
|
36
38
|
/** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
|
|
37
|
-
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
38
|
-
|
|
39
|
+
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
40
|
+
* `surfaceEject` is the manifest's whole-surface `api.eject`: when set, every operation is typed as
|
|
41
|
+
* ejected (raw params/query, `unknown` body) so the generated types match the runtime. */
|
|
42
|
+
export declare function deriveApi(doc: OpenApiDoc, operationsDir?: string, surfaceEject?: boolean): DerivedArtifact[];
|
|
39
43
|
export declare const apiDeriver: Deriver<{
|
|
40
44
|
doc: OpenApiDoc;
|
|
41
45
|
operationsDir?: string;
|
|
46
|
+
surfaceEject?: boolean;
|
|
42
47
|
}>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
|
|
2
2
|
// `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
|
|
3
3
|
// half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
|
|
4
|
-
// requestBody, and
|
|
4
|
+
// requestBody, and response (a union of every documented JSON response body), plus an
|
|
5
|
+
// `ApiOperationId` union — mirroring how
|
|
5
6
|
// `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
|
|
6
7
|
// (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
|
|
7
8
|
// the shared `openapi/spec.ts`, so types and runtime never drift.
|
|
@@ -102,23 +103,34 @@ function objectToTs(schema, depth) {
|
|
|
102
103
|
// No declared properties and additionalProperties: false → a closed, empty object.
|
|
103
104
|
return "Record<string, never>";
|
|
104
105
|
}
|
|
105
|
-
/** The `params`/`query` object type for an operation, from its parameters
|
|
106
|
-
|
|
106
|
+
/** The `params`/`query` object type for an operation, from its parameters. Values are coerced to
|
|
107
|
+
* their declared schema type before the delegate receives them (the runtime coerces + validates,
|
|
108
|
+
* then forwards the coerced value), so a param's type is its schema type — not the raw wire string.
|
|
109
|
+
* A parameter with no schema, or ANY parameter of an ejected operation (whose runtime skips
|
|
110
|
+
* coercion/validation and hands the delegate the raw request), keeps the raw wire type (`string`,
|
|
111
|
+
* or `string | string[]` for query). Ejected query params are also made optional: the runtime does
|
|
112
|
+
* not enforce presence for ejected ops, so a "required" query param may in fact be absent. */
|
|
113
|
+
function paramsTs(op, where, ejected) {
|
|
107
114
|
const ps = op.parameters.filter((p) => p.in === where);
|
|
108
115
|
if (ps.length === 0)
|
|
109
116
|
return "{}";
|
|
117
|
+
const rawWire = where === "query" ? "string | string[]" : "string";
|
|
110
118
|
const fields = ps.map((p) => {
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
119
|
+
// Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
|
|
120
|
+
// modifier — don't fold `undefined` into the value type, or required params would still accept
|
|
121
|
+
// undefined. Schemaless params, and every param of an ejected op, fall back to the raw wire type.
|
|
122
|
+
// A required query param of an ejected op is nonetheless optional here (runtime skips presence);
|
|
123
|
+
// path params stay as declared (the route only matches when every path segment is present).
|
|
124
|
+
const opt = ejected && where === "query" ? "?" : p.required ? "" : "?";
|
|
125
|
+
const t = ejected || !p.schema ? rawWire : schemaToTs(p.schema);
|
|
116
126
|
return `${JSON.stringify(p.name)}${opt}: ${t}`;
|
|
117
127
|
});
|
|
118
128
|
return `{ ${fields.join("; ")} }`;
|
|
119
129
|
}
|
|
120
|
-
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
121
|
-
|
|
130
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
131
|
+
* `surfaceEject` reflects the manifest's whole-surface `api.eject` (which the deriver otherwise
|
|
132
|
+
* can't see): when set, EVERY operation is treated as ejected (raw params/query, `unknown` body). */
|
|
133
|
+
export function emitApiBindings(doc, surfaceEject = false) {
|
|
122
134
|
const ops = collectOperations(doc);
|
|
123
135
|
const components = doc.components?.schemas ?? {};
|
|
124
136
|
// Fail-closed on TypeScript name collisions: typeStem is lossy (non-alphanumerics collapse), so
|
|
@@ -134,6 +146,11 @@ export function emitApiBindings(doc) {
|
|
|
134
146
|
}
|
|
135
147
|
emittedTypeNames.set(typeName, source);
|
|
136
148
|
};
|
|
149
|
+
// Reserve the fixed generated declarations (the operation-id union + operations map) up front, so
|
|
150
|
+
// a component or operationId whose stem collapses onto one of them fails the drift gate with a
|
|
151
|
+
// clear rename message instead of silently emitting a duplicate, uncompilable declaration.
|
|
152
|
+
claimTypeName("ApiOperationId", "the generated operation-id union");
|
|
153
|
+
claimTypeName("ApiOperations", "the generated operations map");
|
|
137
154
|
for (const name of Object.keys(components)) {
|
|
138
155
|
claimTypeName(typeStem(name), `component schema "${name}"`);
|
|
139
156
|
}
|
|
@@ -142,8 +159,9 @@ export function emitApiBindings(doc) {
|
|
|
142
159
|
claimTypeName(`${typeStem(op.operationId)}Response`, `operationId "${op.operationId}"`);
|
|
143
160
|
}
|
|
144
161
|
const header = "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
|
|
145
|
-
"// The endpoint type map: each operationId maps to its typed request (path/query params
|
|
146
|
-
"// JSON body) and
|
|
162
|
+
"// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
|
|
163
|
+
"// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
|
|
164
|
+
"// spec. Do not edit — regenerated from\n" +
|
|
147
165
|
"// the spec; erased to plain JS at compile.\n" +
|
|
148
166
|
"// eslint-disable\n\n";
|
|
149
167
|
const componentDecls = Object.entries(components)
|
|
@@ -153,21 +171,37 @@ export function emitApiBindings(doc) {
|
|
|
153
171
|
const opDecls = ops
|
|
154
172
|
.map((op) => {
|
|
155
173
|
const stem = typeStem(op.operationId);
|
|
174
|
+
const ejected = surfaceEject || op.eject;
|
|
175
|
+
// An ejected operation (`x-urban-eject`, or the whole surface via manifest `api.eject`)
|
|
176
|
+
// bypasses the generated coercion/validation — its delegate reads the raw request — so its
|
|
177
|
+
// input is NOT schema-shaped at runtime: params/query stay raw wire types and the body is
|
|
178
|
+
// unvalidated (`unknown`). Typing it by the schema would claim guarantees the runtime doesn't
|
|
179
|
+
// make. (ADR 0059 documents the ejection exception.)
|
|
156
180
|
// With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
|
|
157
181
|
// required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
|
|
158
|
-
const bodyType =
|
|
159
|
-
?
|
|
160
|
-
: op.
|
|
161
|
-
?
|
|
162
|
-
:
|
|
182
|
+
const bodyType = ejected
|
|
183
|
+
? "unknown"
|
|
184
|
+
: op.requestBodySchema
|
|
185
|
+
? schemaToTs(op.requestBodySchema)
|
|
186
|
+
: op.requestBodyRequired
|
|
187
|
+
? "unknown"
|
|
188
|
+
: "undefined";
|
|
163
189
|
// An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
|
|
164
190
|
// so mark it optional in the type rather than forcing handlers to treat it as always-present.
|
|
165
191
|
const bodyOpt = op.requestBodyRequired ? "" : "?";
|
|
166
|
-
|
|
192
|
+
// Response type: a union of every documented JSON response body (success + errors), so a
|
|
193
|
+
// handler that returns a documented error body typechecks against its operation's response
|
|
194
|
+
// type. Bodyless documented statuses carry no schema and are skipped here. Deduped (identical
|
|
195
|
+
// schemas collapse), stable order. No documented JSON body → unknown.
|
|
196
|
+
const respSeen = new Set();
|
|
197
|
+
const respParts = op.responseSchemas
|
|
198
|
+
.flatMap((e) => (e.schema ? [schemaToTs(e.schema)] : []))
|
|
199
|
+
.filter((t) => (respSeen.has(t) ? false : (respSeen.add(t), true)));
|
|
200
|
+
const resp = respParts.length > 0 ? respParts.join(" | ") : "unknown";
|
|
167
201
|
const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
|
|
168
202
|
return (`${summary}export interface ${stem}Request {\n` +
|
|
169
|
-
` params: ${paramsTs(op, "path")};\n` +
|
|
170
|
-
` query: ${paramsTs(op, "query")};\n` +
|
|
203
|
+
` params: ${paramsTs(op, "path", ejected)};\n` +
|
|
204
|
+
` query: ${paramsTs(op, "query", ejected)};\n` +
|
|
171
205
|
` body${bodyOpt}: ${bodyType};\n` +
|
|
172
206
|
`}\n` +
|
|
173
207
|
`export type ${stem}Response = ${resp};`);
|
|
@@ -264,10 +298,12 @@ export function emitApiController(doc, operationsDir = DEFAULT_OPERATIONS_DIR) {
|
|
|
264
298
|
`};\n`);
|
|
265
299
|
}
|
|
266
300
|
/** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
|
|
267
|
-
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
268
|
-
|
|
301
|
+
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
302
|
+
* `surfaceEject` is the manifest's whole-surface `api.eject`: when set, every operation is typed as
|
|
303
|
+
* ejected (raw params/query, `unknown` body) so the generated types match the runtime. */
|
|
304
|
+
export function deriveApi(doc, operationsDir = DEFAULT_OPERATIONS_DIR, surfaceEject = false) {
|
|
269
305
|
return [
|
|
270
|
-
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
|
|
306
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc, surfaceEject) },
|
|
271
307
|
{ path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
|
|
272
308
|
{ path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
|
|
273
309
|
];
|
|
@@ -275,5 +311,5 @@ export function deriveApi(doc, operationsDir = DEFAULT_OPERATIONS_DIR) {
|
|
|
275
311
|
export const apiDeriver = {
|
|
276
312
|
id: "openapi->api-io",
|
|
277
313
|
describe: "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
|
|
278
|
-
derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
|
|
314
|
+
derive: ({ doc, operationsDir, surfaceEject }) => deriveApi(doc, operationsDir, surfaceEject),
|
|
279
315
|
};
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -101,7 +101,7 @@ async function collectAll(opts) {
|
|
|
101
101
|
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
102
102
|
? manifest.api.dir.trim()
|
|
103
103
|
: undefined;
|
|
104
|
-
artifacts.push(...deriveApi(parseSpec(specText), opsDir));
|
|
104
|
+
artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
|
|
105
105
|
}
|
|
106
106
|
return { artifacts: sortArtifacts(artifacts), derived };
|
|
107
107
|
}
|
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
|
|
|
27
27
|
}>, import("./artifact.ts").Deriver<{
|
|
28
28
|
doc: import("../openapi/spec.ts").OpenApiDoc;
|
|
29
29
|
operationsDir?: string;
|
|
30
|
+
surfaceEject?: boolean;
|
|
30
31
|
}>];
|
|
31
32
|
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
|
|
32
33
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
@@ -26,7 +26,7 @@ export interface WorkerStubPlan {
|
|
|
26
26
|
stub: string;
|
|
27
27
|
manifestEntry: StubManifestEntry;
|
|
28
28
|
}
|
|
29
|
-
export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
|
|
29
|
+
export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external";
|
|
30
30
|
export interface SkippedWorker {
|
|
31
31
|
taskType: string;
|
|
32
32
|
reason: SkipReason;
|
|
@@ -41,8 +41,10 @@ export declare function slugifyTaskType(taskType: string): string;
|
|
|
41
41
|
export declare function renderWorkerStub(taskType: string, typedIn: boolean, typedOut: boolean): string;
|
|
42
42
|
/**
|
|
43
43
|
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
44
|
-
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator,
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
|
|
45
|
+
* declared external (serviced by an out-of-process worker), and not a duplicate of one already
|
|
46
|
+
* planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
|
|
47
|
+
* when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
|
|
48
|
+
* declared external task types, which the app owns the model for but deliberately does not host.
|
|
47
49
|
*/
|
|
48
|
-
export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>): WorkerScaffoldPlan;
|
|
50
|
+
export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>, externalTaskTypes?: Iterable<string>): WorkerScaffoldPlan;
|
|
@@ -57,15 +57,18 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
|
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
59
|
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
60
|
-
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator,
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
|
|
61
|
+
* declared external (serviced by an out-of-process worker), and not a duplicate of one already
|
|
62
|
+
* planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
|
|
63
|
+
* when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
|
|
64
|
+
* declared external task types, which the app owns the model for but deliberately does not host.
|
|
63
65
|
*/
|
|
64
|
-
export function planWorkerScaffold(models, workers = [], declaredTypeIds = []) {
|
|
66
|
+
export function planWorkerScaffold(models, workers = [], declaredTypeIds = [], externalTaskTypes = []) {
|
|
65
67
|
const wired = new Set(workers
|
|
66
68
|
.map((w) => w?.taskType)
|
|
67
69
|
.filter((t) => typeof t === "string" && t.length > 0));
|
|
68
70
|
const declared = new Set(declaredTypeIds);
|
|
71
|
+
const external = new Set(externalTaskTypes);
|
|
69
72
|
const plans = [];
|
|
70
73
|
const skipped = [];
|
|
71
74
|
const seenTaskTypes = new Set();
|
|
@@ -77,6 +80,10 @@ export function planWorkerScaffold(models, workers = [], declaredTypeIds = []) {
|
|
|
77
80
|
skipped.push({ taskType, reason: "orchestrator" });
|
|
78
81
|
continue;
|
|
79
82
|
}
|
|
83
|
+
if (external.has(taskType)) {
|
|
84
|
+
skipped.push({ taskType, reason: "external" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
80
87
|
if (wired.has(taskType)) {
|
|
81
88
|
skipped.push({ taskType, reason: "already-wired" });
|
|
82
89
|
continue;
|
package/dist/toolkit/scaffold.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function scaffoldWorkers(opts) {
|
|
|
15
15
|
const manifest = JSON.parse(await io.readText(manifestPath));
|
|
16
16
|
const models = await readModels(root, io, manifest);
|
|
17
17
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
18
|
-
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
|
|
18
|
+
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds, manifest.externalTaskTypes ?? []);
|
|
19
19
|
const outcomes = [];
|
|
20
20
|
const wired = [];
|
|
21
21
|
for (const plan of plans) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"test:deno": "deno test -A --no-check src/"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@nanobpm/nano-app-schema": "^0.
|
|
65
|
+
"@nanobpm/nano-app-schema": "^0.5.0",
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
operationsWithUnsafeId,
|
|
12
12
|
parseSpec,
|
|
13
13
|
resolveSchema,
|
|
14
|
+
responseSchemaForStatus,
|
|
14
15
|
toRouteMatcher,
|
|
15
16
|
undeclaredPathParams,
|
|
16
17
|
undeclaredSecuritySchemes,
|
|
@@ -517,3 +518,46 @@ test("evaluateSecurity: alternative requirements are OR (any one satisfied autho
|
|
|
517
518
|
// Neither presented → 401.
|
|
518
519
|
assert.equal(evaluateSecurity(doc, op, noHeader, noQuery, env).status, 401);
|
|
519
520
|
});
|
|
521
|
+
|
|
522
|
+
test("responseSchemaForStatus: a documented-but-bodyless status suppresses the default fallback", () => {
|
|
523
|
+
const ops = collectOperations({
|
|
524
|
+
openapi: "3.1.0",
|
|
525
|
+
paths: {
|
|
526
|
+
"/x": {
|
|
527
|
+
get: {
|
|
528
|
+
operationId: "getX",
|
|
529
|
+
responses: {
|
|
530
|
+
"204": {}, // documented, no JSON body
|
|
531
|
+
default: { content: { "application/json": { schema: { type: "object" } } } },
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
const entries = ops[0].responseSchemas;
|
|
538
|
+
// 204 IS documented (just bodyless) → no validation, and it must NOT fall through to `default`.
|
|
539
|
+
assert.equal(responseSchemaForStatus(entries, 204), undefined);
|
|
540
|
+
// 500 is undocumented → falls through to `default`.
|
|
541
|
+
assert.equal(responseSchemaForStatus(entries, 500)?.type, "object");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("responseSchemaForStatus: a status-range ('2XX') covers concrete statuses in its class", () => {
|
|
545
|
+
const ops = collectOperations({
|
|
546
|
+
openapi: "3.1.0",
|
|
547
|
+
paths: {
|
|
548
|
+
"/y": {
|
|
549
|
+
get: {
|
|
550
|
+
operationId: "getY",
|
|
551
|
+
responses: {
|
|
552
|
+
"2XX": { content: { "application/json": { schema: { type: "string" } } } },
|
|
553
|
+
default: { content: { "application/json": { schema: { type: "object" } } } },
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
const entries = ops[0].responseSchemas;
|
|
560
|
+
assert.equal(entries.map((e) => e.status).join(","), "2XX,default"); // ranges before default
|
|
561
|
+
assert.equal(responseSchemaForStatus(entries, 200)?.type, "string"); // 2XX class → range schema
|
|
562
|
+
assert.equal(responseSchemaForStatus(entries, 404)?.type, "object"); // outside 2XX → default
|
|
563
|
+
});
|