@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.
@@ -122,6 +122,15 @@ export interface OpenApiDoc {
122
122
  export const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"] as const;
123
123
  export type HttpMethodLower = (typeof HTTP_METHODS)[number];
124
124
 
125
+ /** A single documented response: its status code ("200"), status range ("2XX"), or "default", and
126
+ * its JSON body schema when it documents one. `schema` is absent when the response is documented
127
+ * but carries no JSON body — recorded so an exact-status match can suppress the `default` fallback
128
+ * rather than mis-validating a bodyless status against the default (typically error) schema. */
129
+ export interface ResponseSchemaEntry {
130
+ status: string;
131
+ schema?: OpenApiSchema;
132
+ }
133
+
125
134
  /** One resolved operation: its id, method, path, parameters, request body schema, and responses.
126
135
  * This is the shared unit both the deriver (→ types) and the runtime (→ routes) consume. */
127
136
  export interface OperationInfo {
@@ -132,8 +141,10 @@ export interface OperationInfo {
132
141
  parameters: OpenApiParameter[];
133
142
  requestBodySchema?: OpenApiSchema;
134
143
  requestBodyRequired: boolean;
135
- /** Schema of the first 2xx (else default) JSON response, when declared. */
136
- responseSchema?: OpenApiSchema;
144
+ /** Every documented JSON response, keyed by status code ("200","400",…,"default"), in a stable
145
+ * order. The type layer unions these into the operation's response type; the runtime validates a
146
+ * handler result against the entry matching its status (else "default"). */
147
+ responseSchemas: ResponseSchemaEntry[];
137
148
  /** Effective security requirements (op-level if declared — including an explicit empty list —
138
149
  * else the document-level default, else `[]`). An empty list means the operation is open. */
139
150
  security: OpenApiSecurityRequirement[];
@@ -255,7 +266,7 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
255
266
  parameters,
256
267
  requestBodySchema,
257
268
  requestBodyRequired: requestBody?.required === true,
258
- responseSchema: firstSuccessSchema(responses),
269
+ responseSchemas: collectResponseSchemas(responses),
259
270
  security,
260
271
  eject: opRaw["x-urban-eject"] === true,
261
272
  summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
@@ -533,15 +544,41 @@ function normalizeParameters(raw: unknown[]): OpenApiParameter[] {
533
544
  return order.map((k) => byKey.get(k)!);
534
545
  }
535
546
 
536
- function firstSuccessSchema(responses: Record<string, unknown>): OpenApiSchema | undefined {
537
- const codes = Object.keys(responses)
538
- .filter((c) => /^2\d\d$/.test(c))
539
- .sort();
540
- const code = codes[0] ?? (isRecord(responses.default) ? "default" : undefined);
541
- if (!code) return undefined;
542
- const resp = responses[code];
543
- if (!isRecord(resp)) return undefined;
544
- return firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
547
+ /** Every documented response, in a stable order: numeric status codes ascending, then status
548
+ * ranges ("1XX".."5XX"), then "default" last. A response with no JSON body schema is still
549
+ * recorded (with `schema` undefined) so an exact-status match suppresses the `default` fallback
550
+ * instead of the bodyless status being mis-validated against the default (error) schema. */
551
+ function collectResponseSchemas(responses: Record<string, unknown>): ResponseSchemaEntry[] {
552
+ const isExact = (c: string) => /^\d{3}$/.test(c);
553
+ const isRange = (c: string) => /^[1-5]XX$/i.test(c);
554
+ const keys = Object.keys(responses).filter((c) => isExact(c) || isRange(c) || c === "default");
555
+ const rank = (c: string) => (c === "default" ? 2 : isRange(c) ? 1 : 0);
556
+ keys.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
557
+ const out: ResponseSchemaEntry[] = [];
558
+ for (const key of keys) {
559
+ const resp = responses[key];
560
+ if (!isRecord(resp)) continue;
561
+ const status = isRange(key) ? key.toUpperCase() : key;
562
+ const schema = firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
563
+ out.push(schema ? { status, schema } : { status });
564
+ }
565
+ return out;
566
+ }
567
+
568
+ /** Select the response schema to validate a handler result against: exact status → status range
569
+ * ("2XX") → "default". A documented-but-bodyless exact status (or range) returns undefined (no
570
+ * validation) and does NOT fall through to `default` — the status IS documented, it just carries no
571
+ * JSON body, so validating it against an unrelated (e.g. error `default`) schema would be wrong. An
572
+ * UNdocumented status falls back to the `default` entry when present, else undefined. */
573
+ export function responseSchemaForStatus(
574
+ entries: ResponseSchemaEntry[],
575
+ status: number,
576
+ ): OpenApiSchema | undefined {
577
+ const exact = entries.find((e) => e.status === String(status));
578
+ if (exact) return exact.schema;
579
+ const range = entries.find((e) => e.status === `${Math.floor(status / 100)}XX`);
580
+ if (range) return range.schema;
581
+ return entries.find((e) => e.status === "default")?.schema;
545
582
  }
546
583
 
547
584
  /** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
@@ -317,10 +317,46 @@ test("repeated query keys are all validated (extra values don't bypass validatio
317
317
  { "/app/operations/listItems": { default: () => ({ body: { ok: true } }) } },
318
318
  arraySpec,
319
319
  );
320
- // First value valid, second (0) violates minimum:1 — must still be caught.
320
+ // A repeated scalar (non-array) param can't be forwarded as the single schema-typed scalar the
321
+ // delegate expects, so it's rejected outright (400) — extra values never reach the delegate.
321
322
  const res = await router(req("GET", "/app/api/items", { query: "n=5&n=0" }));
322
323
  assert.equal(res.status, 400);
323
324
  assert.match(JSON.parse(res.body!).error, /validation failed/);
325
+ assert.match(JSON.stringify(JSON.parse(res.body!).issues), /expected a single value/);
326
+ });
327
+
328
+ test("a declared but schemaless query param preserves raw wire semantics (repeats allowed)", async () => {
329
+ const schemalessSpec = JSON.stringify({
330
+ openapi: "3.0.0",
331
+ paths: {
332
+ "/items": {
333
+ get: {
334
+ operationId: "listItems",
335
+ parameters: [{ name: "x", in: "query" }], // no schema → raw wire `string | string[]`
336
+ responses: { "200": {} },
337
+ },
338
+ },
339
+ },
340
+ });
341
+ let got: unknown;
342
+ const { router } = build(
343
+ { spec: "openapi.json" },
344
+ { "/app/operations/listItems": { default: (i: { query: Record<string, unknown> }) => { got = i.query.x; return { body: { ok: true } }; } } },
345
+ schemalessSpec,
346
+ );
347
+ // Repeated key must NOT be rejected as a scalar-repeat — it's forwarded as the raw string[].
348
+ const res = await router(req("GET", "/app/api/items", { query: "x=a&x=b" }));
349
+ assert.equal(res.status, 200);
350
+ assert.deepEqual(got, ["a", "b"]);
351
+ // A single value stays the raw string.
352
+ let got1: unknown;
353
+ const b2 = build(
354
+ { spec: "openapi.json" },
355
+ { "/app/operations/listItems": { default: (i: { query: Record<string, unknown> }) => { got1 = i.query.x; return { body: { ok: true } }; } } },
356
+ schemalessSpec,
357
+ );
358
+ await b2.router(req("GET", "/app/api/items", { query: "x=solo" }));
359
+ assert.equal(got1, "solo");
324
360
  });
325
361
 
326
362
  test("array-typed query params validate the whole array (items + array constraints)", async () => {
@@ -349,6 +385,120 @@ test("array-typed query params validate the whole array (items + array constrain
349
385
  assert.equal((await build2("tag=a&tag=c")).status, 400); // "c" not in enum
350
386
  });
351
387
 
388
+ test("params/query are coerced to their schema type before reaching the delegate", async () => {
389
+ const coerceSpec = JSON.stringify({
390
+ openapi: "3.0.0",
391
+ paths: {
392
+ "/things/{id}": {
393
+ get: {
394
+ operationId: "getThing",
395
+ parameters: [
396
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
397
+ { name: "verbose", in: "query", schema: { type: "boolean" } },
398
+ { name: "tags", in: "query", schema: { type: "array", items: { type: "string" } } },
399
+ ],
400
+ responses: { "200": {} },
401
+ },
402
+ },
403
+ },
404
+ });
405
+ let got: { params: Record<string, unknown>; query: Record<string, unknown> } | undefined;
406
+ const { router } = build(
407
+ { spec: "openapi.json" },
408
+ {
409
+ "/app/operations/getThing": {
410
+ default: (i: { params: Record<string, unknown>; query: Record<string, unknown> }) => {
411
+ got = { params: i.params, query: i.query };
412
+ return { body: { ok: true } };
413
+ },
414
+ },
415
+ },
416
+ coerceSpec,
417
+ );
418
+ const res = await router(req("GET", "/app/api/things/42", { query: "verbose=true&tags=x&tags=y" }));
419
+ assert.equal(res.status, 200);
420
+ // integer path param → number, boolean query → boolean, array query → string[] (all coerced).
421
+ assert.deepEqual(got, { params: { id: 42 }, query: { verbose: true, tags: ["x", "y"] } });
422
+ });
423
+
424
+ test("a typeless numeric const/enum query param coerces (so its number-literal type is satisfiable)", async () => {
425
+ const enumSpec = JSON.stringify({
426
+ openapi: "3.1.0",
427
+ paths: {
428
+ "/pick": {
429
+ get: {
430
+ operationId: "pick",
431
+ parameters: [
432
+ { name: "v", in: "query", required: true, schema: { enum: [1, 2, 3] } }, // no `type`
433
+ { name: "k", in: "query", required: true, schema: { const: 7 } }, // no `type`
434
+ ],
435
+ responses: { "200": {} },
436
+ },
437
+ },
438
+ },
439
+ });
440
+ let got: { query: Record<string, unknown> } | undefined;
441
+ const { router } = build(
442
+ { spec: "openapi.json" },
443
+ {
444
+ "/app/operations/pick": {
445
+ default: (i: { query: Record<string, unknown> }) => {
446
+ got = { query: i.query };
447
+ return { body: { ok: true } };
448
+ },
449
+ },
450
+ },
451
+ enumSpec,
452
+ );
453
+ // A valid enum/const value coerces to a number and passes validation (would 400 if left a string).
454
+ const ok = await router(req("GET", "/app/api/pick", { query: "v=2&k=7" }));
455
+ assert.equal(ok.status, 200);
456
+ assert.deepEqual(got, { query: { v: 2, k: 7 } });
457
+ // An out-of-set value still fails validation (coercion doesn't loosen the constraint).
458
+ const bad = await router(req("GET", "/app/api/pick", { query: "v=9&k=7" }));
459
+ assert.equal(bad.status, 400);
460
+ });
461
+
462
+ test("response validation is status-keyed: a documented error body validates against ITS schema", async () => {
463
+ const respSpec = JSON.stringify({
464
+ openapi: "3.0.0",
465
+ components: {
466
+ schemas: {
467
+ Ok: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false },
468
+ Err: { type: "object", properties: { error: { type: "string" } }, required: ["error"], additionalProperties: false },
469
+ },
470
+ },
471
+ paths: {
472
+ "/do": {
473
+ post: {
474
+ operationId: "doIt",
475
+ responses: {
476
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Ok" } } } },
477
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
478
+ },
479
+ },
480
+ },
481
+ },
482
+ });
483
+ const runWith = (result: { status: number; body: unknown }) => {
484
+ const b = build(
485
+ { spec: "openapi.json", validateResponses: "always" },
486
+ { "/app/operations/doIt": { default: () => result } },
487
+ respSpec,
488
+ );
489
+ return { call: b.router(req("POST", "/app/api/do", { body: "{}" })), logs: b.logs };
490
+ };
491
+ // A valid documented 400 error body must NOT warn — the pre-fix code validated it against the 200
492
+ // success schema and warned. Now it's checked against the 400 schema and passes.
493
+ const okErr = runWith({ status: 400, body: { error: "bad ref" } });
494
+ await okErr.call;
495
+ assert.equal(okErr.logs.some((l) => l.msg.includes("response failed schema validation")), false);
496
+ // A body that violates the 400 schema DOES warn (proves it's validated against Err, not Ok).
497
+ const badErr = runWith({ status: 400, body: { oops: 1 } });
498
+ await badErr.call;
499
+ assert.equal(badErr.logs.some((l) => l.msg.includes("response failed schema validation")), true);
500
+ });
501
+
352
502
  test("a failed initial spec load is retried, not cached as a permanent 500", async () => {
353
503
  let attempts = 0;
354
504
  const host: HostContext = {
@@ -28,6 +28,7 @@ import {
28
28
  operationsWithUnsafeId,
29
29
  parseSpec,
30
30
  resolveSchema,
31
+ responseSchemaForStatus,
31
32
  toRouteMatcher,
32
33
  undeclaredPathParams,
33
34
  undeclaredSecuritySchemes,
@@ -102,13 +103,17 @@ export interface OperationContract {
102
103
  body?: unknown;
103
104
  }
104
105
 
105
- /** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). `body` is
106
- * optional so a raw (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the
107
- * generated registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body
108
- * is optional/absent must be a subtype of the default contract (handler args are contravariant). */
106
+ /** The loose contract an untyped delegate sees. Params/query values are `unknown` (coerced to their
107
+ * declared schema type before the delegate runs — the runtime validates then forwards the coerced
108
+ * value — but a schemaless/undeclared key, or any param of an ejected op, is forwarded raw, hence
109
+ * `unknown` rather than the coerced type); the JSON body is `unknown`. `body` is optional so a raw
110
+ * (untyped) delegate typed `OperationHandler<DefaultContract>` still satisfies the generated
111
+ * registry's per-operation `OperationHandler<ReqFor<K>>` slot — an op whose request body is
112
+ * optional/absent must be a subtype of the default contract (handler args are contravariant), and a
113
+ * schema-typed param object (e.g. `{ count: number }`) must be assignable to it. */
109
114
  export interface DefaultContract {
110
- params: Record<string, string>;
111
- query: Record<string, string | string[] | undefined>;
115
+ params: Record<string, unknown>;
116
+ query: Record<string, unknown>;
112
117
  body?: unknown;
113
118
  }
114
119
 
@@ -239,16 +244,29 @@ export function apiDocsPath(manifest: unknown): string | undefined {
239
244
  }
240
245
 
241
246
  /** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
242
- * validate. A value that will not coerce is left as-is and fails the type check with a clear
243
- * message. The delegate still receives the original string(s). */
247
+ * validate and the delegate receives the coerced value (not the raw string). A value that will not
248
+ * coerce is left as-is and fails the type check with a clear message. */
244
249
  function coerceParam(doc: OpenApiDoc, schema: OpenApiSchema | undefined, raw: string): unknown {
245
250
  const s = resolveSchema(doc, schema);
246
251
  if (!s) return raw;
247
- if (s.type === "integer" || s.type === "number") {
252
+ // Effective scalar type: the explicit `type`, else inferred from a typeless `const`/`enum` whose
253
+ // values are all numbers or all booleans. Without this, a typeless numeric/boolean const/enum
254
+ // param (whose generated type is a number/boolean literal) could never be satisfied by the wire
255
+ // string — every request would 400 against a type the runtime otherwise never produces.
256
+ let t = s.type;
257
+ if (t === undefined) {
258
+ if (typeof s.const === "number") t = "number";
259
+ else if (typeof s.const === "boolean") t = "boolean";
260
+ else if (Array.isArray(s.enum) && s.enum.length > 0) {
261
+ if (s.enum.every((v) => typeof v === "number")) t = "number";
262
+ else if (s.enum.every((v) => typeof v === "boolean")) t = "boolean";
263
+ }
264
+ }
265
+ if (t === "integer" || t === "number") {
248
266
  const n = Number(raw);
249
267
  return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
250
268
  }
251
- if (s.type === "boolean") {
269
+ if (t === "boolean") {
252
270
  if (raw === "true") return true;
253
271
  if (raw === "false") return false;
254
272
  }
@@ -501,6 +519,11 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
501
519
  const all = req.query.getAll(key);
502
520
  query[key] = all.length > 1 ? all : all[0];
503
521
  }
522
+ // What the delegate actually receives: params/query coerced to their declared schema type. Seed
523
+ // from the raw wire values (used verbatim for ejected ops and for undeclared/schemaless keys);
524
+ // the validation loop below overwrites each declared parameter with its coerced value.
525
+ const coercedParams: Record<string, unknown> = { ...params };
526
+ const coercedQuery: Record<string, unknown> = { ...query };
504
527
 
505
528
  // Parse the JSON body (empty body → undefined). Invalid JSON is a 400 unless the op is ejected
506
529
  // (then the delegate reads the raw request itself).
@@ -530,27 +553,41 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
530
553
  if (p.required) issues.push({ path: `params/${p.name}`, message: "is required" });
531
554
  continue;
532
555
  }
533
- issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, val), `params/${p.name}`));
556
+ // Coerce once, forward the coerced value to the delegate (so a `count` param is a number
557
+ // in the delegate's typed input, matching the generated schema type), and validate it.
558
+ const coerced = coerceParam(d, p.schema, val);
559
+ coercedParams[p.name] = coerced;
560
+ issues.push(...validateValue(d, p.schema, coerced, `params/${p.name}`));
534
561
  } else if (p.in === "query") {
535
562
  const val = query[p.name];
536
563
  if (val === undefined) {
537
564
  if (p.required) issues.push({ path: `query/${p.name}`, message: "is required" });
538
565
  continue;
539
566
  }
567
+ // A declared but schemaless query param has no shape to coerce/validate against — its
568
+ // generated type is the raw wire `string | string[]`, so preserve raw semantics
569
+ // (including repeated keys) and forward the value verbatim (already seeded in coercedQuery
570
+ // from the raw wire). Don't route it through the scalar-repeat rejection below.
571
+ if (!p.schema) continue;
540
572
  const values = Array.isArray(val) ? val : [val];
541
573
  const ps = resolveSchema(d, p.schema);
542
574
  if (ps?.type === "array") {
543
575
  // Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
544
- // whole array against the schema's array constraints (minItems, items, ...).
576
+ // whole array against the schema's array constraints (minItems, items, ...). The coerced
577
+ // array is what the delegate receives.
545
578
  const items = ps.items ? values.map((v) => coerceParam(d, ps.items, v)) : values;
579
+ coercedQuery[p.name] = items;
546
580
  issues.push(...validateValue(d, ps, items, `query/${p.name}`));
581
+ } else if (values.length > 1) {
582
+ // A scalar (non-array) param that's repeated (?x=a&x=b) can't be forwarded as the
583
+ // single schema-typed scalar the delegate's generated type expects — reject it (400)
584
+ // rather than hand the delegate an array the type says is impossible.
585
+ issues.push({ path: `query/${p.name}`, message: "expected a single value" });
547
586
  } else {
548
- // Scalar param: validate EVERY provided value, not just the first, so extra repeated
549
- // values can't bypass validation.
550
- values.forEach((v, i) => {
551
- const at = values.length > 1 ? `query/${p.name}[${i}]` : `query/${p.name}`;
552
- issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, v), at));
553
- });
587
+ // Scalar param: coerce the single value, forward it, and validate it.
588
+ const coerced = coerceParam(d, p.schema, values[0]);
589
+ coercedQuery[p.name] = coerced;
590
+ issues.push(...validateValue(d, p.schema, coerced, `query/${p.name}`));
554
591
  }
555
592
  }
556
593
  }
@@ -604,14 +641,21 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
604
641
  // a body-less type.
605
642
  const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
606
643
  try {
607
- const result = await handler({ req, params, query, body: handlerBody }, app);
644
+ const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody }, app);
608
645
  if (!result) return { status: 204 };
646
+ const status = result.status ?? 200;
609
647
  // Optional response validation (dev by default): warn-only, never blocks the response.
610
648
  const mode = binding.validateResponses ?? "dev";
611
649
  const doValidate =
612
650
  mode === "always" || (mode === "dev" && ctx.host.env("NODE_ENV") !== "production");
613
- if (doValidate && op.responseSchema && result.body !== undefined) {
614
- const rIssues = validateValue(d, op.responseSchema, result.body, "response");
651
+ // Validate against the response schema for THIS status — exact status, else a matching status
652
+ // range ("2XX"), else the "default" entry — so a documented error body (e.g. a 400 `{ error }`)
653
+ // is checked against its own schema, not spuriously against the success response. A
654
+ // documented-but-bodyless status carries no schema, so it (like a status with no `default`
655
+ // fallback) is left unvalidated.
656
+ const responseSchema = responseSchemaForStatus(op.responseSchemas, status);
657
+ if (doValidate && responseSchema && result.body !== undefined) {
658
+ const rIssues = validateValue(d, responseSchema, result.body, "response");
615
659
  if (rIssues.length > 0) {
616
660
  ctx.host.log("warn", "operation response failed schema validation (ADR 0058)", {
617
661
  operationId: op.operationId,
@@ -620,7 +664,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
620
664
  }
621
665
  }
622
666
  return {
623
- status: result.status ?? 200,
667
+ status,
624
668
  headers: { "content-type": "application/json", ...(result.headers ?? {}) },
625
669
  body: result.body === undefined ? undefined : JSON.stringify(result.body),
626
670
  };
@@ -139,10 +139,167 @@ test("required query params aren't undefined-widened; optional ones use the ? mo
139
139
  },
140
140
  };
141
141
  const out = emitApiBindings(qdoc);
142
- assert.match(out, /"q": string \| string\[\]/); // required → present, not optional
142
+ assert.match(out, /"q": string/); // required scalar string → present, coerced to its schema type
143
143
  assert.doesNotMatch(out, /"q"\?:/); // not optional
144
- assert.doesNotMatch(out, /"q": string \| string\[\] \| undefined/); // not undefined-widened
145
- assert.match(out, /"page"\?: string \| string\[\]/); // optional → ? modifier
144
+ assert.doesNotMatch(out, /"q": string \| undefined/); // not undefined-widened
145
+ assert.match(out, /"page"\?: string/); // optional → ? modifier
146
+ });
147
+
148
+ test("params are typed by their schema (coerced), not the raw wire string", () => {
149
+ const pdoc: OpenApiDoc = {
150
+ openapi: "3.0.0",
151
+ paths: {
152
+ "/things/{id}": {
153
+ get: {
154
+ operationId: "getThing",
155
+ parameters: [
156
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
157
+ { name: "verbose", in: "query", schema: { type: "boolean" } },
158
+ { name: "tags", in: "query", schema: { type: "array", items: { type: "string" } } },
159
+ { name: "raw", in: "query" }, // no schema → raw wire type
160
+ ],
161
+ responses: { "200": {} },
162
+ },
163
+ },
164
+ },
165
+ };
166
+ const out = emitApiBindings(pdoc);
167
+ assert.match(out, /"id": number/); // integer path param → number, not string
168
+ assert.match(out, /"verbose"\?: boolean/); // boolean query → boolean
169
+ assert.match(out, /"tags"\?: Array<string>/); // array query → Array<item>
170
+ assert.match(out, /"raw"\?: string \| string\[\]/); // schemaless query → raw wire type
171
+ });
172
+
173
+ test("the response type unions every documented JSON response body (success + error)", () => {
174
+ const rdoc: OpenApiDoc = {
175
+ openapi: "3.0.0",
176
+ components: {
177
+ schemas: {
178
+ Ok: { type: "object", properties: { ok: { type: "boolean" } }, additionalProperties: false },
179
+ Err: { type: "object", properties: { error: { type: "string" } }, additionalProperties: false },
180
+ },
181
+ },
182
+ paths: {
183
+ "/do": {
184
+ post: {
185
+ operationId: "doIt",
186
+ responses: {
187
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Ok" } } } },
188
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
189
+ },
190
+ },
191
+ },
192
+ },
193
+ };
194
+ const out = emitApiBindings(rdoc);
195
+ assert.match(out, /export type DoItResponse = Ok \| Err;/); // both bodies in the union
196
+ });
197
+
198
+ test("an ejected operation keeps raw wire param/query types + an unknown body (runtime skips coercion)", () => {
199
+ const edoc: OpenApiDoc = {
200
+ openapi: "3.0.0",
201
+ paths: {
202
+ "/raw/{id}": {
203
+ post: {
204
+ operationId: "rawOp",
205
+ "x-urban-eject": true,
206
+ parameters: [
207
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
208
+ { name: "n", in: "query", schema: { type: "integer" } },
209
+ ],
210
+ requestBody: {
211
+ required: true,
212
+ content: { "application/json": { schema: { type: "object", properties: { a: { type: "string" } } } } },
213
+ },
214
+ responses: { "200": {} },
215
+ },
216
+ },
217
+ },
218
+ };
219
+ const out = emitApiBindings(edoc);
220
+ assert.match(out, /"id": string/); // ejected integer path param stays a raw wire string
221
+ assert.match(out, /"n"\?: string \| string\[\]/); // ejected query param stays raw wire
222
+ assert.match(out, /body: unknown;/); // ejected body is unvalidated → unknown
223
+ });
224
+
225
+ test("whole-surface eject (manifest api.eject) types every op as ejected (raw params, unknown body)", () => {
226
+ const sdoc: OpenApiDoc = {
227
+ openapi: "3.0.0",
228
+ paths: {
229
+ "/thing/{id}": {
230
+ post: {
231
+ operationId: "doThing",
232
+ parameters: [
233
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
234
+ { name: "q", in: "query", required: true, schema: { type: "integer" } },
235
+ ],
236
+ requestBody: {
237
+ required: true,
238
+ content: { "application/json": { schema: { type: "object", properties: { a: { type: "string" } } } } },
239
+ },
240
+ responses: { "200": {} },
241
+ },
242
+ },
243
+ },
244
+ };
245
+ // Without the surface flag the op is fully typed…
246
+ const typed = emitApiBindings(sdoc, false);
247
+ assert.match(typed, /"id": number/);
248
+ assert.match(typed, /"q": number/);
249
+ // …with whole-surface eject it degrades to raw wire + unknown body, matching the runtime.
250
+ const ejected = emitApiBindings(sdoc, true);
251
+ assert.match(ejected, /"id": string/); // raw wire path param
252
+ assert.match(ejected, /"q"\?: string \| string\[\]/); // required query param becomes optional + raw
253
+ assert.match(ejected, /body: unknown;/);
254
+ });
255
+
256
+ test("an ejected required query param is typed optional (runtime does not enforce presence)", () => {
257
+ const edoc: OpenApiDoc = {
258
+ openapi: "3.0.0",
259
+ paths: {
260
+ "/raw": {
261
+ get: {
262
+ operationId: "rawReq",
263
+ "x-urban-eject": true,
264
+ parameters: [{ name: "req", in: "query", required: true, schema: { type: "integer" } }],
265
+ responses: { "200": {} },
266
+ },
267
+ },
268
+ },
269
+ };
270
+ const out = emitApiBindings(edoc);
271
+ assert.match(out, /"req"\?: string \| string\[\]/); // required-but-ejected → optional raw wire
272
+ });
273
+
274
+ test("a component or operationId colliding with a fixed generated name fails the drift gate", () => {
275
+ const cdoc: OpenApiDoc = {
276
+ openapi: "3.0.0",
277
+ components: { schemas: { ApiOperations: { type: "string" } } },
278
+ paths: { "/z": { get: { operationId: "getZ", responses: { "200": {} } } } },
279
+ };
280
+ assert.throws(() => emitApiBindings(cdoc), /duplicate TypeScript type "ApiOperations"/);
281
+ });
282
+
283
+ test("a documented-but-bodyless status is excluded from the response union", () => {
284
+ const rdoc: OpenApiDoc = {
285
+ openapi: "3.1.0",
286
+ components: {
287
+ schemas: { Err: { type: "object", properties: { error: { type: "string" } }, additionalProperties: false } },
288
+ },
289
+ paths: {
290
+ "/x": {
291
+ get: {
292
+ operationId: "getX",
293
+ responses: {
294
+ "204": {}, // documented, no JSON body → not in the union
295
+ default: { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
296
+ },
297
+ },
298
+ },
299
+ },
300
+ };
301
+ const out = emitApiBindings(rdoc);
302
+ assert.match(out, /export type GetXResponse = Err;/); // only the schema'd default body
146
303
  });
147
304
 
148
305
  test("request body optionality follows requestBody.required (runtime passes undefined when absent)", () => {