typespec-http-zod 0.25.1 → 0.27.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 +171 -116
- package/dist/src/api.js +456 -344
- package/dist/src/constraints.d.ts +46 -0
- package/dist/src/constraints.js +233 -10
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/lib.d.ts +32 -22
- package/dist/src/lib.js +53 -2
- package/dist/src/linter.d.ts +15 -0
- package/dist/src/linter.js +23 -0
- package/dist/src/registry.d.ts +1 -1
- package/dist/src/registry.js +158 -36
- package/dist/src/rules/redos-prone-pattern.rule.d.ts +27 -0
- package/dist/src/rules/redos-prone-pattern.rule.js +47 -0
- package/dist/src/runtime.d.ts +20 -34
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.js +13 -4
- package/dist/src/uri-template.d.ts +106 -0
- package/dist/src/uri-template.js +254 -0
- package/dist/src/zod.d.ts +11 -17
- package/dist/src/zod.js +87 -14
- package/package.json +6 -3
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"];
|
|
14
|
+
function parseUriTemplate(template) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
for (const [, rawExpression, literal] of template.matchAll(/\{([^{}]+)\}|([^{}]+)/g)) {
|
|
17
|
+
if (rawExpression === undefined) {
|
|
18
|
+
parts.push(literal ?? "");
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
let expression = rawExpression;
|
|
22
|
+
let operator;
|
|
23
|
+
const first = expression[0];
|
|
24
|
+
if (first !== undefined && OPERATORS.includes(first)) {
|
|
25
|
+
operator = first;
|
|
26
|
+
expression = expression.slice(1);
|
|
27
|
+
}
|
|
28
|
+
for (const item of expression.split(",")) {
|
|
29
|
+
const match = /([^:*]*)(?::(\d+)|(\*))?/.exec(item);
|
|
30
|
+
parts.push({ name: match?.[1] ?? item, operator, explode: match?.[3] !== undefined });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return parts;
|
|
34
|
+
}
|
|
35
|
+
/** A route template as the segments a server mounts, and the literal query pairs it carries. */
|
|
36
|
+
export function routeTemplateOf(uriTemplate, parameters) {
|
|
37
|
+
const facts = new Map(parameters.map((parameter) => [parameter.name, parameter]));
|
|
38
|
+
const raw = [];
|
|
39
|
+
const literalQuery = [];
|
|
40
|
+
let current;
|
|
41
|
+
let inQuery = false;
|
|
42
|
+
const open = () => {
|
|
43
|
+
current = [];
|
|
44
|
+
raw.push(current);
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
for (const part of parseUriTemplate(uriTemplate)) {
|
|
48
|
+
if (typeof part === "string") {
|
|
49
|
+
let text = part;
|
|
50
|
+
if (inQuery) {
|
|
51
|
+
literalQuery.push(...pairsOf(text));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (text.includes("?")) {
|
|
55
|
+
inQuery = true;
|
|
56
|
+
literalQuery.push(...pairsOf(text.slice(text.indexOf("?") + 1)));
|
|
57
|
+
text = text.slice(0, text.indexOf("?"));
|
|
58
|
+
}
|
|
59
|
+
text.split("/").forEach((piece, index) => {
|
|
60
|
+
const target = index === 0 ? (current ?? (piece === "" ? undefined : open())) : open();
|
|
61
|
+
if (piece !== "" && target !== undefined)
|
|
62
|
+
target.push(piece);
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// `{?x}` and `{&x}` are query parameters, which a query validator reads; they are not path.
|
|
67
|
+
if (part.operator === "?" || part.operator === "&")
|
|
68
|
+
continue;
|
|
69
|
+
if (part.operator === "/")
|
|
70
|
+
open().push(part);
|
|
71
|
+
else
|
|
72
|
+
(current ?? open()).push(part);
|
|
73
|
+
}
|
|
74
|
+
const segments = raw
|
|
75
|
+
.filter((parts) => parts.length > 0)
|
|
76
|
+
.map((parts) => {
|
|
77
|
+
const expressions = parts.filter((part) => typeof part !== "string");
|
|
78
|
+
const text = (from) => from.filter((part) => typeof part === "string").join("");
|
|
79
|
+
const [only] = expressions;
|
|
80
|
+
if (only === undefined)
|
|
81
|
+
return { kind: "literal", text: text(parts) };
|
|
82
|
+
if (expressions.length > 1) {
|
|
83
|
+
return {
|
|
84
|
+
kind: "unsupported",
|
|
85
|
+
text: parts
|
|
86
|
+
.map((part) => typeof part === "string" ? part : `{${part.operator ?? ""}${part.name}}`)
|
|
87
|
+
.join(""),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const at = parts.indexOf(only);
|
|
91
|
+
const fact = facts.get(only.name);
|
|
92
|
+
return {
|
|
93
|
+
kind: "expression",
|
|
94
|
+
parameter: only.name,
|
|
95
|
+
prefix: text(parts.slice(0, at)),
|
|
96
|
+
suffix: text(parts.slice(at + 1)),
|
|
97
|
+
operator: only.operator === "?" || only.operator === "&" ? "" : (only.operator ?? ""),
|
|
98
|
+
explode: only.explode,
|
|
99
|
+
optional: fact?.optional ?? false,
|
|
100
|
+
reserved: only.operator === "+" || only.operator === "#" || fact?.reserved === true,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
return { segments, literalQuery };
|
|
104
|
+
}
|
|
105
|
+
/** `a=1&b=2` -> the pairs it spells; a piece with no `=` is a name with an empty value. */
|
|
106
|
+
function pairsOf(text) {
|
|
107
|
+
return text
|
|
108
|
+
.split("&")
|
|
109
|
+
.filter((piece) => piece !== "")
|
|
110
|
+
.map((piece) => {
|
|
111
|
+
const at = piece.indexOf("=");
|
|
112
|
+
return at === -1 ? [piece, ""] : [piece.slice(0, at), piece.slice(at + 1)];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether a path value needs decoding at all. **An ordinary whole-segment parameter does not**, and
|
|
117
|
+
* leaving its validator untouched keeps every spec without an RFC 6570 form byte-identical.
|
|
118
|
+
*/
|
|
119
|
+
export function needsExpansionDecoding(segment, shape) {
|
|
120
|
+
return (segment.prefix !== "" ||
|
|
121
|
+
segment.suffix !== "" ||
|
|
122
|
+
segment.operator === "." ||
|
|
123
|
+
segment.operator === ";" ||
|
|
124
|
+
shape !== "scalar");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The call that decodes one expansion, as a `z.preprocess` argument. The decoder itself is
|
|
128
|
+
* {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
|
|
129
|
+
*/
|
|
130
|
+
export function uriExpansionCall(spec) {
|
|
131
|
+
return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${JSON.stringify(spec.prefix)}, suffix: ${JSON.stringify(spec.suffix)}, operator: ${JSON.stringify(spec.operator)}, name: ${JSON.stringify(spec.name)}, explode: ${spec.explode}, shape: ${JSON.stringify(spec.shape)}, values: ${JSON.stringify(spec.values)} })`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
|
|
135
|
+
* sits in (or, for an exploding `/` expression, every segment after its literal), and this undoes
|
|
136
|
+
* the expansion: the literal around it, the operator's leading character, a matrix parameter's
|
|
137
|
+
* `name=`, then the joiner the operator and explode modifier chose.
|
|
138
|
+
*
|
|
139
|
+
* Text that is not the expansion the route declares is REFUSED with an issue of its own, not passed
|
|
140
|
+
* on: a string parameter's schema would accept `primitive;other=a` as the string it is, so leaving
|
|
141
|
+
* the refusal to the schema admitted a request the route does not match. A list's element values
|
|
142
|
+
* are decoded by the element decoder inside, as a query list's are; a record's values are decoded
|
|
143
|
+
* here, because no schema expression reaches them one by one.
|
|
144
|
+
*/
|
|
145
|
+
export const URI_EXPANSION_SOURCE = `
|
|
146
|
+
/** RFC 6570 section 3.2 in reverse: one expression's expansion, decoded into the value it expanded. */
|
|
147
|
+
function uriExpansion(
|
|
148
|
+
raw: unknown,
|
|
149
|
+
ctx: { addIssue(issue: { code: "custom"; message: string }): void },
|
|
150
|
+
spec: {
|
|
151
|
+
readonly prefix: string;
|
|
152
|
+
readonly suffix: string;
|
|
153
|
+
readonly operator: string;
|
|
154
|
+
readonly name: string;
|
|
155
|
+
readonly explode: boolean;
|
|
156
|
+
readonly shape: "scalar" | "list" | "record";
|
|
157
|
+
readonly values: "number" | "boolean" | "";
|
|
158
|
+
},
|
|
159
|
+
): unknown {
|
|
160
|
+
if (typeof raw !== "string") return raw;
|
|
161
|
+
const refuse = (): unknown => {
|
|
162
|
+
ctx.addIssue({ code: "custom", message: \`Not the expansion of {\${spec.operator}\${spec.name}\${spec.explode ? "*" : ""}} this route declares\` });
|
|
163
|
+
return z.NEVER;
|
|
164
|
+
};
|
|
165
|
+
if (raw.length < spec.prefix.length + spec.suffix.length) return refuse();
|
|
166
|
+
if (!raw.startsWith(spec.prefix) || !raw.endsWith(spec.suffix)) return refuse();
|
|
167
|
+
let text = raw.slice(spec.prefix.length, raw.length - spec.suffix.length);
|
|
168
|
+
if (spec.operator === "." || spec.operator === ";") {
|
|
169
|
+
if (!text.startsWith(spec.operator)) return refuse();
|
|
170
|
+
text = text.slice(1);
|
|
171
|
+
}
|
|
172
|
+
if (spec.operator === ";" && !(spec.shape === "record" && spec.explode)) {
|
|
173
|
+
const named = text
|
|
174
|
+
.split(";")
|
|
175
|
+
.map((part) => (part === spec.name ? "" : part.startsWith(\`\${spec.name}=\`) ? part.slice(spec.name.length + 1) : undefined));
|
|
176
|
+
if (named.some((part) => part === undefined)) return refuse();
|
|
177
|
+
if (spec.shape === "list" && spec.explode) return named;
|
|
178
|
+
if (named.length !== 1) return refuse();
|
|
179
|
+
text = named[0] ?? "";
|
|
180
|
+
}
|
|
181
|
+
if (spec.shape === "scalar") return text;
|
|
182
|
+
const joiner = spec.explode && (spec.operator === "." || spec.operator === "/" || spec.operator === ";") ? spec.operator : ",";
|
|
183
|
+
if (spec.shape === "list") return text === "" ? [] : text.split(joiner);
|
|
184
|
+
const value = (item: string): unknown =>
|
|
185
|
+
spec.values === "number" && item.trim() !== "" && Number.isFinite(Number(item))
|
|
186
|
+
? Number(item)
|
|
187
|
+
: spec.values === "boolean" && (item === "true" || item === "false")
|
|
188
|
+
? item === "true"
|
|
189
|
+
: item;
|
|
190
|
+
const entries: [string, unknown][] = [];
|
|
191
|
+
if (spec.explode) {
|
|
192
|
+
for (const pair of text === "" ? [] : text.split(joiner)) {
|
|
193
|
+
const at = pair.indexOf("=");
|
|
194
|
+
if (at === -1) return refuse();
|
|
195
|
+
entries.push([pair.slice(0, at), value(pair.slice(at + 1))]);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
const items = text === "" ? [] : text.split(",");
|
|
199
|
+
if (items.length % 2 !== 0) return refuse();
|
|
200
|
+
for (let index = 0; index < items.length; index += 2) {
|
|
201
|
+
entries.push([items[index] ?? "", value(items[index + 1] ?? "")]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return Object.fromEntries(entries);
|
|
205
|
+
}
|
|
206
|
+
`;
|
|
207
|
+
/**
|
|
208
|
+
* The call that gathers a form-exploded record or model back under its parameter name. The
|
|
209
|
+
* gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
|
|
210
|
+
*/
|
|
211
|
+
export function explodedQueryCall(spec) {
|
|
212
|
+
return `(raw) => explodedQuery(raw, { name: ${JSON.stringify(spec.name)}, others: ${JSON.stringify(spec.others)}, keys: ${JSON.stringify(spec.keys ?? null)}, numbers: ${JSON.stringify(spec.numbers)}, booleans: ${JSON.stringify(spec.booleans)}, values: ${JSON.stringify(spec.values)} })`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
|
|
216
|
+
* `?field=status&value=active`, so there is no `param` key for a query validator to find. This
|
|
217
|
+
* gathers those keys back under the parameter's name before the query object is validated: a model
|
|
218
|
+
* takes exactly its declared properties, a record every key no other query parameter claims.
|
|
219
|
+
*/
|
|
220
|
+
export const EXPLODED_QUERY_SOURCE = `
|
|
221
|
+
/** RFC 6570 form expansion of an exploded record or model, gathered back under its parameter. */
|
|
222
|
+
function explodedQuery(
|
|
223
|
+
raw: unknown,
|
|
224
|
+
spec: {
|
|
225
|
+
readonly name: string;
|
|
226
|
+
readonly others: readonly string[];
|
|
227
|
+
readonly keys: readonly string[] | null;
|
|
228
|
+
readonly numbers: readonly string[];
|
|
229
|
+
readonly booleans: readonly string[];
|
|
230
|
+
readonly values: "number" | "boolean" | "";
|
|
231
|
+
},
|
|
232
|
+
): unknown {
|
|
233
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw) || spec.name in raw) return raw;
|
|
234
|
+
const gathered: Record<string, unknown> = {};
|
|
235
|
+
const rest: Record<string, unknown> = {};
|
|
236
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
237
|
+
const own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);
|
|
238
|
+
if (!own) {
|
|
239
|
+
rest[key] = value;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const kind = spec.values !== "" ? spec.values : spec.numbers.includes(key) ? "number" : spec.booleans.includes(key) ? "boolean" : "";
|
|
243
|
+
gathered[key] =
|
|
244
|
+
typeof value !== "string"
|
|
245
|
+
? value
|
|
246
|
+
: kind === "number" && value.trim() !== "" && Number.isFinite(Number(value))
|
|
247
|
+
? Number(value)
|
|
248
|
+
: kind === "boolean" && (value === "true" || value === "false")
|
|
249
|
+
? value === "true"
|
|
250
|
+
: value;
|
|
251
|
+
}
|
|
252
|
+
return Object.keys(gathered).length === 0 ? raw : { ...rest, [spec.name]: gathered };
|
|
253
|
+
}
|
|
254
|
+
`;
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { type Enum, type Model, type ModelProperty, type Program, type Type } from "@typespec/compiler";
|
|
2
2
|
import { Visibility } from "@typespec/http";
|
|
3
|
+
/**
|
|
4
|
+
* What a walk returns after reporting a refusal.
|
|
5
|
+
*
|
|
6
|
+
* **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
|
|
7
|
+
* unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
|
|
8
|
+
* run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
|
|
9
|
+
* Zod, rejects everything, and unreachable in practice because the error diagnostic has already
|
|
10
|
+
* failed the compile that would have written it.
|
|
11
|
+
*/
|
|
12
|
+
export declare const UNREPRESENTABLE = "z.never()";
|
|
13
|
+
export declare function withDeferredAnnotator<T>(annotator: (property: ModelProperty) => string | undefined, run: () => T): T;
|
|
3
14
|
export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
|
|
4
15
|
export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
|
|
5
16
|
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
@@ -97,23 +108,6 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
|
|
|
97
108
|
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
98
109
|
*/
|
|
99
110
|
export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
|
|
100
|
-
/**
|
|
101
|
-
* **What JSON kind a value of this type arrives as, for a parameter carried in text.**
|
|
102
|
-
*
|
|
103
|
-
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
104
|
-
* to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
|
|
105
|
-
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
106
|
-
* and fails silently the moment the expression is spelled any other way.
|
|
107
|
-
*
|
|
108
|
-
* It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
|
|
109
|
-
* neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
|
|
110
|
-
* that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
|
|
111
|
-
* caller got a 400, and no document comparison could see it, because both sides agree the parameter
|
|
112
|
-
* is a number - the disagreement is with the wire.
|
|
113
|
-
*
|
|
114
|
-
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
115
|
-
* single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
|
|
116
|
-
*/
|
|
117
111
|
export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
|
|
118
112
|
/**
|
|
119
113
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
package/dist/src/zod.js
CHANGED
|
@@ -11,7 +11,26 @@ import { reportDiagnostic } from "./lib.js";
|
|
|
11
11
|
* Zod, rejects everything, and unreachable in practice because the error diagnostic has already
|
|
12
12
|
* failed the compile that would have written it.
|
|
13
13
|
*/
|
|
14
|
-
const UNREPRESENTABLE = "z.never()";
|
|
14
|
+
export const UNREPRESENTABLE = "z.never()";
|
|
15
|
+
/**
|
|
16
|
+
* **Installed by the registry, which is the only place that knows what a declared type is CALLED.**
|
|
17
|
+
*
|
|
18
|
+
* A property emitted as a getter needs its Zod type written out - see the getter docblock in
|
|
19
|
+
* `modelToZod`. Naming the type is the registry's job (it resolves a named type to its declaration,
|
|
20
|
+
* versioned name included), and `types.ts` already imports this module, so the annotation is handed
|
|
21
|
+
* in rather than computed here.
|
|
22
|
+
*/
|
|
23
|
+
let annotateDeferred = () => undefined;
|
|
24
|
+
export function withDeferredAnnotator(annotator, run) {
|
|
25
|
+
const previous = annotateDeferred;
|
|
26
|
+
annotateDeferred = annotator;
|
|
27
|
+
try {
|
|
28
|
+
return run();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
annotateDeferred = previous;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
15
34
|
/**
|
|
16
35
|
* TypeSpec type -> Zod source text.
|
|
17
36
|
*
|
|
@@ -320,16 +339,36 @@ const SCALARS = {
|
|
|
320
339
|
string: "z.string()",
|
|
321
340
|
boolean: "z.boolean()",
|
|
322
341
|
bytes: "z.string()",
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
342
|
+
/**
|
|
343
|
+
* **A declared WIDTH is a claim about the value too, and ten of them used to share one check.**
|
|
344
|
+
*
|
|
345
|
+
* Every integer scalar emitted `z.number().int()`, which is "an integer within the safe range" and
|
|
346
|
+
* nothing more. Measured at the wire on `workerd`, against a document publishing the widths below:
|
|
347
|
+
* a `uint8` accepted `-5` and `100000`, an `int32` accepted `3000000000` and `-3000000000`.
|
|
348
|
+
*
|
|
349
|
+
* The differential oracle cannot see it. JSON Schema 2020-12 treats `format` as an annotation, so
|
|
350
|
+
* Ajv on the published document answers VALID for all of those and agrees with the server while
|
|
351
|
+
* both are wrong. Ajv says so out loud: `unknown format "uint8" ignored`.
|
|
352
|
+
*
|
|
353
|
+
* Zod 4 has the named formats for the two widths that have them, and they emit better JSON Schema
|
|
354
|
+
* than a bare bound would. The rest take explicit bounds.
|
|
355
|
+
*
|
|
356
|
+
* **`int64` and `uint64` keep the safe-integer ceiling, and that is a limit rather than a
|
|
357
|
+
* choice.** `JSON.parse` has already lost a value above 2^53 before any validator runs, so the
|
|
358
|
+
* alternatives are to refuse it or to accept a number that is not the one the caller sent.
|
|
359
|
+
* Refusing is the honest one. `z.int64()` exists but is a `bigint`, which a JSON body cannot
|
|
360
|
+
* carry. What changes here is only that `uint64` stops accepting negatives.
|
|
361
|
+
*/
|
|
362
|
+
int8: "z.int().min(-128).max(127)",
|
|
363
|
+
int16: "z.int().min(-32768).max(32767)",
|
|
364
|
+
int32: "z.int32()",
|
|
365
|
+
int64: "z.int()",
|
|
366
|
+
safeint: "z.int()",
|
|
367
|
+
uint8: "z.int().min(0).max(255)",
|
|
368
|
+
uint16: "z.int().min(0).max(65535)",
|
|
369
|
+
uint32: "z.uint32()",
|
|
370
|
+
uint64: "z.int().min(0)",
|
|
371
|
+
integer: "z.int()",
|
|
333
372
|
float: "z.number()",
|
|
334
373
|
float32: "z.number()",
|
|
335
374
|
float64: "z.number()",
|
|
@@ -762,9 +801,26 @@ function modelToZod(program, model) {
|
|
|
762
801
|
.map((property) => {
|
|
763
802
|
const key = propertyKey(program, property);
|
|
764
803
|
const { value, deferred } = captureBackEdges(() => withDeclarationSite(property, () => propertyToZod(program, property)));
|
|
804
|
+
/**
|
|
805
|
+
* **A getter without a return type infers `unknown`, and the collapse is SILENT.**
|
|
806
|
+
*
|
|
807
|
+
* The docblock above claims the getter keeps `z.infer` inferring the recursive type. It does
|
|
808
|
+
* for the declaration itself, and not for a schema that WRAPS it: measured on the conformance
|
|
809
|
+
* corpus, `z.record(z.string(), innerModelSchema)` - what `@body body: Record<InnerModel>`
|
|
810
|
+
* emits - infers `children?: unknown`, and `.parse()` on it returns `unknown` outright. The
|
|
811
|
+
* generated server then failed to compile with `TS2322`, in `type/dictionary`, which is how
|
|
812
|
+
* this was found; nothing said the emitted TYPE was weaker than the document.
|
|
813
|
+
*
|
|
814
|
+
* Annotating the GETTER rather than the declaration is what keeps `.shape` and `.extend`,
|
|
815
|
+
* which inheritance and every group spread depend on. `: z.ZodType<T, T>` names both of Zod's
|
|
816
|
+
* parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
|
|
817
|
+
*/
|
|
818
|
+
const annotation = deferred ? (annotateDeferred(property) ?? "") : "";
|
|
765
819
|
return {
|
|
766
820
|
deferred,
|
|
767
|
-
text: deferred
|
|
821
|
+
text: deferred
|
|
822
|
+
? `\tget ${key}()${annotation} {\n\t\treturn ${value};\n\t},`
|
|
823
|
+
: `\t${key}: ${value},`,
|
|
768
824
|
};
|
|
769
825
|
});
|
|
770
826
|
const entries = members.map((member) => member.text);
|
|
@@ -823,8 +879,25 @@ function modelToZod(program, model) {
|
|
|
823
879
|
* is a number - the disagreement is with the wire.
|
|
824
880
|
*
|
|
825
881
|
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
826
|
-
* single list of what becomes a number
|
|
882
|
+
* single list of what becomes a number.
|
|
883
|
+
*
|
|
884
|
+
* **It still inspects the emitted expression to decide WHICH kind, and that did drift once more.**
|
|
885
|
+
* The test was `startsWith("z.number()")`, and giving each integer width its declared bounds spelled
|
|
886
|
+
* nine of them `z.int(`, `z.int32(` and `z.uint32(` instead. Every one silently lost its wire
|
|
887
|
+
* decoder: 22 arms went red at once, which is the only reason it was not shipped. The prefixes live
|
|
888
|
+
* in one named constant now.
|
|
889
|
+
*
|
|
890
|
+
* **That claim named a `scalars.test.ts` which did not exist, and nothing graded the list.**
|
|
891
|
+
* Measured: dropping `z.uint32(` from it, and separately `z.int(`, left all 468 arms in this
|
|
892
|
+
* package green, because no fixture had ever declared an integer width as a PARAMETER - the only
|
|
893
|
+
* place the decoder is attached. `test/widths/` now declares all ten as query parameters and sends
|
|
894
|
+
* each as text, so a spelling this list forgets fails here rather than at a consumer.
|
|
895
|
+
*/
|
|
896
|
+
/**
|
|
897
|
+
* Every emitted expression that denotes a JSON number. Named once, because `SCALARS` and this list
|
|
898
|
+
* have to agree and nothing about the two being in the same file makes them.
|
|
827
899
|
*/
|
|
900
|
+
const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
|
|
828
901
|
export function wireKindOf(program, type) {
|
|
829
902
|
if (type.kind === "Scalar") {
|
|
830
903
|
const encoded = encodedTypeOf(program, type);
|
|
@@ -834,7 +907,7 @@ export function wireKindOf(program, type) {
|
|
|
834
907
|
const mapped = SCALARS[current.name];
|
|
835
908
|
if (mapped === undefined)
|
|
836
909
|
continue;
|
|
837
|
-
if (mapped.startsWith(
|
|
910
|
+
if (NUMERIC_EXPRESSIONS.some((prefix) => mapped.startsWith(prefix)))
|
|
838
911
|
return "number";
|
|
839
912
|
if (mapped === "z.boolean()")
|
|
840
913
|
return "boolean";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codegen",
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
"access": "public",
|
|
43
43
|
"provenance": true
|
|
44
44
|
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"redos-detector": "^6.1.4"
|
|
47
|
+
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@types/node": "^26.5.1",
|
|
47
50
|
"@typespec/compiler": "1.16.0",
|
|
@@ -62,7 +65,7 @@
|
|
|
62
65
|
"typespec-http-zod": "link:.",
|
|
63
66
|
"vitest": "^5.0.0",
|
|
64
67
|
"yaml": "^2.9.1",
|
|
65
|
-
"zod": "^4.5
|
|
68
|
+
"zod": "^4.6.5"
|
|
66
69
|
},
|
|
67
70
|
"peerDependencies": {
|
|
68
71
|
"@typespec/compiler": "^1.15.0",
|
|
@@ -85,7 +88,7 @@
|
|
|
85
88
|
},
|
|
86
89
|
"tspMain": "lib/main.tsp",
|
|
87
90
|
"scripts": {
|
|
88
|
-
"build": "tsc -p tsconfig.build.json",
|
|
91
|
+
"build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
|
|
89
92
|
"test": "tsc -p tsconfig.build.json && vitest run",
|
|
90
93
|
"typecheck": "tsc -p tsconfig.json",
|
|
91
94
|
"lint": "oxlint src/ test/",
|