@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.
- package/dist/openapi/spec.d.ts +16 -1
- package/dist/openapi/spec.js +53 -12
- package/dist/runtime/core/modules/api.js +23 -15
- package/dist/toolkit/derivers/api.js +33 -7
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +47 -0
- package/src/openapi/spec.ts +62 -13
- package/src/runtime/core/modules/api.test.ts +81 -0
- package/src/runtime/core/modules/api.ts +23 -14
- package/src/toolkit/derivers/api.test.ts +20 -0
- package/src/toolkit/derivers/api.ts +35 -6
package/dist/openapi/spec.d.ts
CHANGED
|
@@ -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
|
package/dist/openapi/spec.js
CHANGED
|
@@ -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).
|
|
12
|
-
//
|
|
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
|
-
|
|
51
|
+
const valueTypes = schemaValueTypes(schema);
|
|
52
|
+
if (valueTypes.includes("object"))
|
|
23
53
|
return true;
|
|
24
|
-
if (
|
|
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
|
|
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 (
|
|
524
|
-
issues.push({ path: at, message: `expected ${
|
|
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
|
-
|
|
554
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
150
|
-
// values are all numbers or all booleans. Without
|
|
151
|
-
// param (whose generated type is a number/boolean literal)
|
|
152
|
-
// string — every request would 400 against a type the runtime
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
+
valueTypes = ["number"];
|
|
157
158
|
else if (typeof s.const === "boolean")
|
|
158
|
-
|
|
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
|
-
|
|
162
|
+
valueTypes = ["number"];
|
|
162
163
|
else if (s.enum.every((v) => typeof v === "boolean"))
|
|
163
|
-
|
|
164
|
+
valueTypes = ["boolean"];
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
|
-
|
|
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
|
-
|
|
175
|
+
if (raw.trim() !== "" && !Number.isNaN(n))
|
|
176
|
+
return n;
|
|
169
177
|
}
|
|
170
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
-
//
|
|
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.
|
|
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.
|
|
69
|
+
"create-urban-app": "^0.13.0",
|
|
70
70
|
"yaml": "^2.9.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -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.
|
package/src/openapi/spec.ts
CHANGED
|
@@ -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).
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
58
|
-
if (
|
|
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
|
|
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 (
|
|
681
|
-
issues.push({ path: at, message: `expected ${
|
|
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
|
-
|
|
712
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
253
|
-
// values are all numbers or all booleans. Without
|
|
254
|
-
// param (whose generated type is a number/boolean literal)
|
|
255
|
-
// string — every request would 400 against a type the runtime
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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"))
|
|
262
|
-
else if (s.enum.every((v) => typeof v === "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
|
-
|
|
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
|
-
|
|
276
|
+
if (raw.trim() !== "" && !Number.isNaN(n)) return n;
|
|
268
277
|
}
|
|
269
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
86
|
-
//
|
|
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 ?? []);
|