@nanobpm/urban 0.37.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.
@@ -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
- /** Schema of the first 2xx (else default) JSON response, when declared. */
101
- responseSchema?: OpenApiSchema;
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. */
@@ -139,7 +139,7 @@ export function collectOperations(doc) {
139
139
  parameters,
140
140
  requestBodySchema,
141
141
  requestBodyRequired: requestBody?.required === true,
142
- responseSchema: firstSuccessSchema(responses),
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
- function firstSuccessSchema(responses) {
403
- const codes = Object.keys(responses)
404
- .filter((c) => /^2\d\d$/.test(c))
405
- .sort();
406
- const code = codes[0] ?? (isRecord(responses.default) ? "default" : undefined);
407
- if (!code)
408
- return undefined;
409
- const resp = responses[code];
410
- if (!isRecord(resp))
411
- return undefined;
412
- return firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
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 (strings on the wire; unknown JSON body). `body` is
26
- * optional so a raw (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the
27
- * generated registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body
28
- * is optional/absent must be a subtype of the default contract (handler args are contravariant). */
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, string>;
31
- query: Record<string, string | string[] | undefined>;
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. A value that will not coerce is left as-is and fails the type check with a clear
144
- * message. The delegate still receives the original string(s). */
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
- if (s.type === "integer" || s.type === "number") {
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 (s.type === "boolean") {
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
- issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, val), `params/${p.name}`));
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: validate EVERY provided value, not just the first, so extra repeated
417
- // values can't bypass validation.
418
- values.forEach((v, i) => {
419
- const at = values.length > 1 ? `query/${p.name}[${i}]` : `query/${p.name}`;
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
- if (doValidate && op.responseSchema && result.body !== undefined) {
480
- const rIssues = validateValue(d, op.responseSchema, result.body, "response");
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: result.status ?? 200,
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
- export declare function emitApiBindings(doc: OpenApiDoc): string;
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
- export declare function deriveApi(doc: OpenApiDoc, operationsDir?: string): DerivedArtifact[];
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 first success response, plus an `ApiOperationId` union — mirroring how
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 (strings on the wire). */
106
- function paramsTs(op, where) {
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
- const opt = p.required ? "" : "?";
112
- // Query values arrive as strings (or string[] for repeated keys); path params are single
113
- // strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
114
- // value type, or required query params would still accept undefined.
115
- const t = where === "query" ? "string | string[]" : "string";
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
- export function emitApiBindings(doc) {
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 +\n" +
146
- "// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
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 = op.requestBodySchema
159
- ? schemaToTs(op.requestBodySchema)
160
- : op.requestBodyRequired
161
- ? "unknown"
162
- : "undefined";
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
- const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
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
- export function deriveApi(doc, operationsDir = DEFAULT_OPERATIONS_DIR) {
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
  };
@@ -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
  }
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.37.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",
@@ -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
+ });