typespec-http-zod 0.27.0 → 0.29.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/README.md +6 -6
- package/dist/src/api.d.ts +61 -9
- package/dist/src/api.js +767 -308
- package/dist/src/constraints.d.ts +1 -1
- package/dist/src/constraints.js +59 -21
- package/dist/src/lib.d.ts +19 -1
- package/dist/src/lib.js +42 -0
- package/dist/src/literal.d.ts +27 -0
- package/dist/src/literal.js +33 -0
- package/dist/src/registry.js +54 -10
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +135 -0
- package/dist/src/statuses.d.ts +24 -0
- package/dist/src/statuses.js +27 -0
- package/dist/src/types.js +42 -28
- package/dist/src/uri-template.d.ts +1 -13
- package/dist/src/uri-template.js +7 -3
- package/dist/src/zod.d.ts +49 -1
- package/dist/src/zod.js +224 -24
- package/package.json +14 -14
|
@@ -30,7 +30,7 @@ export declare function applyConstraints(program: Program, expression: string, t
|
|
|
30
30
|
*
|
|
31
31
|
* **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
|
|
32
32
|
* `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
|
|
33
|
-
* the
|
|
33
|
+
* the step budget is judged the same way as one it decides against. Measured over a realistic spread of
|
|
34
34
|
* 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
|
|
35
35
|
* overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
|
|
36
36
|
* ordinary and takes **11 seconds** on a 29-character input.
|
package/dist/src/constraints.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
2
|
-
import { isSafePattern } from "redos-detector";
|
|
2
|
+
import { defaultMaxSteps, isSafePattern } from "redos-detector";
|
|
3
3
|
import { reportDiagnostic } from "./lib.js";
|
|
4
|
+
import { escapeNonAscii } from "./literal.js";
|
|
4
5
|
/**
|
|
5
6
|
* Constraint decorators -> the Zod modifiers that enforce them.
|
|
6
7
|
*
|
|
@@ -18,15 +19,16 @@ import { reportDiagnostic } from "./lib.js";
|
|
|
18
19
|
*/
|
|
19
20
|
export function applyConstraints(program, expression, target) {
|
|
20
21
|
let result = expression;
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
22
|
+
const read = (get) => declaredOn(program, target, get);
|
|
23
|
+
const minLength = read(getMinLength);
|
|
24
|
+
const maxLength = read(getMaxLength);
|
|
25
|
+
const minValue = read(getMinValue);
|
|
26
|
+
const maxValue = read(getMaxValue);
|
|
27
|
+
const minItems = read(getMinItems);
|
|
28
|
+
const maxItems = read(getMaxItems);
|
|
29
|
+
const pattern = read(getPattern);
|
|
30
|
+
const minExclusive = read(getMinValueExclusive);
|
|
31
|
+
const maxExclusive = read(getMaxValueExclusive);
|
|
30
32
|
/**
|
|
31
33
|
* **A numeric bound says nothing about a value carried as text.** `@encode(string)` is legal on a
|
|
32
34
|
* numeric property and makes the wire value a string, so the expression here is `z.string()` while
|
|
@@ -72,6 +74,30 @@ export function applyConstraints(program, expression, target) {
|
|
|
72
74
|
}
|
|
73
75
|
return result;
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* **A constraint on a scalar is read up its `extends` chain: the nearest scalar declaring it wins.**
|
|
79
|
+
*
|
|
80
|
+
* The compiler's getters read the decorators of the one type they are handed, and this read the
|
|
81
|
+
* scalar a property NAMED. So `scalar ShortWord extends Word` with `@pattern` on `Word` validated
|
|
82
|
+
* as a bare `z.string()`, and every value the pattern forbids was accepted - while the document
|
|
83
|
+
* published the pattern on `ShortWord`. `@typespec/openapi3`'s `#getSchemaForScalar` builds a
|
|
84
|
+
* derived scalar from its base's schema and then applies its own decorators over it, so a keyword
|
|
85
|
+
* the derived scalar declares REPLACES the base's rather than adding to it. Reading the nearest
|
|
86
|
+
* declaration per keyword is that rule exactly: `Overrides extends Word` with its own `@pattern`
|
|
87
|
+
* publishes only its own, and enforcing both would refuse what the document accepts.
|
|
88
|
+
*
|
|
89
|
+
* A property is not a chain: its type's constraints are applied where the type is rendered.
|
|
90
|
+
*/
|
|
91
|
+
function declaredOn(program, target, get) {
|
|
92
|
+
if (target.kind !== "Scalar")
|
|
93
|
+
return get(program, target);
|
|
94
|
+
for (let current = target; current !== undefined; current = current.baseScalar) {
|
|
95
|
+
const value = get(program, current);
|
|
96
|
+
if (value !== undefined)
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
75
101
|
/**
|
|
76
102
|
* Whether the value this type puts on the wire is text because `@encode` says so. Read from the
|
|
77
103
|
* property and then from its scalar, which is where an encoding declared once for a named scalar
|
|
@@ -87,18 +113,21 @@ function encodesAsString(program, target) {
|
|
|
87
113
|
return (own ?? scalar)?.type.name === "string";
|
|
88
114
|
}
|
|
89
115
|
/**
|
|
90
|
-
* How
|
|
116
|
+
* How much analysis one pattern gets before the answer is "could not prove it": a number of steps.
|
|
91
117
|
*
|
|
92
|
-
* **Bounded, because the analysis is itself exponential in the bad case
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
118
|
+
* **Bounded, because the analysis is itself exponential in the bad case, and bounded in STEPS,
|
|
119
|
+
* because a verdict must not depend on the machine.** It was 500 ms of wall-clock time, and on a
|
|
120
|
+
* loaded CI host `^([0-9a-f]{40}|[0-9a-f]{64})$` took 93 to 403 ms and once ran out: the same spec
|
|
121
|
+
* warned on one build and not the next. A step count is the same everywhere. `redos-detector`'s own
|
|
122
|
+
* default; measured, `^(a|a)+$` spends it in 1.7 seconds idle and 6 under load, and every ordinary
|
|
123
|
+
* pattern decides in milliseconds. A pattern that cannot be decided inside it is one this emitter
|
|
124
|
+
* cannot certify, which is what the warning says.
|
|
96
125
|
*/
|
|
97
|
-
const
|
|
126
|
+
const ANALYSIS_STEPS = defaultMaxSteps;
|
|
98
127
|
/**
|
|
99
128
|
* Patterns already judged, keyed on the text, which is the only input the verdict depends on.
|
|
100
129
|
*
|
|
101
|
-
* The analysis costs up to {@link
|
|
130
|
+
* The analysis costs up to {@link ANALYSIS_STEPS} steps a pattern, and one pattern text commonly
|
|
102
131
|
* recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
|
|
103
132
|
* consumer spec - so each distinct text is analysed once per process.
|
|
104
133
|
*/
|
|
@@ -162,7 +191,7 @@ function firstReportOf(program, code, target) {
|
|
|
162
191
|
*
|
|
163
192
|
* **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
|
|
164
193
|
* `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
|
|
165
|
-
* the
|
|
194
|
+
* the step budget is judged the same way as one it decides against. Measured over a realistic spread of
|
|
166
195
|
* 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
|
|
167
196
|
* overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
|
|
168
197
|
* ordinary and takes **11 seconds** on a 29-character input.
|
|
@@ -173,7 +202,8 @@ export function patternIsProvablySafe(pattern) {
|
|
|
173
202
|
try {
|
|
174
203
|
safe = isSafePattern(pattern, {
|
|
175
204
|
unicode: unicodeSafe(pattern),
|
|
176
|
-
|
|
205
|
+
maxSteps: ANALYSIS_STEPS,
|
|
206
|
+
timeout: Number.POSITIVE_INFINITY,
|
|
177
207
|
}).safe;
|
|
178
208
|
}
|
|
179
209
|
catch {
|
|
@@ -206,7 +236,7 @@ export function patternIsProvablySafe(pattern) {
|
|
|
206
236
|
* instead of guessing at an equivalence.
|
|
207
237
|
*/
|
|
208
238
|
export function patternToRegex(pattern) {
|
|
209
|
-
return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
239
|
+
return `/${escapeNonAscii(escapeSlashes(pattern))}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
210
240
|
}
|
|
211
241
|
/**
|
|
212
242
|
* **Escapes the slashes a regex literal needs escaped, and only those.**
|
|
@@ -228,7 +258,15 @@ function escapeSlashes(pattern) {
|
|
|
228
258
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
229
259
|
const character = pattern[index];
|
|
230
260
|
if (character === "\\") {
|
|
231
|
-
|
|
261
|
+
const next = pattern[index + 1] ?? "";
|
|
262
|
+
/**
|
|
263
|
+
* **An identity escape of a character outside printable ASCII loses its backslash**, because
|
|
264
|
+
* the character is about to be written as `\uXXXX` and `\\uXXXX` would be a literal
|
|
265
|
+
* backslash followed by `u`. An accented letter matches the same thing escaped or not; under
|
|
266
|
+
* the `u` flag the escape is a syntax error, and `unicodeSafe` has already sent such a
|
|
267
|
+
* pattern to the bare form.
|
|
268
|
+
*/
|
|
269
|
+
escaped += /[^\x20-\x7e]/.test(next) ? next : character + next;
|
|
232
270
|
index += 1;
|
|
233
271
|
continue;
|
|
234
272
|
}
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -162,6 +162,12 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
162
162
|
"unenforceable-encoded-bound": {
|
|
163
163
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
164
164
|
};
|
|
165
|
+
"unenforceable-property-name": {
|
|
166
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
167
|
+
};
|
|
168
|
+
"optional-prototype-name": {
|
|
169
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
170
|
+
};
|
|
165
171
|
"duplicate-operation-id": {
|
|
166
172
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
167
173
|
};
|
|
@@ -172,7 +178,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
172
178
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
173
179
|
};
|
|
174
180
|
}, EmitterOptions, never>;
|
|
175
|
-
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
181
|
+
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
176
182
|
"unsupported-type": {
|
|
177
183
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
178
184
|
};
|
|
@@ -206,6 +212,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
206
212
|
"unenforceable-encoded-bound": {
|
|
207
213
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
208
214
|
};
|
|
215
|
+
"unenforceable-property-name": {
|
|
216
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
217
|
+
};
|
|
218
|
+
"optional-prototype-name": {
|
|
219
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
220
|
+
};
|
|
209
221
|
"duplicate-operation-id": {
|
|
210
222
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
211
223
|
};
|
|
@@ -249,6 +261,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
249
261
|
"unenforceable-encoded-bound": {
|
|
250
262
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
251
263
|
};
|
|
264
|
+
"unenforceable-property-name": {
|
|
265
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
266
|
+
};
|
|
267
|
+
"optional-prototype-name": {
|
|
268
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
269
|
+
};
|
|
252
270
|
"duplicate-operation-id": {
|
|
253
271
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
254
272
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -297,6 +297,48 @@ const diagnostics = {
|
|
|
297
297
|
default: paramMessage `The bound on '${"name"}' cannot be enforced, and is not emitted. '@encode(string)' makes the wire value text, so '@minValue'/'@maxValue' would become a check on how many CHARACTERS it has - '@minValue(10)' would refuse '"42"' - and the exclusive form has no string equivalent at all. The document is no help either: it publishes 'minimum' on a 'type: string' schema, which JSON Schema ignores, so a validator enforcing the bound would refuse what the published contract accepts. Declare the bound as a '@pattern', which the document publishes and every caller can read, or remove '@encode(string)' and let the value be a number.`,
|
|
298
298
|
},
|
|
299
299
|
},
|
|
300
|
+
/**
|
|
301
|
+
* **A property whose wire name is `__proto__` is published, and no Zod validator can check it.**
|
|
302
|
+
*
|
|
303
|
+
* Zod skips a declared `__proto__` key on purpose, so prototype pollution cannot reach through a
|
|
304
|
+
* parse (colinhacks/zod#6386): a value of any type, or none, is accepted for it and dropped from
|
|
305
|
+
* the result. The document publishes it like any other property, and nothing emitted can enforce
|
|
306
|
+
* it - which is the case `unenforceable-encoded-bound` reports for a bound, for the same reason:
|
|
307
|
+
* the spec is representable, so refusing it would make it emit under one emitter and not the
|
|
308
|
+
* other, and serving it silently would leave a validator looser than its contract unsaid.
|
|
309
|
+
*
|
|
310
|
+
* The key is still DECLARED, as a computed `["__proto__"]`, because a plain `__proto__:` in an
|
|
311
|
+
* object literal sets the shape's prototype instead. Measured: that dropped the property from the
|
|
312
|
+
* published JSON Schema, and under `seal-object-schemas` refused every body carrying it as an
|
|
313
|
+
* unrecognised key - the opposite of what the document says.
|
|
314
|
+
*/
|
|
315
|
+
"unenforceable-property-name": {
|
|
316
|
+
severity: "warning",
|
|
317
|
+
messages: {
|
|
318
|
+
default: paramMessage `'${"name"}' is published as a property and cannot be validated: Zod skips a key named '__proto__' on purpose (colinhacks/zod#6386), so any value, or none, is accepted for it and dropped from the parsed result. The published schema still states it. Give it another wire name if its value has to be checked.`,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
/**
|
|
322
|
+
* **An optional property named after an `Object.prototype` member refuses every value that
|
|
323
|
+
* leaves it out.**
|
|
324
|
+
*
|
|
325
|
+
* Zod reads a property as `input[key]`, so an absent `constructor`, `toString`, `valueOf` or any
|
|
326
|
+
* other inherited member reads as the function every object inherits, and is refused as the wrong
|
|
327
|
+
* type (colinhacks/zod#6666, open). A required one is unaffected, because a value that leaves it
|
|
328
|
+
* out is refused either way. The emitted validators are right and inherit the defect - on
|
|
329
|
+
* requests and responses alike - and the only repair in emitted code, a `z.preprocess` onto a
|
|
330
|
+
* null-prototype copy, would cost the model `.shape` and `.extend`.
|
|
331
|
+
*
|
|
332
|
+
* Reported where the property is optional, which includes the PATCH position that makes a
|
|
333
|
+
* required property optional. When Zod ships the fix, the peer floor moves to it and this retires:
|
|
334
|
+
* `test/inheritednames` pins the defect so that release is noticed.
|
|
335
|
+
*/
|
|
336
|
+
"optional-prototype-name": {
|
|
337
|
+
severity: "warning",
|
|
338
|
+
messages: {
|
|
339
|
+
default: paramMessage `'${"name"}' is optional here, and the validator refuses every value that leaves it out: Zod reads an absent '${"name"}' as the member every object inherits from Object.prototype, a function, and rejects it (colinhacks/zod#6666). A value that carries '${"name"}' validates normally. Give it a wire name Object.prototype does not have, or declare it required, until Zod releases a fix.`,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
300
342
|
"duplicate-operation-id": {
|
|
301
343
|
severity: "error",
|
|
302
344
|
messages: {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **A value as a JavaScript literal whose source is ASCII.**
|
|
3
|
+
*
|
|
4
|
+
* `JSON.stringify` is the right spelling for spec text that becomes code - a key, an enum value, a
|
|
5
|
+
* media type, a description - because it quotes and escapes exactly as a string literal needs. It
|
|
6
|
+
* leaves every other character as it is, and no file this package generates may carry one: a
|
|
7
|
+
* consumer commits the output, and an invisible character in it has held a block comment open past
|
|
8
|
+
* its terminator in the sibling package. `test/portability.test.ts` grades every emitted file.
|
|
9
|
+
*
|
|
10
|
+
* Measured once descriptions reached the output: `@typespec/http-specs`' `payload/xml` documents its
|
|
11
|
+
* models as `"\u00a71.1 \u2014 Contains fields of primitive types."`, and the section sign and the dash went
|
|
12
|
+
* straight into `schemas.gen.ts`. Every other site had the same exposure and was ASCII only because
|
|
13
|
+
* no spec in the corpus happened to put a non-ASCII character in a key or a value.
|
|
14
|
+
*
|
|
15
|
+
* So every character outside printable ASCII is written as a `\uXXXX` escape: the same string at
|
|
16
|
+
* run time, in either quote position, and in a TypeScript literal type. An astral character is two
|
|
17
|
+
* UTF-16 units, escaped one at a time, which is how JavaScript spells it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function literal(value: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* Every UTF-16 unit outside printable ASCII as a `\uXXXX` escape. A string literal and a regular
|
|
22
|
+
* expression read the escape alike, with or without the `u` flag: under it, an escaped surrogate pair
|
|
23
|
+
* is the one code point the pair spells.
|
|
24
|
+
*/
|
|
25
|
+
export declare function escapeNonAscii(source: string): string;
|
|
26
|
+
/** One UTF-16 unit as `\uXXXX`. */
|
|
27
|
+
export declare function unicodeEscape(unit: string): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **A value as a JavaScript literal whose source is ASCII.**
|
|
3
|
+
*
|
|
4
|
+
* `JSON.stringify` is the right spelling for spec text that becomes code - a key, an enum value, a
|
|
5
|
+
* media type, a description - because it quotes and escapes exactly as a string literal needs. It
|
|
6
|
+
* leaves every other character as it is, and no file this package generates may carry one: a
|
|
7
|
+
* consumer commits the output, and an invisible character in it has held a block comment open past
|
|
8
|
+
* its terminator in the sibling package. `test/portability.test.ts` grades every emitted file.
|
|
9
|
+
*
|
|
10
|
+
* Measured once descriptions reached the output: `@typespec/http-specs`' `payload/xml` documents its
|
|
11
|
+
* models as `"\u00a71.1 \u2014 Contains fields of primitive types."`, and the section sign and the dash went
|
|
12
|
+
* straight into `schemas.gen.ts`. Every other site had the same exposure and was ASCII only because
|
|
13
|
+
* no spec in the corpus happened to put a non-ASCII character in a key or a value.
|
|
14
|
+
*
|
|
15
|
+
* So every character outside printable ASCII is written as a `\uXXXX` escape: the same string at
|
|
16
|
+
* run time, in either quote position, and in a TypeScript literal type. An astral character is two
|
|
17
|
+
* UTF-16 units, escaped one at a time, which is how JavaScript spells it.
|
|
18
|
+
*/
|
|
19
|
+
export function literal(value) {
|
|
20
|
+
return escapeNonAscii(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Every UTF-16 unit outside printable ASCII as a `\uXXXX` escape. A string literal and a regular
|
|
24
|
+
* expression read the escape alike, with or without the `u` flag: under it, an escaped surrogate pair
|
|
25
|
+
* is the one code point the pair spells.
|
|
26
|
+
*/
|
|
27
|
+
export function escapeNonAscii(source) {
|
|
28
|
+
return source.replace(/[^\x20-\x7e]/g, unicodeEscape);
|
|
29
|
+
}
|
|
30
|
+
/** One UTF-16 unit as `\uXXXX`. */
|
|
31
|
+
export function unicodeEscape(unit) {
|
|
32
|
+
return `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
33
|
+
}
|
package/dist/src/registry.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { getFriendlyName, } from "@typespec/compiler";
|
|
1
|
+
import { getDoc, getFriendlyName, } from "@typespec/compiler";
|
|
2
2
|
import { getVisibilitySuffix, Visibility } from "@typespec/http";
|
|
3
3
|
import { reportDiagnostic } from "./lib.js";
|
|
4
4
|
import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
|
|
5
|
-
import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
5
|
+
import { captureBackEdges, currentVisibility, described, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
6
6
|
/**
|
|
7
7
|
* The name an ANONYMOUS payload model inherits from the single model it spreads.
|
|
8
8
|
*
|
|
@@ -134,16 +134,37 @@ const RESERVED_DECLARATION_NAMES = new Set([
|
|
|
134
134
|
"with",
|
|
135
135
|
"yield",
|
|
136
136
|
]);
|
|
137
|
+
/**
|
|
138
|
+
* **The names the emitted files declare for THEMSELVES, beside the spec's own declarations.**
|
|
139
|
+
*
|
|
140
|
+
* `requests.gen.ts` declares `Produced`, `Simplify`, `ByteStream` and its three lookups; `schemas.gen.ts`
|
|
141
|
+
* declares `ResponseArm`. A model named after one of them was declared twice: `TS2300` for a type
|
|
142
|
+
* alias, and for `WireInputs` or `WireOutputs` something worse, because two interfaces of one name
|
|
143
|
+
* MERGE - the model's properties silently became members of the lookup. So a spec's model of that
|
|
144
|
+
* name is renamed exactly as a reserved word is.
|
|
145
|
+
*
|
|
146
|
+
* Graded by `test/collisions/`, which reads the names an emitted file declares off the OUTPUT rather
|
|
147
|
+
* than off this set, so a helper added later without a line here fails a test.
|
|
148
|
+
*/
|
|
149
|
+
const EMITTED_HELPER_NAMES = new Set([
|
|
150
|
+
"ByteStream",
|
|
151
|
+
"Produced",
|
|
152
|
+
"ResponseArm",
|
|
153
|
+
"Simplify",
|
|
154
|
+
"WireInputs",
|
|
155
|
+
"WireOutputs",
|
|
156
|
+
"WireResults",
|
|
157
|
+
]);
|
|
137
158
|
/**
|
|
138
159
|
* The name a declaration is emitted under.
|
|
139
160
|
*
|
|
140
|
-
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration
|
|
141
|
-
* applied in ONE place so both walks agree:
|
|
142
|
-
* contract type by name, and a mangling applied to
|
|
143
|
-
* than the file.
|
|
161
|
+
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration, or is one
|
|
162
|
+
* the emitted file already declares for itself. It is applied in ONE place so both walks agree:
|
|
163
|
+
* `wire-contract.gen.ts` pairs a validator against a contract type by name, and a mangling applied to
|
|
164
|
+
* one side only would break that assertion rather than the file.
|
|
144
165
|
*/
|
|
145
166
|
function safeDeclarationName(name) {
|
|
146
|
-
return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
|
|
167
|
+
return RESERVED_DECLARATION_NAMES.has(name) || EMITTED_HELPER_NAMES.has(name) ? `${name}_` : name;
|
|
147
168
|
}
|
|
148
169
|
/** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
|
|
149
170
|
function declaredNameOf(program, type) {
|
|
@@ -373,7 +394,17 @@ export class SchemaRegistry {
|
|
|
373
394
|
* and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
|
|
374
395
|
* instead, which is the only place left to put the deferral once no property can hold it.
|
|
375
396
|
*/
|
|
376
|
-
|
|
397
|
+
/**
|
|
398
|
+
* **The description goes on the declaration's own body, so the exported const IS the
|
|
399
|
+
* described schema.** Every reference then names that one instance, which is what keeps
|
|
400
|
+
* identity, `z.compile`'s clone (it inherits the description through its parent link) and
|
|
401
|
+
* Zod's `$ref` extraction all intact. Described at a reference instead, each use would be a
|
|
402
|
+
* fresh clone. Read from the canonical type, because an anonymous payload has its doc only on
|
|
403
|
+
* the model it was spread from, and a visibility projection carries the doc of the model it
|
|
404
|
+
* projects - as openapi3 publishes one component for both.
|
|
405
|
+
*/
|
|
406
|
+
const body = rendered.deferred ? `z.lazy(() => ${rendered.value})` : rendered.value;
|
|
407
|
+
const source = described(body, getDoc(this.#program, canonicalTypeFor(type)));
|
|
377
408
|
const declaration = {
|
|
378
409
|
identifier,
|
|
379
410
|
typeName: name,
|
|
@@ -502,7 +533,20 @@ export class SchemaRegistry {
|
|
|
502
533
|
* drift.
|
|
503
534
|
*/
|
|
504
535
|
#structuralBodyOf(type) {
|
|
505
|
-
return withTsRefResolver((candidate) =>
|
|
536
|
+
return withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => typeToTsBody(this.#program, type));
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* **The TypeScript name a reference resolves to is the one {@link expressionFor} declares it
|
|
540
|
+
* under**, visibility suffix included. Resolved by the bare name, a cycle through a model a
|
|
541
|
+
* request projects typed the projection's back edges by the READ shape: `NodeCreate`'s
|
|
542
|
+
* `children` became `Node[]`, which carries a property the request never sends, and the declared
|
|
543
|
+
* validator did not satisfy its own annotation (`TS2375`).
|
|
544
|
+
*/
|
|
545
|
+
#typeNameOf(candidate) {
|
|
546
|
+
const bare = declaredNameOf(this.#program, candidate);
|
|
547
|
+
if (bare === undefined)
|
|
548
|
+
return undefined;
|
|
549
|
+
return nameAt(bare, visibilityFor(this.#program, candidate, currentVisibility()));
|
|
506
550
|
}
|
|
507
551
|
/**
|
|
508
552
|
* Render `type` without consulting its own declaration, but letting nested types use theirs.
|
|
@@ -546,7 +590,7 @@ export class SchemaRegistry {
|
|
|
546
590
|
#deferredAnnotationOf(property) {
|
|
547
591
|
if (property.defaultValue !== undefined)
|
|
548
592
|
return undefined;
|
|
549
|
-
const declared = withTsRefResolver((candidate) =>
|
|
593
|
+
const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property));
|
|
550
594
|
const core = `z.ZodType<${declared}, ${declared}>`;
|
|
551
595
|
const optional = property.optional || isOptionalAt(this.#program, property);
|
|
552
596
|
return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **What a result is made of: one member per declared response and media type, and the codes each
|
|
3
|
+
* one answers with.** One derivation, read by every artefact that states a result.
|
|
4
|
+
*
|
|
5
|
+
* `typespec-hono` types a handler's return as a union of these members, `typespec-http-mcp` types a
|
|
6
|
+
* tool's the same way, and `requests.gen.ts` states the same union for a backend that has no
|
|
7
|
+
* framework to import. All three used to hold their own copy of these rules, and three copies of
|
|
8
|
+
* rules that must agree are three that will not: a status one of them could see and another could
|
|
9
|
+
* not is how `WireOutputs` came to publish one success body for an operation answering two.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A status as the DOCUMENT keys it: an exact code, a `4XX` bucket, or the catch-all.
|
|
13
|
+
*
|
|
14
|
+
* `"default"` is kept as itself rather than mapped to a number. The document means "any status not
|
|
15
|
+
* listed above", and inventing one would be answering a question the contract left open.
|
|
16
|
+
*/
|
|
17
|
+
export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
18
|
+
/**
|
|
19
|
+
* The facts about one declared response that decide how its result is shaped.
|
|
20
|
+
*
|
|
21
|
+
* Structural, so both an emitted response (`EmittedResponse`, whose body is a Zod expression) and a
|
|
22
|
+
* resolved one (whose body is a TypeSpec type) satisfy it; the caller says which ones carry a body.
|
|
23
|
+
*/
|
|
24
|
+
export interface ResponseFacts {
|
|
25
|
+
readonly status: StatusKey;
|
|
26
|
+
readonly contentTypes: readonly string[];
|
|
27
|
+
readonly binary: boolean;
|
|
28
|
+
readonly streamed: boolean;
|
|
29
|
+
readonly textual: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** How a member's body is served, which decides both its type and the call that serves it. */
|
|
32
|
+
export type BodyServing = "none" | "stream" | "binary" | "json" | "text" | "unvalidated";
|
|
33
|
+
/**
|
|
34
|
+
* One member of an operation's result: one response, served as one media type.
|
|
35
|
+
*
|
|
36
|
+
* **A status offering several media types is several members**, because the body differs by media
|
|
37
|
+
* type: the Swagger Petstore answers `getPetById` with a `Pet` as `application/json` and with text as
|
|
38
|
+
* `application/xml`. One member with a union body could not say which body goes with which type.
|
|
39
|
+
*/
|
|
40
|
+
export interface ResultMember<R extends ResponseFacts> {
|
|
41
|
+
readonly response: R;
|
|
42
|
+
/** The response's position in the list it came from, so a caller can find what it names it by. */
|
|
43
|
+
readonly index: number;
|
|
44
|
+
/** The media type served, or `undefined` where the response has no body. */
|
|
45
|
+
readonly mediaType: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the result names the media type: the status offers more than one, or the document names
|
|
48
|
+
* a range such as `image/*`, which is not a type a response can be sent as.
|
|
49
|
+
*/
|
|
50
|
+
readonly choosesMediaType: boolean;
|
|
51
|
+
readonly serving: BodyServing;
|
|
52
|
+
/**
|
|
53
|
+
* **The document declares a schema for this body that nothing checks it against**: a model under a
|
|
54
|
+
* media type that is not JSON, which the handler supplies as text (`"unvalidated"`) or, under a
|
|
55
|
+
* type that is not text, as bytes (`"binary"`). A `bytes` or `Http.File` body declares nothing to
|
|
56
|
+
* check, so it is not this.
|
|
57
|
+
*/
|
|
58
|
+
readonly unvalidated: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
62
|
+
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
63
|
+
* comparison of a declared media type is of this. `application/json; charset=utf-8` and
|
|
64
|
+
* `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
|
|
65
|
+
* and the response an unvalidated string.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mediaTypeEssenceOf(type: string): string;
|
|
68
|
+
/** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
|
|
69
|
+
export declare function isJsonMediaType(type: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
|
|
72
|
+
* section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
|
|
73
|
+
*
|
|
74
|
+
* **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
|
|
75
|
+
* has no reader or serialisation this emitter can derive, so it is handed over as the text that
|
|
76
|
+
* arrived and answered as the text the handler writes. Under any other type, such as
|
|
77
|
+
* `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
|
|
78
|
+
* every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
|
|
79
|
+
* well, and is parsed rather than handed over, so it never reaches this question.
|
|
80
|
+
*/
|
|
81
|
+
export declare function isTextualMediaType(type: string): boolean;
|
|
82
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
83
|
+
export declare function isMediaRange(mediaType: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The type of the `contentType` a result names for a member.
|
|
86
|
+
*
|
|
87
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
88
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
89
|
+
* full range is `string`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function mediaTypeTypeOf(mediaType: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* Every member an operation's responses declare, in the order the responses are listed.
|
|
94
|
+
*
|
|
95
|
+
* `hasBody` says which responses carry a body, because the two kinds of record spell that
|
|
96
|
+
* differently - an emitted one has a schema, a resolved one has a type.
|
|
97
|
+
*/
|
|
98
|
+
export declare function resultMembersOf<R extends ResponseFacts>(responses: readonly R[], hasBody: (response: R) => boolean): ResultMember<R>[];
|
|
99
|
+
/**
|
|
100
|
+
* The codes a declared response answers with, written out.
|
|
101
|
+
*
|
|
102
|
+
* - An exact code is itself.
|
|
103
|
+
* - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
|
|
104
|
+
* before its range, as a server does - and, where it carries a body, less the codes that cannot.
|
|
105
|
+
* - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
|
|
106
|
+
* again less the contentless codes where it carries a body. `1xx` is not a result a handler
|
|
107
|
+
* returns.
|
|
108
|
+
*
|
|
109
|
+
* **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
|
|
110
|
+
* narrow a result on a status type that admits everything. Empty where nothing is left, which a
|
|
111
|
+
* caller renders as `never`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function statusCodesOf<R extends ResponseFacts>(response: R, responses: readonly R[], hasBody: (response: R) => boolean): readonly number[];
|