typespec-http-zod 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api.d.ts +43 -0
- package/dist/src/api.js +342 -57
- package/dist/src/constraints.d.ts +15 -0
- package/dist/src/constraints.js +46 -12
- package/dist/src/lib.d.ts +37 -1
- package/dist/src/lib.js +80 -0
- package/dist/src/literal.d.ts +27 -0
- package/dist/src/literal.js +33 -0
- package/dist/src/registry.js +30 -7
- package/dist/src/results.js +2 -1
- package/dist/src/types.js +34 -10
- package/dist/src/uri-template.d.ts +0 -12
- package/dist/src/uri-template.js +3 -2
- package/dist/src/zod.d.ts +78 -0
- package/dist/src/zod.js +366 -35
- package/package.json +1 -1
|
@@ -15,6 +15,21 @@ import { type Program, type Type } from "@typespec/compiler";
|
|
|
15
15
|
* asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
|
|
16
16
|
*/
|
|
17
17
|
export declare function applyConstraints(program: Program, expression: string, target: Type): string;
|
|
18
|
+
/**
|
|
19
|
+
* **Whether this diagnostic has already been raised against this declaration.**
|
|
20
|
+
*
|
|
21
|
+
* Keyed on the code as well as the target. Keying on the target alone once made the memo shared
|
|
22
|
+
* between every pattern diagnostic, so whichever ran first claimed the declaration and silenced
|
|
23
|
+
* the rest: measured on `^(\w+\s?)*\-$`, one warning where two were due. The ReDoS judgement has
|
|
24
|
+
* since moved to a linter rule, which reports per declaration by construction, so that pairing can
|
|
25
|
+
* no longer meet here - but a memo keyed on the target alone is the wrong shape for the next
|
|
26
|
+
* diagnostic added beside this one, and costs nothing to keep right.
|
|
27
|
+
*
|
|
28
|
+
* The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
|
|
29
|
+
* 153 times for a single scalar in the reference service - and repeating one warning that often
|
|
30
|
+
* is noise a reader learns to skip, which is how a real one gets missed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function firstReportOf(program: Program, code: string, target: Type): boolean;
|
|
18
33
|
/**
|
|
19
34
|
* **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
|
|
20
35
|
*
|
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
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
|
|
@@ -134,7 +160,7 @@ const reported = new WeakMap();
|
|
|
134
160
|
* 153 times for a single scalar in the reference service - and repeating one warning that often
|
|
135
161
|
* is noise a reader learns to skip, which is how a real one gets missed.
|
|
136
162
|
*/
|
|
137
|
-
function firstReportOf(program, code, target) {
|
|
163
|
+
export function firstReportOf(program, code, target) {
|
|
138
164
|
let byCode = reported.get(program);
|
|
139
165
|
if (byCode === undefined) {
|
|
140
166
|
byCode = new Map();
|
|
@@ -210,7 +236,7 @@ export function patternIsProvablySafe(pattern) {
|
|
|
210
236
|
* instead of guessing at an equivalence.
|
|
211
237
|
*/
|
|
212
238
|
export function patternToRegex(pattern) {
|
|
213
|
-
return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
239
|
+
return `/${escapeNonAscii(escapeSlashes(pattern))}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
214
240
|
}
|
|
215
241
|
/**
|
|
216
242
|
* **Escapes the slashes a regex literal needs escaped, and only those.**
|
|
@@ -232,7 +258,15 @@ function escapeSlashes(pattern) {
|
|
|
232
258
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
233
259
|
const character = pattern[index];
|
|
234
260
|
if (character === "\\") {
|
|
235
|
-
|
|
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;
|
|
236
270
|
index += 1;
|
|
237
271
|
continue;
|
|
238
272
|
}
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -162,17 +162,29 @@ 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
|
};
|
|
168
174
|
"inline-cycle": {
|
|
169
175
|
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
170
176
|
};
|
|
177
|
+
"encode-cycle": {
|
|
178
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
|
|
179
|
+
};
|
|
180
|
+
"shared-input-key": {
|
|
181
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
|
|
182
|
+
};
|
|
171
183
|
"duplicate-declaration": {
|
|
172
184
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
173
185
|
};
|
|
174
186
|
}, 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 {
|
|
187
|
+
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "encode-cycle" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "shared-input-key" | "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
188
|
"unsupported-type": {
|
|
177
189
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
178
190
|
};
|
|
@@ -206,12 +218,24 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
206
218
|
"unenforceable-encoded-bound": {
|
|
207
219
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
208
220
|
};
|
|
221
|
+
"unenforceable-property-name": {
|
|
222
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
223
|
+
};
|
|
224
|
+
"optional-prototype-name": {
|
|
225
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
226
|
+
};
|
|
209
227
|
"duplicate-operation-id": {
|
|
210
228
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
211
229
|
};
|
|
212
230
|
"inline-cycle": {
|
|
213
231
|
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
214
232
|
};
|
|
233
|
+
"encode-cycle": {
|
|
234
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
|
|
235
|
+
};
|
|
236
|
+
"shared-input-key": {
|
|
237
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
|
|
238
|
+
};
|
|
215
239
|
"duplicate-declaration": {
|
|
216
240
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
217
241
|
};
|
|
@@ -249,12 +273,24 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
249
273
|
"unenforceable-encoded-bound": {
|
|
250
274
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
251
275
|
};
|
|
276
|
+
"unenforceable-property-name": {
|
|
277
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
278
|
+
};
|
|
279
|
+
"optional-prototype-name": {
|
|
280
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
281
|
+
};
|
|
252
282
|
"duplicate-operation-id": {
|
|
253
283
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
254
284
|
};
|
|
255
285
|
"inline-cycle": {
|
|
256
286
|
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
257
287
|
};
|
|
288
|
+
"encode-cycle": {
|
|
289
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
|
|
290
|
+
};
|
|
291
|
+
"shared-input-key": {
|
|
292
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
|
|
293
|
+
};
|
|
258
294
|
"duplicate-declaration": {
|
|
259
295
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
260
296
|
};
|
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: {
|
|
@@ -338,6 +380,44 @@ const diagnostics = {
|
|
|
338
380
|
default: paramMessage `'${"type"}' closes a reference cycle without passing through anything this emitter can declare, so the cycle has no name to reference itself by. A template instantiation is inlined rather than declared, exactly as '@typespec/openapi3' inlines it, and that emitter refuses the same spec with its own 'inline-cycle' error. Give the type a name with '@friendlyName' so it becomes a declaration, or break the cycle.`,
|
|
339
381
|
},
|
|
340
382
|
},
|
|
383
|
+
/**
|
|
384
|
+
* **An `@encode` chain that returns to where it started never reaches a wire type.**
|
|
385
|
+
*
|
|
386
|
+
* `@encode("x", Pong) scalar Ping` and `@encode("x", Ping) scalar Pong` compile cleanly - the
|
|
387
|
+
* compiler checks neither the encoding's name nor where its type leads - and the walks here
|
|
388
|
+
* followed `@encode` by calling themselves on the encoded type. Measured before this existed:
|
|
389
|
+
* `RangeError: Maximum call stack size exceeded`, "Emitter crashed! This is a bug."
|
|
390
|
+
*
|
|
391
|
+
* **An error, as `inline-cycle` is.** A value of such a scalar has no form on the wire to validate,
|
|
392
|
+
* so there is no validator to emit. openapi3 reads one link of the chain and publishes a schema
|
|
393
|
+
* with no `type` at all - `{ format: "x" }` - which states nothing about the value either.
|
|
394
|
+
*/
|
|
395
|
+
"encode-cycle": {
|
|
396
|
+
severity: "error",
|
|
397
|
+
messages: {
|
|
398
|
+
default: paramMessage `'${"type"}' is encoded as ${"chain"}: the '@encode' chain returns to a scalar it already passed, so it never reaches a type that crosses the wire. Encode one of them as a standard scalar such as 'string', or break the cycle.`,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
/**
|
|
402
|
+
* **Two inputs of one operation handed to its handler under one key.**
|
|
403
|
+
*
|
|
404
|
+
* A handler's input is the path, query, header and cookie groups spread into one object, with the
|
|
405
|
+
* body merged or named beside them. `@header("x") a: string` beside `@query("x") b: int32` therefore
|
|
406
|
+
* reached the handler as one `x`, the header's string replacing the validated number, while the
|
|
407
|
+
* input was typed `number & string`. `@typespec/http` refuses only a path and a query parameter of
|
|
408
|
+
* one name; the rest compiled with no diagnostic (typespec-http-graphql, 2026-10-16).
|
|
409
|
+
*
|
|
410
|
+
* **A warning, because the spec is valid and openapi3 publishes it**: the parameters are distinct on
|
|
411
|
+
* the wire. It is the input this package states that cannot carry both, so the key is typed and
|
|
412
|
+
* validated as both declarations at once, and the contract types still compile. A server emitter
|
|
413
|
+
* reading `EmittedRoute.sharedInputKeys` declines to mount the operation.
|
|
414
|
+
*/
|
|
415
|
+
"shared-input-key": {
|
|
416
|
+
severity: "warning",
|
|
417
|
+
messages: {
|
|
418
|
+
default: paramMessage `'${"operation"}' takes the ${"first"} '${"key"}' and the ${"second"} '${"key"}', and a handler's input holds one value under each key, so '${"key"}' is stated as both declarations at once and accepts only a value satisfying both. Give one of them a different name.`,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
341
421
|
"duplicate-declaration": {
|
|
342
422
|
severity: "error",
|
|
343
423
|
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
|
*
|
|
@@ -394,7 +394,17 @@ export class SchemaRegistry {
|
|
|
394
394
|
* and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
|
|
395
395
|
* instead, which is the only place left to put the deferral once no property can hold it.
|
|
396
396
|
*/
|
|
397
|
-
|
|
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)));
|
|
398
408
|
const declaration = {
|
|
399
409
|
identifier,
|
|
400
410
|
typeName: name,
|
|
@@ -441,8 +451,8 @@ export class SchemaRegistry {
|
|
|
441
451
|
* them.
|
|
442
452
|
*
|
|
443
453
|
* **Subtypes are deliberately NOT walked here, because nothing would be walking them.** A
|
|
444
|
-
* discriminated base renders as
|
|
445
|
-
* already asks the registry for each one. A loop here looked load-bearing and was measured not to
|
|
454
|
+
* discriminated base renders as a dispatch over its subtypes - `z.discriminatedUnion`, beside a base
|
|
455
|
+
* arm where its discriminator is open - and rendering that already asks the registry for each one. A loop here looked load-bearing and was measured not to
|
|
446
456
|
* be: removing it left all 23 polymorphism and differential arms green.
|
|
447
457
|
*
|
|
448
458
|
* **Wrapped in {@link captureBackEdges} and the result discarded.** This call can land on a
|
|
@@ -523,7 +533,20 @@ export class SchemaRegistry {
|
|
|
523
533
|
* drift.
|
|
524
534
|
*/
|
|
525
535
|
#structuralBodyOf(type) {
|
|
526
|
-
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()));
|
|
527
550
|
}
|
|
528
551
|
/**
|
|
529
552
|
* Render `type` without consulting its own declaration, but letting nested types use theirs.
|
|
@@ -567,7 +590,7 @@ export class SchemaRegistry {
|
|
|
567
590
|
#deferredAnnotationOf(property) {
|
|
568
591
|
if (property.defaultValue !== undefined)
|
|
569
592
|
return undefined;
|
|
570
|
-
const declared = withTsRefResolver((candidate) =>
|
|
593
|
+
const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property));
|
|
571
594
|
const core = `z.ZodType<${declared}, ${declared}>`;
|
|
572
595
|
const optional = property.optional || isOptionalAt(this.#program, property);
|
|
573
596
|
return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
|
package/dist/src/results.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
2
|
+
import { literal } from "./literal.js";
|
|
2
3
|
/**
|
|
3
4
|
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
4
5
|
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
@@ -41,7 +42,7 @@ export function isMediaRange(mediaType) {
|
|
|
41
42
|
*/
|
|
42
43
|
export function mediaTypeTypeOf(mediaType) {
|
|
43
44
|
if (!isMediaRange(mediaType))
|
|
44
|
-
return
|
|
45
|
+
return literal(mediaType);
|
|
45
46
|
if (mediaType === "*/*")
|
|
46
47
|
return "string";
|
|
47
48
|
return `\`${mediaType.slice(0, -1)}\${string}\``;
|
package/dist/src/types.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
|
-
import {
|
|
3
|
+
import { literal } from "./literal.js";
|
|
4
|
+
import { discriminatedBaseOf, discriminatedSubtypes, effectiveIndexer, emitsDefault, encodeCycleOf, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
5
|
/**
|
|
5
6
|
* TypeSpec type -> plain TypeScript type text.
|
|
6
7
|
*
|
|
@@ -74,8 +75,12 @@ function refuse(program, type, why, remedy = "") {
|
|
|
74
75
|
function scalarToTs(program, scalar) {
|
|
75
76
|
// A scalar declaration can carry the encoding itself - see `scalarToZod`.
|
|
76
77
|
const encoded = encodedTypeOf(program, scalar);
|
|
77
|
-
if (encoded !== undefined && encoded !== scalar)
|
|
78
|
-
|
|
78
|
+
if (encoded !== undefined && encoded !== scalar) {
|
|
79
|
+
// Reported once, by the schema walk - see `encodeCycleOf`.
|
|
80
|
+
return encodeCycleOf(program, scalar) === undefined
|
|
81
|
+
? scalarToTs(program, encoded)
|
|
82
|
+
: UNREPRESENTABLE;
|
|
83
|
+
}
|
|
79
84
|
let current = scalar;
|
|
80
85
|
// Walk `extends` so `scalar TrimmedString extends string` resolves to the primitive underneath.
|
|
81
86
|
while (current !== undefined) {
|
|
@@ -102,7 +107,7 @@ function scalarToTs(program, scalar) {
|
|
|
102
107
|
}
|
|
103
108
|
function enumToTs(target) {
|
|
104
109
|
const values = [...target.members.values()].map((member) => member.value ?? member.name);
|
|
105
|
-
return values.map((value) =>
|
|
110
|
+
return values.map((value) => literal(value)).join(" | ");
|
|
106
111
|
}
|
|
107
112
|
function unionToTs(program, union) {
|
|
108
113
|
/**
|
|
@@ -115,7 +120,7 @@ function unionToTs(program, union) {
|
|
|
115
120
|
if (discriminated !== undefined && discriminated.defaultVariant === undefined) {
|
|
116
121
|
const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
|
|
117
122
|
const arms = [...discriminated.variants.entries()].map(([name, variant]) => {
|
|
118
|
-
const tag = `${objectKey(discriminatorPropertyName)}: ${
|
|
123
|
+
const tag = `${objectKey(discriminatorPropertyName)}: ${literal(name)};`;
|
|
119
124
|
const inner = typeToTs(program, variant);
|
|
120
125
|
// No injection: with no envelope the variant declares the discriminator itself, or the union
|
|
121
126
|
// is refused in `zod.ts`. Nothing is added that the document does not publish.
|
|
@@ -152,11 +157,26 @@ function modelToTs(program, model) {
|
|
|
152
157
|
* the emitter disagreeing with itself - one TypeSpec, walked twice - and this is the second defect
|
|
153
158
|
* of that class it has found. The first was a vocabulary inlined by one walk and referenced by the
|
|
154
159
|
* other.
|
|
160
|
+
*
|
|
161
|
+
* **An open base adds itself as the last arm**, as `modelToZod` does: a value whose discriminator
|
|
162
|
+
* names no subtype is the base's own object. The base arm's discriminator keeps its declared type
|
|
163
|
+
* here; TypeScript cannot say "a string other than these", and the validator's refinement does not
|
|
164
|
+
* change what it accepts as input, so `z.input` and this type stay identical.
|
|
155
165
|
*/
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
166
|
+
const base = discriminatedBaseOf(program, model);
|
|
167
|
+
if (base !== undefined) {
|
|
168
|
+
const arms = discriminatedSubtypes(program, model).map((derived) => typeToTs(program, derived));
|
|
169
|
+
if (base.open)
|
|
170
|
+
arms.push(modelObjectToTs(program, model, base));
|
|
171
|
+
return arms.join(" | ");
|
|
159
172
|
}
|
|
173
|
+
return modelObjectToTs(program, model);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A model as the object of its own properties, inherited ones included - for a discriminated base,
|
|
177
|
+
* its base arm, with the `string` discriminator openapi3 adds where the base declares none.
|
|
178
|
+
*/
|
|
179
|
+
function modelObjectToTs(program, model, base) {
|
|
160
180
|
const indexer = effectiveIndexer(model);
|
|
161
181
|
const indexerKey = indexer?.key.name;
|
|
162
182
|
// Inherited properties included - see `inheritedAndOwnProperties`. Both artefacts describe the
|
|
@@ -213,6 +233,10 @@ function modelToTs(program, model) {
|
|
|
213
233
|
.filter((property) => isPayloadProperty(program, property))
|
|
214
234
|
.filter((property) => !isNeverType(property.type))
|
|
215
235
|
.map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
|
|
236
|
+
if (base !== undefined &&
|
|
237
|
+
!declared.some((property) => resolveEncodedName(program, property, "application/json") === base.propertyName)) {
|
|
238
|
+
entries.push(`\t${objectKey(base.propertyName)}: string;`);
|
|
239
|
+
}
|
|
216
240
|
/**
|
|
217
241
|
* **The indexer is deliberately NOT intersected in.**
|
|
218
242
|
*
|
|
@@ -369,13 +393,13 @@ export function typeToTsBody(program, type) {
|
|
|
369
393
|
case "Union":
|
|
370
394
|
return unionToTs(program, type);
|
|
371
395
|
case "String":
|
|
372
|
-
return
|
|
396
|
+
return literal(type.value);
|
|
373
397
|
case "Number":
|
|
374
398
|
return String(type.value);
|
|
375
399
|
case "Boolean":
|
|
376
400
|
return String(type.value);
|
|
377
401
|
case "EnumMember":
|
|
378
|
-
return
|
|
402
|
+
return literal(type.value ?? type.name);
|
|
379
403
|
// A named union variant is a referable type - `DogKind.Golden`. See the note in `zod.ts`.
|
|
380
404
|
case "UnionVariant":
|
|
381
405
|
return typeToTs(program, type.type);
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
|
|
3
|
-
*
|
|
4
|
-
* **`HttpOperation.uriTemplate`, never `HttpOperation.path`.** `path` drops every operator, so
|
|
5
|
-
* `array{.param*}`, `array{;param}` and `array{/param}` all become `array{param}`, and a route mounted
|
|
6
|
-
* from it answers 404 to the URL its own contract declares. Measured on `@typespec/http-specs`
|
|
7
|
-
* `routes`: 30 of its mock server's URIs, plus the query-continuation six.
|
|
8
|
-
*
|
|
9
|
-
* `parseUriTemplate` is copied from `@typespec/http` 1.16.0 (`dist/src/uri-template.js`, MIT), which
|
|
10
|
-
* implements it but does not export it, so the template is split exactly as the library that built
|
|
11
|
-
* it splits it.
|
|
12
|
-
*/
|
|
13
1
|
/**
|
|
14
2
|
* One `/`-delimited segment of a route, as a server has to mount it.
|
|
15
3
|
*
|
package/dist/src/uri-template.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { literal } from "./literal.js";
|
|
1
2
|
/**
|
|
2
3
|
* RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
|
|
3
4
|
*
|
|
@@ -128,7 +129,7 @@ export function needsExpansionDecoding(segment, shape) {
|
|
|
128
129
|
* {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
|
|
129
130
|
*/
|
|
130
131
|
export function uriExpansionCall(spec) {
|
|
131
|
-
return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${
|
|
132
|
+
return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${literal(spec.prefix)}, suffix: ${literal(spec.suffix)}, operator: ${literal(spec.operator)}, name: ${literal(spec.name)}, explode: ${spec.explode}, shape: ${literal(spec.shape)}, values: ${literal(spec.values)} })`;
|
|
132
133
|
}
|
|
133
134
|
/**
|
|
134
135
|
* **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
|
|
@@ -209,7 +210,7 @@ function uriExpansion(
|
|
|
209
210
|
* gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
|
|
210
211
|
*/
|
|
211
212
|
export function explodedQueryCall(spec) {
|
|
212
|
-
return `(raw) => explodedQuery(raw, { name: ${
|
|
213
|
+
return `(raw) => explodedQuery(raw, { name: ${literal(spec.name)}, others: ${literal(spec.others)}, keys: ${literal(spec.keys ?? null)}, numbers: ${literal(spec.numbers)}, booleans: ${literal(spec.booleans)}, values: ${literal(spec.values)} })`;
|
|
213
214
|
}
|
|
214
215
|
/**
|
|
215
216
|
* **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
|