@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.
- 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/package.json +1 -1
- 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/openapi/spec.ts
CHANGED
|
@@ -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
|
-
/**
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
//
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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,
|
|
111
|
-
query: Record<string,
|
|
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
|
|
243
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
|
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
|
|
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 \|
|
|
145
|
-
assert.match(out, /"page"\?: string
|
|
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)", () => {
|