@nanobpm/urban 0.40.0 → 0.40.1

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.
@@ -2,7 +2,7 @@
2
2
  * are ignored by the validator and fall back to `unknown` in the type emitter. */
3
3
  export interface OpenApiSchema {
4
4
  $ref?: string;
5
- type?: string;
5
+ type?: string | string[];
6
6
  nullable?: boolean;
7
7
  enum?: unknown[];
8
8
  const?: unknown;
@@ -25,6 +25,21 @@ export interface OpenApiSchema {
25
25
  anyOf?: OpenApiSchema[];
26
26
  description?: string;
27
27
  }
28
+ /**
29
+ * The declared JSON-Schema `type`(s) as a list, normalizing OpenAPI 3.0 (a single string) and
30
+ * 3.1 (a `string[]`) into one shape. Returns `[]` when no `type` keyword is present, so callers
31
+ * distinguish "typeless" (infer from other keywords) from "typed".
32
+ */
33
+ export declare function schemaTypeList(schema: OpenApiSchema): string[];
34
+ /** The declared non-"null" value types — what a 3.1 `type: [T, "null"]` permits as a value. */
35
+ export declare function schemaValueTypes(schema: OpenApiSchema): string[];
36
+ /** The first declared non-"null" value type (for single-type scalar logic), or `undefined`. */
37
+ export declare function firstValueType(schema: OpenApiSchema): string | undefined;
38
+ /** Whether the schema permits an explicit `null` — via OpenAPI 3.0 `nullable: true` or the 3.1
39
+ * idiom `type: [..., "null"]`. */
40
+ export declare function schemaAllowsNull(schema: OpenApiSchema): boolean;
41
+ /** Whether the schema declares (permits) the given non-"null" value type. */
42
+ export declare function schemaHasType(schema: OpenApiSchema, t: string): boolean;
28
43
  /**
29
44
  * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
30
45
  * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
@@ -8,9 +8,38 @@
8
8
  //
9
9
  // Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
10
10
  // requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
11
- // string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
12
- // XML, discriminated oneOf composition) is intentionally out of scope for this slice.
11
+ // string bounds/pattern/array & object shape/nullable/$ref). The `type` keyword is read in both
12
+ // OpenAPI 3.0 (a single string, `nullable: true` for null) and 3.1 (a `string[]`, e.g. the
13
+ // `type: [T, "null"]` nullable idiom or a multi-type union) dialects. Exotic OpenAPI (callbacks,
14
+ // links, XML, discriminated oneOf composition) is intentionally out of scope for this slice.
13
15
  import { parse as parseYaml } from "yaml";
16
+ /**
17
+ * The declared JSON-Schema `type`(s) as a list, normalizing OpenAPI 3.0 (a single string) and
18
+ * 3.1 (a `string[]`) into one shape. Returns `[]` when no `type` keyword is present, so callers
19
+ * distinguish "typeless" (infer from other keywords) from "typed".
20
+ */
21
+ export function schemaTypeList(schema) {
22
+ if (schema.type === undefined)
23
+ return [];
24
+ return Array.isArray(schema.type) ? schema.type : [schema.type];
25
+ }
26
+ /** The declared non-"null" value types — what a 3.1 `type: [T, "null"]` permits as a value. */
27
+ export function schemaValueTypes(schema) {
28
+ return schemaTypeList(schema).filter((t) => t !== "null");
29
+ }
30
+ /** The first declared non-"null" value type (for single-type scalar logic), or `undefined`. */
31
+ export function firstValueType(schema) {
32
+ return schemaValueTypes(schema)[0];
33
+ }
34
+ /** Whether the schema permits an explicit `null` — via OpenAPI 3.0 `nullable: true` or the 3.1
35
+ * idiom `type: [..., "null"]`. */
36
+ export function schemaAllowsNull(schema) {
37
+ return schema.nullable === true || schemaTypeList(schema).includes("null");
38
+ }
39
+ /** Whether the schema declares (permits) the given non-"null" value type. */
40
+ export function schemaHasType(schema, t) {
41
+ return schemaValueTypes(schema).includes(t);
42
+ }
14
43
  /**
15
44
  * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
16
45
  * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
@@ -19,9 +48,10 @@ import { parse as parseYaml } from "yaml";
19
48
  * the emitted type calls an object is the same body the validator shape-checks.
20
49
  */
21
50
  export function isObjectSchema(schema) {
22
- if (schema.type === "object")
51
+ const valueTypes = schemaValueTypes(schema);
52
+ if (valueTypes.includes("object"))
23
53
  return true;
24
- if (schema.type !== undefined)
54
+ if (valueTypes.length > 0)
25
55
  return false;
26
56
  return (schema.properties !== undefined ||
27
57
  schema.required !== undefined ||
@@ -494,8 +524,14 @@ function matchesType(declared, value) {
494
524
  return actual === "number" || actual === "integer";
495
525
  if (declared === "integer")
496
526
  return actual === "integer";
527
+ if (declared === "null")
528
+ return value === null;
497
529
  return actual === declared;
498
530
  }
531
+ /** Whether `value` matches any of the declared value types (a 3.1 `type` list is a union). */
532
+ function matchesAnyType(declared, value) {
533
+ return declared.some((t) => matchesType(t, value));
534
+ }
499
535
  /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
500
536
  * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
501
537
  export function validateValue(doc, schemaIn, value, path = "") {
@@ -505,7 +541,7 @@ export function validateValue(doc, schemaIn, value, path = "") {
505
541
  const issues = [];
506
542
  const at = path || "(root)";
507
543
  if (value === null) {
508
- if (schema.nullable === true || schema.type === "null")
544
+ if (schemaAllowsNull(schema))
509
545
  return issues;
510
546
  // enum/const may explicitly permit null even without `nullable`.
511
547
  if (schema.enum && schema.enum.some((e) => e === null))
@@ -514,14 +550,15 @@ export function validateValue(doc, schemaIn, value, path = "") {
514
550
  return issues;
515
551
  // Otherwise null is invalid for a typed schema, an object schema (properties/required imply
516
552
  // object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
553
+ const valueTypes = schemaValueTypes(schema);
517
554
  if (schema.enum) {
518
555
  issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
519
556
  }
520
557
  else if (schema.const !== undefined) {
521
558
  issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
522
559
  }
523
- else if (schema.type) {
524
- issues.push({ path: at, message: `expected ${schema.type}, got null` });
560
+ else if (valueTypes.length > 0) {
561
+ issues.push({ path: at, message: `expected ${valueTypes.join(",")}, got null` });
525
562
  }
526
563
  else if (isObjectSchema(schema)) {
527
564
  issues.push({ path: at, message: "expected object, got null" });
@@ -550,11 +587,15 @@ export function validateValue(doc, schemaIn, value, path = "") {
550
587
  issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
551
588
  }
552
589
  }
553
- if (schema.type && !matchesType(schema.type, value)) {
554
- issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
590
+ // `value` is non-null here (the null branch returned above), so match against the declared value
591
+ // types. Use the full `type` list — not `schemaValueTypes` — so a null-only schema (`["null"]`)
592
+ // still rejects non-null values instead of skipping the check on an empty value-type list.
593
+ const declaredTypes = schemaTypeList(schema);
594
+ if (declaredTypes.length > 0 && !matchesAnyType(declaredTypes, value)) {
595
+ issues.push({ path: at, message: `expected ${declaredTypes.join(",")}, got ${typeOfValue(value)}` });
555
596
  return issues; // the shape checks below assume the declared type held
556
597
  }
557
- if (schema.type === "string" && typeof value === "string") {
598
+ if (schemaHasType(schema, "string") && typeof value === "string") {
558
599
  if (schema.minLength !== undefined && value.length < schema.minLength) {
559
600
  issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
560
601
  }
@@ -578,7 +619,7 @@ export function validateValue(doc, schemaIn, value, path = "") {
578
619
  }
579
620
  }
580
621
  }
581
- if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
622
+ if ((schemaHasType(schema, "number") || schemaHasType(schema, "integer")) && typeof value === "number") {
582
623
  // OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
583
624
  // JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
584
625
  // Support both so specs in either dialect are validated, not silently under-checked.
@@ -603,7 +644,7 @@ export function validateValue(doc, schemaIn, value, path = "") {
603
644
  issues.push({ path: at, message: `must be < ${schema.exclusiveMaximum}` });
604
645
  }
605
646
  }
606
- if (schema.type === "array" && Array.isArray(value)) {
647
+ if (schemaHasType(schema, "array") && Array.isArray(value)) {
607
648
  if (schema.minItems !== undefined && value.length < schema.minItems) {
608
649
  issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
609
650
  }
@@ -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, responseSchemaForStatus, toRouteMatcher, undeclaredPathParams, undeclaredSecuritySchemes, evaluateSecurity, validateValue, } from "../../../openapi/spec.js";
18
+ import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, responseSchemaForStatus, schemaHasType, schemaValueTypes, 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. */
@@ -146,28 +146,36 @@ function coerceParam(doc, schema, raw) {
146
146
  const s = resolveSchema(doc, schema);
147
147
  if (!s)
148
148
  return raw;
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) {
149
+ // Which value types the schema permits coercion into: the explicit `type`(s), else inferred from a
150
+ // typeless `const`/`enum` whose values are all numbers or all booleans. Without the inference, a
151
+ // typeless numeric/boolean const/enum param (whose generated type is a number/boolean literal)
152
+ // could never be satisfied by the wire string — every request would 400 against a type the runtime
153
+ // otherwise never produces.
154
+ let valueTypes = schemaValueTypes(s);
155
+ if (valueTypes.length === 0) {
155
156
  if (typeof s.const === "number")
156
- t = "number";
157
+ valueTypes = ["number"];
157
158
  else if (typeof s.const === "boolean")
158
- t = "boolean";
159
+ valueTypes = ["boolean"];
159
160
  else if (Array.isArray(s.enum) && s.enum.length > 0) {
160
161
  if (s.enum.every((v) => typeof v === "number"))
161
- t = "number";
162
+ valueTypes = ["number"];
162
163
  else if (s.enum.every((v) => typeof v === "boolean"))
163
- t = "boolean";
164
+ valueTypes = ["boolean"];
164
165
  }
165
166
  }
166
- if (t === "integer" || t === "number") {
167
+ // Coerce order-independently for OpenAPI 3.1 multi-type unions: a schema that *allows* number or
168
+ // boolean gets that coercion attempted regardless of where it sits in the `type` array (so
169
+ // `["string","integer"]` and `["integer","string"]` behave identically). Each attempt falls back
170
+ // to the raw string when it doesn't parse, preserving a value that only fits the union's `string`
171
+ // arm. Number is tried before boolean so a numeric-looking value in a `number|boolean` union
172
+ // becomes a number (`"true"`/`"false"` aren't numeric, so they still reach the boolean arm).
173
+ if (valueTypes.includes("integer") || valueTypes.includes("number")) {
167
174
  const n = Number(raw);
168
- return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
175
+ if (raw.trim() !== "" && !Number.isNaN(n))
176
+ return n;
169
177
  }
170
- if (t === "boolean") {
178
+ if (valueTypes.includes("boolean")) {
171
179
  if (raw === "true")
172
180
  return true;
173
181
  if (raw === "false")
@@ -438,7 +446,7 @@ export function mountApi(ctx, app) {
438
446
  continue;
439
447
  const values = Array.isArray(val) ? val : [val];
440
448
  const ps = resolveSchema(d, p.schema);
441
- if (ps?.type === "array") {
449
+ if (ps && schemaHasType(ps, "array")) {
442
450
  // Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
443
451
  // whole array against the schema's array constraints (minItems, items, ...). The coerced
444
452
  // array is what the delegate receives.
@@ -8,7 +8,7 @@
8
8
  // the shared `openapi/spec.ts`, so types and runtime never drift.
9
9
  import { GENERATED_DIR } from "../artifact.js";
10
10
  import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.js";
11
- import { collectOperations, assertUniqueOperationIds, isObjectSchema, refName, } from "../../openapi/spec.js";
11
+ import { collectOperations, assertUniqueOperationIds, isObjectSchema, refName, schemaAllowsNull, schemaTypeList, schemaValueTypes, } from "../../openapi/spec.js";
12
12
  /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
13
13
  export const API_BINDINGS_DTS = "api-io.d.ts";
14
14
  export const API_BINDINGS_TS = "operations.ts";
@@ -35,7 +35,7 @@ export function schemaToTs(schema, depth = 0) {
35
35
  const name = refName(schema.$ref);
36
36
  return name ? typeStem(name) : "unknown";
37
37
  }
38
- const nullable = schema.nullable === true;
38
+ const nullable = schemaAllowsNull(schema);
39
39
  const wrap = (t) => (nullable ? `${t} | null` : t);
40
40
  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
41
41
  return wrap(schema.enum.map((v) => JSON.stringify(v)).join(" | "));
@@ -51,7 +51,13 @@ export function schemaToTs(schema, depth = 0) {
51
51
  if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
52
52
  return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
53
53
  }
54
- switch (schema.type) {
54
+ // Value types, normalized from OpenAPI 3.0 (single string) or 3.1 (array, e.g. `[T, "null"]` or a
55
+ // multi-type union). `null` is stripped here and re-applied by `wrap` via `schemaAllowsNull`.
56
+ const valueTypes = schemaValueTypes(schema);
57
+ if (valueTypes.length > 1) {
58
+ return wrap(valueTypes.map((t) => tsForScalarType(t, schema, depth)).join(" | "));
59
+ }
60
+ switch (valueTypes[0]) {
55
61
  case "string":
56
62
  return wrap("string");
57
63
  case "integer":
@@ -59,20 +65,40 @@ export function schemaToTs(schema, depth = 0) {
59
65
  return wrap("number");
60
66
  case "boolean":
61
67
  return wrap("boolean");
62
- case "null":
63
- return "null";
64
68
  case "array":
65
69
  return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
66
70
  case "object":
67
71
  return wrap(objectToTs(schema, depth));
68
72
  default:
69
- // No `type`: infer object-ness from object-only keywords (shared with the runtime
70
- // validator via isObjectSchema so types and validation agree); otherwise unknown.
73
+ // No value type. A schema whose only declared type is "null" is exactly `null`; otherwise
74
+ // infer object-ness from object-only keywords (shared with the runtime validator via
75
+ // isObjectSchema so types and validation agree), else fall back to unknown.
76
+ if (schemaTypeList(schema).length > 0 && schemaValueTypes(schema).length === 0)
77
+ return "null";
71
78
  if (isObjectSchema(schema))
72
79
  return wrap(objectToTs(schema, depth));
73
80
  return "unknown";
74
81
  }
75
82
  }
83
+ /** The TS expression for a single (non-"null") JSON-Schema value type. Nullability is applied by
84
+ * the caller's `wrap`, so this never emits `| null`. */
85
+ function tsForScalarType(t, schema, depth) {
86
+ switch (t) {
87
+ case "string":
88
+ return "string";
89
+ case "integer":
90
+ case "number":
91
+ return "number";
92
+ case "boolean":
93
+ return "boolean";
94
+ case "array":
95
+ return `Array<${schemaToTs(schema.items, depth + 1)}>`;
96
+ case "object":
97
+ return objectToTs(schema, depth);
98
+ default:
99
+ return "unknown";
100
+ }
101
+ }
76
102
  function objectToTs(schema, depth) {
77
103
  const props = schema.properties ?? {};
78
104
  const required = new Set(schema.required ?? []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
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",
@@ -66,7 +66,7 @@
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.12.0",
69
+ "create-urban-app": "^0.13.0",
70
70
  "yaml": "^2.9.0"
71
71
  },
72
72
  "devDependencies": {
@@ -208,6 +208,53 @@ test("isObjectSchema treats typeless property/required/additionalProperties sche
208
208
  assert.equal(isObjectSchema({}), false); // no shape keywords → not an object
209
209
  });
210
210
 
211
+ test("isObjectSchema understands OpenAPI 3.1 nullable type-arrays", () => {
212
+ assert.equal(isObjectSchema({ type: ["object", "null"] }), true); // nullable object is an object
213
+ assert.equal(isObjectSchema({ type: ["string", "null"] }), false); // nullable scalar is not
214
+ });
215
+
216
+ test("validateValue accepts OpenAPI 3.1 nullable type-arrays (type: [T, null])", () => {
217
+ // The 3.1 idiom `type: [T, "null"]` must be treated exactly like 3.0 `nullable: true`: the value
218
+ // type is accepted, an explicit null is accepted, and a wrong type is still rejected.
219
+ const nullableStr: OpenApiSchema = { type: ["string", "null"] };
220
+ assert.deepEqual(validateValue(doc, nullableStr, "hi"), []);
221
+ assert.deepEqual(validateValue(doc, nullableStr, null), []);
222
+ assert.equal(validateValue(doc, nullableStr, 42).length, 1);
223
+
224
+ const nullableInt: OpenApiSchema = { type: ["integer", "null"] };
225
+ assert.deepEqual(validateValue(doc, nullableInt, 7), []);
226
+ assert.deepEqual(validateValue(doc, nullableInt, null), []);
227
+ assert.equal(validateValue(doc, nullableInt, "no").length, 1);
228
+ });
229
+
230
+ test("validateValue keeps bounds + multi-type unions under 3.1 type-arrays", () => {
231
+ // String bounds still apply to the string arm of a nullable string.
232
+ const bounded: OpenApiSchema = { type: ["string", "null"], minLength: 2 };
233
+ assert.deepEqual(validateValue(doc, bounded, "ok"), []);
234
+ assert.deepEqual(validateValue(doc, bounded, null), []);
235
+ assert.equal(validateValue(doc, bounded, "x").length, 1); // shorter than minLength
236
+
237
+ // A genuine multi-type union (no null) accepts any listed type, rejects others.
238
+ const strOrNum: OpenApiSchema = { type: ["string", "number"] };
239
+ assert.deepEqual(validateValue(doc, strOrNum, "a"), []);
240
+ assert.deepEqual(validateValue(doc, strOrNum, 1), []);
241
+ assert.equal(validateValue(doc, strOrNum, true).length, 1);
242
+ assert.equal(validateValue(doc, strOrNum, null).length, 1); // null not permitted without "null"
243
+ });
244
+
245
+ test("validateValue rejects non-null values for a null-only schema (type: [null] / type: null)", () => {
246
+ // A schema whose only declared type is "null" must accept null and reject everything else — the
247
+ // value-type list is empty, so the type check must fall back to the full `type` list.
248
+ const nullOnlyArr: OpenApiSchema = { type: ["null"] };
249
+ assert.deepEqual(validateValue(doc, nullOnlyArr, null), []);
250
+ assert.equal(validateValue(doc, nullOnlyArr, "x").length, 1);
251
+ assert.equal(validateValue(doc, nullOnlyArr, 0).length, 1);
252
+
253
+ const nullOnlyStr: OpenApiSchema = { type: "null" };
254
+ assert.deepEqual(validateValue(doc, nullOnlyStr, null), []);
255
+ assert.equal(validateValue(doc, nullOnlyStr, "x").length, 1);
256
+ });
257
+
211
258
  test("validateValue shape-checks typeless object schemas (no type/runtime drift)", () => {
212
259
  // A schema with properties/required but no explicit `type: "object"` is emitted as an object
213
260
  // by schemaToTs, so the validator must shape-check it too — otherwise invalid bodies pass.
@@ -8,8 +8,10 @@
8
8
  //
9
9
  // Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
10
10
  // requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
11
- // string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
12
- // XML, discriminated oneOf composition) is intentionally out of scope for this slice.
11
+ // string bounds/pattern/array & object shape/nullable/$ref). The `type` keyword is read in both
12
+ // OpenAPI 3.0 (a single string, `nullable: true` for null) and 3.1 (a `string[]`, e.g. the
13
+ // `type: [T, "null"]` nullable idiom or a multi-type union) dialects. Exotic OpenAPI (callbacks,
14
+ // links, XML, discriminated oneOf composition) is intentionally out of scope for this slice.
13
15
 
14
16
  import { parse as parseYaml } from "yaml";
15
17
 
@@ -17,7 +19,11 @@ import { parse as parseYaml } from "yaml";
17
19
  * are ignored by the validator and fall back to `unknown` in the type emitter. */
18
20
  export interface OpenApiSchema {
19
21
  $ref?: string;
20
- type?: string;
22
+ // OpenAPI 3.0 uses a single `type` string; JSON Schema 2020-12 (OpenAPI 3.1) also permits an
23
+ // array of types, most commonly the nullable idiom `type: [T, "null"]`. Both dialects are read
24
+ // via the `schemaTypeList`/`schemaValueTypes`/`schemaAllowsNull`/`schemaHasType` helpers so the
25
+ // type emitter and the runtime validator treat a 3.1 type-array the same way.
26
+ type?: string | string[];
21
27
  nullable?: boolean;
22
28
  enum?: unknown[];
23
29
  const?: unknown;
@@ -46,6 +52,37 @@ export interface OpenApiSchema {
46
52
  description?: string;
47
53
  }
48
54
 
55
+ /**
56
+ * The declared JSON-Schema `type`(s) as a list, normalizing OpenAPI 3.0 (a single string) and
57
+ * 3.1 (a `string[]`) into one shape. Returns `[]` when no `type` keyword is present, so callers
58
+ * distinguish "typeless" (infer from other keywords) from "typed".
59
+ */
60
+ export function schemaTypeList(schema: OpenApiSchema): string[] {
61
+ if (schema.type === undefined) return [];
62
+ return Array.isArray(schema.type) ? schema.type : [schema.type];
63
+ }
64
+
65
+ /** The declared non-"null" value types — what a 3.1 `type: [T, "null"]` permits as a value. */
66
+ export function schemaValueTypes(schema: OpenApiSchema): string[] {
67
+ return schemaTypeList(schema).filter((t) => t !== "null");
68
+ }
69
+
70
+ /** The first declared non-"null" value type (for single-type scalar logic), or `undefined`. */
71
+ export function firstValueType(schema: OpenApiSchema): string | undefined {
72
+ return schemaValueTypes(schema)[0];
73
+ }
74
+
75
+ /** Whether the schema permits an explicit `null` — via OpenAPI 3.0 `nullable: true` or the 3.1
76
+ * idiom `type: [..., "null"]`. */
77
+ export function schemaAllowsNull(schema: OpenApiSchema): boolean {
78
+ return schema.nullable === true || schemaTypeList(schema).includes("null");
79
+ }
80
+
81
+ /** Whether the schema declares (permits) the given non-"null" value type. */
82
+ export function schemaHasType(schema: OpenApiSchema, t: string): boolean {
83
+ return schemaValueTypes(schema).includes(t);
84
+ }
85
+
49
86
  /**
50
87
  * Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
51
88
  * keyword be omitted when `properties`/`required`/`additionalProperties` already imply
@@ -54,8 +91,9 @@ export interface OpenApiSchema {
54
91
  * the emitted type calls an object is the same body the validator shape-checks.
55
92
  */
56
93
  export function isObjectSchema(schema: OpenApiSchema): boolean {
57
- if (schema.type === "object") return true;
58
- if (schema.type !== undefined) return false;
94
+ const valueTypes = schemaValueTypes(schema);
95
+ if (valueTypes.includes("object")) return true;
96
+ if (valueTypes.length > 0) return false;
59
97
  return (
60
98
  schema.properties !== undefined ||
61
99
  schema.required !== undefined ||
@@ -650,9 +688,15 @@ function matchesType(declared: string, value: unknown): boolean {
650
688
  const actual = typeOfValue(value);
651
689
  if (declared === "number") return actual === "number" || actual === "integer";
652
690
  if (declared === "integer") return actual === "integer";
691
+ if (declared === "null") return value === null;
653
692
  return actual === declared;
654
693
  }
655
694
 
695
+ /** Whether `value` matches any of the declared value types (a 3.1 `type` list is a union). */
696
+ function matchesAnyType(declared: string[], value: unknown): boolean {
697
+ return declared.some((t) => matchesType(t, value));
698
+ }
699
+
656
700
  /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
657
701
  * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
658
702
  export function validateValue(
@@ -667,18 +711,19 @@ export function validateValue(
667
711
  const at = path || "(root)";
668
712
 
669
713
  if (value === null) {
670
- if (schema.nullable === true || schema.type === "null") return issues;
714
+ if (schemaAllowsNull(schema)) return issues;
671
715
  // enum/const may explicitly permit null even without `nullable`.
672
716
  if (schema.enum && schema.enum.some((e) => e === null)) return issues;
673
717
  if (schema.const === null) return issues;
674
718
  // Otherwise null is invalid for a typed schema, an object schema (properties/required imply
675
719
  // object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
720
+ const valueTypes = schemaValueTypes(schema);
676
721
  if (schema.enum) {
677
722
  issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
678
723
  } else if (schema.const !== undefined) {
679
724
  issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
680
- } else if (schema.type) {
681
- issues.push({ path: at, message: `expected ${schema.type}, got null` });
725
+ } else if (valueTypes.length > 0) {
726
+ issues.push({ path: at, message: `expected ${valueTypes.join(",")}, got null` });
682
727
  } else if (isObjectSchema(schema)) {
683
728
  issues.push({ path: at, message: "expected object, got null" });
684
729
  }
@@ -708,12 +753,16 @@ export function validateValue(
708
753
  }
709
754
  }
710
755
 
711
- if (schema.type && !matchesType(schema.type, value)) {
712
- issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
756
+ // `value` is non-null here (the null branch returned above), so match against the declared value
757
+ // types. Use the full `type` list — not `schemaValueTypes` — so a null-only schema (`["null"]`)
758
+ // still rejects non-null values instead of skipping the check on an empty value-type list.
759
+ const declaredTypes = schemaTypeList(schema);
760
+ if (declaredTypes.length > 0 && !matchesAnyType(declaredTypes, value)) {
761
+ issues.push({ path: at, message: `expected ${declaredTypes.join(",")}, got ${typeOfValue(value)}` });
713
762
  return issues; // the shape checks below assume the declared type held
714
763
  }
715
764
 
716
- if (schema.type === "string" && typeof value === "string") {
765
+ if (schemaHasType(schema, "string") && typeof value === "string") {
717
766
  if (schema.minLength !== undefined && value.length < schema.minLength) {
718
767
  issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
719
768
  }
@@ -737,7 +786,7 @@ export function validateValue(
737
786
  }
738
787
  }
739
788
 
740
- if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
789
+ if ((schemaHasType(schema, "number") || schemaHasType(schema, "integer")) && typeof value === "number") {
741
790
  // OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
742
791
  // JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
743
792
  // Support both so specs in either dialect are validated, not silently under-checked.
@@ -763,7 +812,7 @@ export function validateValue(
763
812
  }
764
813
  }
765
814
 
766
- if (schema.type === "array" && Array.isArray(value)) {
815
+ if (schemaHasType(schema, "array") && Array.isArray(value)) {
767
816
  if (schema.minItems !== undefined && value.length < schema.minItems) {
768
817
  issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
769
818
  }
@@ -457,6 +457,87 @@ test("params/query are coerced to their schema type before reaching the delegate
457
457
  assert.deepEqual(got, { params: { id: 42 }, query: { verbose: true, tags: ["x", "y"] } });
458
458
  });
459
459
 
460
+ test("3.1 nullable-typed params still coerce (type: [T, null]) and array-typed repeats still group", async () => {
461
+ // A param declared with the OpenAPI 3.1 nullable idiom must coerce on its value type exactly like
462
+ // the 3.0 single-type form — otherwise a `type: ["integer","null"]` query would reach the delegate
463
+ // as a raw string (and an array-typed nullable would reject repeats).
464
+ const spec = JSON.stringify({
465
+ openapi: "3.1.0",
466
+ paths: {
467
+ "/things/{id}": {
468
+ get: {
469
+ operationId: "getThing",
470
+ parameters: [
471
+ { name: "id", in: "path", required: true, schema: { type: ["integer", "null"] } },
472
+ { name: "tags", in: "query", schema: { type: ["array", "null"], items: { type: "string" } } },
473
+ ],
474
+ responses: { "200": {} },
475
+ },
476
+ },
477
+ },
478
+ });
479
+ let got: { params: Record<string, unknown>; query: Record<string, unknown> } | undefined;
480
+ const { router } = build(
481
+ { spec: "openapi.json" },
482
+ {
483
+ "/app/operations/getThing": {
484
+ default: (i: { params: Record<string, unknown>; query: Record<string, unknown> }) => {
485
+ got = { params: i.params, query: i.query };
486
+ return { body: { ok: true } };
487
+ },
488
+ },
489
+ },
490
+ spec,
491
+ );
492
+ const res = await router(req("GET", "/app/api/things/7", { query: "tags=x&tags=y" }));
493
+ assert.equal(res.status, 200);
494
+ assert.deepEqual(got, { params: { id: 7 }, query: { tags: ["x", "y"] } });
495
+ });
496
+
497
+ test("3.1 multi-type union params coerce order-independently (type array order doesn't matter)", async () => {
498
+ // A 3.1 union like `["string","integer"]` must coerce numeric-looking values to numbers regardless
499
+ // of where `integer`/`number` sits in the array — otherwise coercion is order-dependent and a
500
+ // `["string","integer"]` param would never enforce numeric bounds (it'd reach the delegate as a
501
+ // raw string), while `["integer","string"]` would. Non-numeric values still fall back to the
502
+ // string arm, and a `number|boolean` union coerces each token to its natural type.
503
+ const spec = JSON.stringify({
504
+ openapi: "3.1.0",
505
+ paths: {
506
+ "/u": {
507
+ get: {
508
+ operationId: "u",
509
+ parameters: [
510
+ { name: "a", in: "query", required: true, schema: { type: ["string", "integer"] } },
511
+ { name: "b", in: "query", required: true, schema: { type: ["integer", "string"] } },
512
+ { name: "c", in: "query", required: true, schema: { type: ["string", "integer"] } },
513
+ { name: "d", in: "query", required: true, schema: { type: ["number", "boolean"] } },
514
+ { name: "e", in: "query", required: true, schema: { type: ["number", "boolean"] } },
515
+ ],
516
+ responses: { "200": {} },
517
+ },
518
+ },
519
+ },
520
+ });
521
+ let got: { query: Record<string, unknown> } | undefined;
522
+ const { router } = build(
523
+ { spec: "openapi.json" },
524
+ {
525
+ "/app/operations/u": {
526
+ default: (i: { query: Record<string, unknown> }) => {
527
+ got = { query: i.query };
528
+ return { body: { ok: true } };
529
+ },
530
+ },
531
+ },
532
+ spec,
533
+ );
534
+ const res = await router(req("GET", "/app/api/u", { query: "a=42&b=42&c=hello&d=3.5&e=true" }));
535
+ assert.equal(res.status, 200);
536
+ // a and b both coerce to 42 despite opposite type-array order; c stays a string (only fits the
537
+ // union's string arm); d coerces to a number, e to a boolean.
538
+ assert.deepEqual(got, { query: { a: 42, b: 42, c: "hello", d: 3.5, e: true } });
539
+ });
540
+
460
541
  test("a typeless numeric const/enum query param coerces (so its number-literal type is satisfiable)", async () => {
461
542
  const enumSpec = JSON.stringify({
462
543
  openapi: "3.1.0",
@@ -29,6 +29,8 @@ import {
29
29
  parseSpec,
30
30
  resolveSchema,
31
31
  responseSchemaForStatus,
32
+ schemaHasType,
33
+ schemaValueTypes,
32
34
  toRouteMatcher,
33
35
  undeclaredPathParams,
34
36
  undeclaredSecuritySchemes,
@@ -249,24 +251,31 @@ export function apiDocsPath(manifest: unknown): string | undefined {
249
251
  function coerceParam(doc: OpenApiDoc, schema: OpenApiSchema | undefined, raw: string): unknown {
250
252
  const s = resolveSchema(doc, schema);
251
253
  if (!s) return raw;
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";
254
+ // Which value types the schema permits coercion into: the explicit `type`(s), else inferred from a
255
+ // typeless `const`/`enum` whose values are all numbers or all booleans. Without the inference, a
256
+ // typeless numeric/boolean const/enum param (whose generated type is a number/boolean literal)
257
+ // could never be satisfied by the wire string — every request would 400 against a type the runtime
258
+ // otherwise never produces.
259
+ let valueTypes = schemaValueTypes(s);
260
+ if (valueTypes.length === 0) {
261
+ if (typeof s.const === "number") valueTypes = ["number"];
262
+ else if (typeof s.const === "boolean") valueTypes = ["boolean"];
260
263
  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";
264
+ if (s.enum.every((v) => typeof v === "number")) valueTypes = ["number"];
265
+ else if (s.enum.every((v) => typeof v === "boolean")) valueTypes = ["boolean"];
263
266
  }
264
267
  }
265
- if (t === "integer" || t === "number") {
268
+ // Coerce order-independently for OpenAPI 3.1 multi-type unions: a schema that *allows* number or
269
+ // boolean gets that coercion attempted regardless of where it sits in the `type` array (so
270
+ // `["string","integer"]` and `["integer","string"]` behave identically). Each attempt falls back
271
+ // to the raw string when it doesn't parse, preserving a value that only fits the union's `string`
272
+ // arm. Number is tried before boolean so a numeric-looking value in a `number|boolean` union
273
+ // becomes a number (`"true"`/`"false"` aren't numeric, so they still reach the boolean arm).
274
+ if (valueTypes.includes("integer") || valueTypes.includes("number")) {
266
275
  const n = Number(raw);
267
- return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
276
+ if (raw.trim() !== "" && !Number.isNaN(n)) return n;
268
277
  }
269
- if (t === "boolean") {
278
+ if (valueTypes.includes("boolean")) {
270
279
  if (raw === "true") return true;
271
280
  if (raw === "false") return false;
272
281
  }
@@ -571,7 +580,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
571
580
  if (!p.schema) continue;
572
581
  const values = Array.isArray(val) ? val : [val];
573
582
  const ps = resolveSchema(d, p.schema);
574
- if (ps?.type === "array") {
583
+ if (ps && schemaHasType(ps, "array")) {
575
584
  // Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
576
585
  // whole array against the schema's array constraints (minItems, items, ...). The coerced
577
586
  // array is what the delegate receives.
@@ -38,6 +38,26 @@ test("schemaToTs renders primitives, enums, arrays, refs, and unknown fallback",
38
38
  assert.equal(schemaToTs(undefined), "unknown");
39
39
  });
40
40
 
41
+ test("schemaToTs renders OpenAPI 3.1 nullable type-arrays as `T | null`", () => {
42
+ // The 3.1 idiom `type: [T, "null"]` must emit the same `T | null` as 3.0 `nullable: true`,
43
+ // not degrade to `unknown` (the bug this fixes).
44
+ assert.equal(schemaToTs({ type: ["string", "null"] }), "string | null");
45
+ assert.equal(schemaToTs({ type: ["integer", "null"] }), "number | null");
46
+ assert.equal(schemaToTs({ type: ["boolean", "null"] }), "boolean | null");
47
+ assert.equal(schemaToTs({ type: ["array", "null"], items: { type: "string" } }), "Array<string> | null");
48
+ // 3.0 nullable stays equivalent.
49
+ assert.equal(schemaToTs({ type: "string", nullable: true }), "string | null");
50
+ // A bare `type: ["null"]` is exactly `null`.
51
+ assert.equal(schemaToTs({ type: ["null"] }), "null");
52
+ // A genuine multi-type union renders as a union (plus `| null` when null is present).
53
+ assert.equal(schemaToTs({ type: ["string", "number"] }), "string | number");
54
+ assert.equal(schemaToTs({ type: ["string", "number", "null"] }), "string | number | null");
55
+ });
56
+
57
+ test("schemaToTs renders a 3.1 nullable object type-array as `{...} | null`", () => {
58
+ assert.match(schemaToTs({ type: ["object", "null"], properties: { a: { type: "string" } } }), /\| null$/);
59
+ });
60
+
41
61
  test("emitApiBindings emits component types + per-op request/response + the id map", () => {
42
62
  const out = emitApiBindings(doc);
43
63
  assert.match(out, /export type Invoice = \{/);
@@ -18,6 +18,9 @@ import {
18
18
  type OpenApiSchema,
19
19
  type OperationInfo,
20
20
  refName,
21
+ schemaAllowsNull,
22
+ schemaTypeList,
23
+ schemaValueTypes,
21
24
  } from "../../openapi/spec.ts";
22
25
 
23
26
  /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
@@ -49,7 +52,7 @@ export function schemaToTs(schema: OpenApiSchema | undefined, depth = 0): string
49
52
  const name = refName(schema.$ref);
50
53
  return name ? typeStem(name) : "unknown";
51
54
  }
52
- const nullable = schema.nullable === true;
55
+ const nullable = schemaAllowsNull(schema);
53
56
  const wrap = (t: string) => (nullable ? `${t} | null` : t);
54
57
 
55
58
  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
@@ -67,7 +70,13 @@ export function schemaToTs(schema: OpenApiSchema | undefined, depth = 0): string
67
70
  return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
68
71
  }
69
72
 
70
- switch (schema.type) {
73
+ // Value types, normalized from OpenAPI 3.0 (single string) or 3.1 (array, e.g. `[T, "null"]` or a
74
+ // multi-type union). `null` is stripped here and re-applied by `wrap` via `schemaAllowsNull`.
75
+ const valueTypes = schemaValueTypes(schema);
76
+ if (valueTypes.length > 1) {
77
+ return wrap(valueTypes.map((t) => tsForScalarType(t, schema, depth)).join(" | "));
78
+ }
79
+ switch (valueTypes[0]) {
71
80
  case "string":
72
81
  return wrap("string");
73
82
  case "integer":
@@ -75,20 +84,40 @@ export function schemaToTs(schema: OpenApiSchema | undefined, depth = 0): string
75
84
  return wrap("number");
76
85
  case "boolean":
77
86
  return wrap("boolean");
78
- case "null":
79
- return "null";
80
87
  case "array":
81
88
  return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
82
89
  case "object":
83
90
  return wrap(objectToTs(schema, depth));
84
91
  default:
85
- // No `type`: infer object-ness from object-only keywords (shared with the runtime
86
- // validator via isObjectSchema so types and validation agree); otherwise unknown.
92
+ // No value type. A schema whose only declared type is "null" is exactly `null`; otherwise
93
+ // infer object-ness from object-only keywords (shared with the runtime validator via
94
+ // isObjectSchema so types and validation agree), else fall back to unknown.
95
+ if (schemaTypeList(schema).length > 0 && schemaValueTypes(schema).length === 0) return "null";
87
96
  if (isObjectSchema(schema)) return wrap(objectToTs(schema, depth));
88
97
  return "unknown";
89
98
  }
90
99
  }
91
100
 
101
+ /** The TS expression for a single (non-"null") JSON-Schema value type. Nullability is applied by
102
+ * the caller's `wrap`, so this never emits `| null`. */
103
+ function tsForScalarType(t: string, schema: OpenApiSchema, depth: number): string {
104
+ switch (t) {
105
+ case "string":
106
+ return "string";
107
+ case "integer":
108
+ case "number":
109
+ return "number";
110
+ case "boolean":
111
+ return "boolean";
112
+ case "array":
113
+ return `Array<${schemaToTs(schema.items, depth + 1)}>`;
114
+ case "object":
115
+ return objectToTs(schema, depth);
116
+ default:
117
+ return "unknown";
118
+ }
119
+ }
120
+
92
121
  function objectToTs(schema: OpenApiSchema, depth: number): string {
93
122
  const props = schema.properties ?? {};
94
123
  const required = new Set(schema.required ?? []);