typespec-http-zod 0.26.0 → 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 +21 -0
- package/dist/src/api.js +107 -16
- 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 -3
- package/dist/src/lib.js +53 -0
- 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 +135 -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/zod.d.ts +10 -17
- package/dist/src/zod.js +50 -13
- package/package.json +6 -3
package/dist/src/lib.d.ts
CHANGED
|
@@ -70,8 +70,10 @@ export interface EmitterOptions {
|
|
|
70
70
|
* `import "zod/compile"` does not: it compiles on first PARSE, which happens inside a request,
|
|
71
71
|
* where the runtime refuses. Only the emitter owns the module scope these schemas are built in.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* No member of a reference CYCLE is compiled. Compiling one that a `z.lazy()` body names throws
|
|
74
|
+
* `Cannot read properties of undefined (reading '_zod')` on the first parse from zod 4.6 onwards, so
|
|
75
|
+
* the whole cycle is left uncompiled and goes on parsing normally. The option stays safe to set on
|
|
76
|
+
* any spec.
|
|
75
77
|
*/
|
|
76
78
|
"compile-schemas"?: boolean;
|
|
77
79
|
/**
|
|
@@ -154,14 +156,23 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
154
156
|
"unsupported-status-code-range": {
|
|
155
157
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
156
158
|
};
|
|
159
|
+
"non-unicode-pattern": {
|
|
160
|
+
readonly default: import("@typespec/compiler").CallableMessage<["pattern"]>;
|
|
161
|
+
};
|
|
162
|
+
"unenforceable-encoded-bound": {
|
|
163
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
164
|
+
};
|
|
157
165
|
"duplicate-operation-id": {
|
|
158
166
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
159
167
|
};
|
|
168
|
+
"inline-cycle": {
|
|
169
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
170
|
+
};
|
|
160
171
|
"duplicate-declaration": {
|
|
161
172
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
162
173
|
};
|
|
163
174
|
}, EmitterOptions, never>;
|
|
164
|
-
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "truncated-doc-comment" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
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 {
|
|
165
176
|
"unsupported-type": {
|
|
166
177
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
167
178
|
};
|
|
@@ -189,9 +200,18 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
189
200
|
"unsupported-status-code-range": {
|
|
190
201
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
191
202
|
};
|
|
203
|
+
"non-unicode-pattern": {
|
|
204
|
+
readonly default: import("@typespec/compiler").CallableMessage<["pattern"]>;
|
|
205
|
+
};
|
|
206
|
+
"unenforceable-encoded-bound": {
|
|
207
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
208
|
+
};
|
|
192
209
|
"duplicate-operation-id": {
|
|
193
210
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
194
211
|
};
|
|
212
|
+
"inline-cycle": {
|
|
213
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
214
|
+
};
|
|
195
215
|
"duplicate-declaration": {
|
|
196
216
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
197
217
|
};
|
|
@@ -223,9 +243,18 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
223
243
|
"unsupported-status-code-range": {
|
|
224
244
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
225
245
|
};
|
|
246
|
+
"non-unicode-pattern": {
|
|
247
|
+
readonly default: import("@typespec/compiler").CallableMessage<["pattern"]>;
|
|
248
|
+
};
|
|
249
|
+
"unenforceable-encoded-bound": {
|
|
250
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
251
|
+
};
|
|
226
252
|
"duplicate-operation-id": {
|
|
227
253
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
228
254
|
};
|
|
255
|
+
"inline-cycle": {
|
|
256
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
257
|
+
};
|
|
229
258
|
"duplicate-declaration": {
|
|
230
259
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
231
260
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -267,6 +267,36 @@ const diagnostics = {
|
|
|
267
267
|
* **This is the one collision worth refusing.** The derived case is representable and is emitted.
|
|
268
268
|
* This one cannot be resolved without contradicting what the author explicitly wrote.
|
|
269
269
|
*/
|
|
270
|
+
/**
|
|
271
|
+
* **A pattern the document's own readers cannot compile.**
|
|
272
|
+
*
|
|
273
|
+
* A JSON Schema `pattern` is an ECMA-262 expression, and a reader compiles it with the Unicode
|
|
274
|
+
* flag - Ajv does by default. Several ordinary spellings are an error under that flag: a redundant
|
|
275
|
+
* `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a`.
|
|
276
|
+
* Measured: Ajv throws `Invalid regular expression: /^\-?\d+$/u` on a pattern the emitted
|
|
277
|
+
* validator runs perfectly well, so the server enforces a rule nothing reading the document can.
|
|
278
|
+
*
|
|
279
|
+
* Reported rather than rewritten, for the same reason as every other pattern diagnostic: openapi3
|
|
280
|
+
* publishes the expression verbatim and this package does not edit a published contract. The
|
|
281
|
+
* remedy is a character the flag accepts, and it is almost always a `\` that was never needed.
|
|
282
|
+
*/
|
|
283
|
+
"non-unicode-pattern": {
|
|
284
|
+
severity: "warning",
|
|
285
|
+
messages: {
|
|
286
|
+
default: paramMessage `'${"pattern"}' is not a valid regular expression under the Unicode flag, which is how a JSON Schema reader compiles a published pattern: Ajv throws on it rather than evaluating it. The emitted validator falls back to running it without the flag, so this server enforces a rule no reader of its own document can. Remove the character the flag rejects: it is almost always a backslash that was never needed, such as a '\\-' outside a character class or an escape like '\\a', and otherwise a lone ']' or '{' that should be escaped.`,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
/**
|
|
290
|
+
* **A diagnostic, not a linter rule**, because here the emitter cannot do what the spec asks: it
|
|
291
|
+
* drops a bound the author wrote. That is the case TypeSpec reserves for `$onValidate`-style
|
|
292
|
+
* diagnostics, where a linter is for a program that is correct but could be better.
|
|
293
|
+
*/
|
|
294
|
+
"unenforceable-encoded-bound": {
|
|
295
|
+
severity: "warning",
|
|
296
|
+
messages: {
|
|
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
|
+
},
|
|
299
|
+
},
|
|
270
300
|
"duplicate-operation-id": {
|
|
271
301
|
severity: "error",
|
|
272
302
|
messages: {
|
|
@@ -285,6 +315,29 @@ const diagnostics = {
|
|
|
285
315
|
* namespace and a TypeScript module cannot, so keeping the first would publish a contract for a
|
|
286
316
|
* type nothing checks, and picking either is a guess about which the author meant.
|
|
287
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* **A cycle that never passes through a declaration has nothing to reference itself by.**
|
|
320
|
+
*
|
|
321
|
+
* A recursive MODEL is fine: it earns a declaration, and the back edge becomes a getter or a
|
|
322
|
+
* `z.lazy()` naming that declaration. A cycle closing through a type this emitter INLINES has no
|
|
323
|
+
* such name. A template instantiation is the reachable case - `declaredNameOf` returns undefined
|
|
324
|
+
* for one, matching how `@typespec/openapi3` inlines it - so `model Box<T> { next?: Box<T> }` used
|
|
325
|
+
* as `Box<string>` walked into itself forever. Measured before this existed:
|
|
326
|
+
* `RangeError: Maximum call stack size exceeded`, reported by the compiler as "Emitter crashed!
|
|
327
|
+
* This is a bug."
|
|
328
|
+
*
|
|
329
|
+
* **An error, and the same error openapi3 raises.** It answers the identical spec with
|
|
330
|
+
* `@typespec/openapi3/inline-cycle` at severity error and writes no document, so there is no
|
|
331
|
+
* contract for a validator to agree with even if one could be emitted. The remedy it names works
|
|
332
|
+
* here for the same reason: `@friendlyName` gives the instantiation a name, which makes it a
|
|
333
|
+
* declaration, which gives the back edge something to point at.
|
|
334
|
+
*/
|
|
335
|
+
"inline-cycle": {
|
|
336
|
+
severity: "error",
|
|
337
|
+
messages: {
|
|
338
|
+
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
|
+
},
|
|
340
|
+
},
|
|
288
341
|
"duplicate-declaration": {
|
|
289
342
|
severity: "error",
|
|
290
343
|
messages: {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type LinterDefinition } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* **The advisory checks, opt-in, as TypeSpec expects them.**
|
|
4
|
+
*
|
|
5
|
+
* Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-http-zod/recommended"] }`.
|
|
6
|
+
* A consumer of `typespec-hono` enables the same rule through that package's own ruleset instead,
|
|
7
|
+
* because it never loads this package as a TypeSpec library and so cannot name it there.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Typed explicitly through this package's own `@typespec/compiler` specifier. A checkout beside
|
|
11
|
+
* `typespec-hono` resolves two copies of the compiler, and an INFERRED declaration type can then
|
|
12
|
+
* name the other package's copy by a `.pnpm` path (`TS2883`, "likely not portable"), which is
|
|
13
|
+
* broken for everyone who installed differently. `typespec-hono` hit exactly that on its own linter.
|
|
14
|
+
*/
|
|
15
|
+
export declare const $linter: LinterDefinition;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineLinter } from "@typespec/compiler";
|
|
2
|
+
import { redosPronePatternRule } from "./rules/redos-prone-pattern.rule.js";
|
|
3
|
+
/**
|
|
4
|
+
* **The advisory checks, opt-in, as TypeSpec expects them.**
|
|
5
|
+
*
|
|
6
|
+
* Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-http-zod/recommended"] }`.
|
|
7
|
+
* A consumer of `typespec-hono` enables the same rule through that package's own ruleset instead,
|
|
8
|
+
* because it never loads this package as a TypeSpec library and so cannot name it there.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Typed explicitly through this package's own `@typespec/compiler` specifier. A checkout beside
|
|
12
|
+
* `typespec-hono` resolves two copies of the compiler, and an INFERRED declaration type can then
|
|
13
|
+
* name the other package's copy by a `.pnpm` path (`TS2883`, "likely not portable"), which is
|
|
14
|
+
* broken for everyone who installed differently. `typespec-hono` hit exactly that on its own linter.
|
|
15
|
+
*/
|
|
16
|
+
export const $linter = defineLinter({
|
|
17
|
+
rules: [redosPronePatternRule],
|
|
18
|
+
ruleSets: {
|
|
19
|
+
recommended: {
|
|
20
|
+
enable: { [`typespec-http-zod/${redosPronePatternRule.name}`]: true },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ModelProperty, type Program, type Type } from "@typespec/compiler";
|
|
2
2
|
import { Visibility } from "@typespec/http";
|
|
3
3
|
/**
|
|
4
4
|
* Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
|
package/dist/src/registry.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { getFriendlyName, } from "@typespec/compiler";
|
|
1
2
|
import { getVisibilitySuffix, Visibility } from "@typespec/http";
|
|
2
3
|
import { reportDiagnostic } from "./lib.js";
|
|
3
4
|
import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
|
|
4
|
-
import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
5
|
+
import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
5
6
|
/**
|
|
6
7
|
* The name an ANONYMOUS payload model inherits from the single model it spreads.
|
|
7
8
|
*
|
|
@@ -145,7 +146,7 @@ function safeDeclarationName(name) {
|
|
|
145
146
|
return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
|
|
146
147
|
}
|
|
147
148
|
/** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
|
|
148
|
-
function declaredNameOf(type) {
|
|
149
|
+
function declaredNameOf(program, type) {
|
|
149
150
|
if (type.kind === "Enum")
|
|
150
151
|
return safeDeclarationName(type.name);
|
|
151
152
|
/**
|
|
@@ -168,18 +169,29 @@ function declaredNameOf(type) {
|
|
|
168
169
|
return spread === undefined ? undefined : safeDeclarationName(spread);
|
|
169
170
|
}
|
|
170
171
|
/**
|
|
171
|
-
* **A template INSTANTIATION carries the template's name, and it is not a declaration
|
|
172
|
+
* **A template INSTANTIATION carries the template's name, and it is not a declaration - unless
|
|
173
|
+
* `@friendlyName` gives it one of its own.**
|
|
172
174
|
*
|
|
173
175
|
* `PublicSuccess<PublicCompanyDetail>` and `PublicSuccess<PublicOfficers>` are both called
|
|
174
176
|
* `PublicSuccess`, so naming them would export eleven consts called `publicSuccessSchema` - the
|
|
175
|
-
* file does not compile, and if it did, one shape would validate every response.
|
|
176
|
-
* inlines instantiations for the same reason
|
|
177
|
-
*
|
|
177
|
+
* file does not compile, and if it did, one shape would validate every response.
|
|
178
|
+
* `@typespec/openapi3` inlines instantiations for the same reason.
|
|
179
|
+
*
|
|
180
|
+
* **But it stops inlining one that has been named, and this used to keep inlining it.** Its rule
|
|
181
|
+
* is `shouldInline` in `@typespec/openapi`, and the first thing that function does is
|
|
182
|
+
* `if (getFriendlyName(program, type)) return false`. Measured on
|
|
183
|
+
* `@friendlyName("{name}Box", T) model Box<T>` used as `Box<string>`: openapi3 publishes a
|
|
184
|
+
* component `stringBox` and refers to it with `$ref`, while this emitter wrote the object out
|
|
185
|
+
* inline. So the two artefacts disagreed about what has a name, which is the opposite of what
|
|
186
|
+
* this docblock used to claim, and `@friendlyName` - the remedy openapi3 names for an inline
|
|
187
|
+
* cycle, and the one {@link "inline-cycle"} names here - did nothing.
|
|
178
188
|
*
|
|
179
189
|
* Invisible until a spec uses a template; a paged-response envelope is the usual first one.
|
|
180
190
|
*/
|
|
181
|
-
if (type.templateMapper !== undefined)
|
|
182
|
-
|
|
191
|
+
if (type.templateMapper !== undefined) {
|
|
192
|
+
const friendly = getFriendlyName(program, type);
|
|
193
|
+
return friendly === undefined || friendly === "" ? undefined : safeDeclarationName(friendly);
|
|
194
|
+
}
|
|
183
195
|
return safeDeclarationName(type.name);
|
|
184
196
|
}
|
|
185
197
|
const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
@@ -269,6 +281,19 @@ export class SchemaRegistry {
|
|
|
269
281
|
#inProgress = new Map();
|
|
270
282
|
/** Keys on a cycle - every one needs a structural type rather than a `z.infer` alias. */
|
|
271
283
|
#cyclic = new Set();
|
|
284
|
+
/**
|
|
285
|
+
* Inlined types currently being walked, by identity, against the number of declarations that were
|
|
286
|
+
* in flight when each was entered.
|
|
287
|
+
*
|
|
288
|
+
* **The count is what tells a real inline cycle from an ordinary recursive model.** `#inProgress`
|
|
289
|
+
* cannot serve on its own: it is keyed by declaration name and these types have none, which is
|
|
290
|
+
* the whole reason the cycle has nothing to point at. But `InnerModel[]` re-enters `Array<InnerModel>`
|
|
291
|
+
* on a perfectly ordinary recursion - array, then the model, then the model's own `children` - and
|
|
292
|
+
* that cycle closes through `InnerModel`, which IS a declaration and terminates it. Comparing the
|
|
293
|
+
* count is how this asks openapi3's question, `cycle.containsDeclaration`: if a declaration was
|
|
294
|
+
* entered since, the cycle has a name to close on and the walk may continue.
|
|
295
|
+
*/
|
|
296
|
+
#inlineInProgress = new Map();
|
|
272
297
|
constructor(program) {
|
|
273
298
|
this.#program = program;
|
|
274
299
|
}
|
|
@@ -278,9 +303,30 @@ export class SchemaRegistry {
|
|
|
278
303
|
* what lets the generated file be plain `const` declarations with no forward references.
|
|
279
304
|
*/
|
|
280
305
|
expressionFor(type) {
|
|
281
|
-
const bare = declaredNameOf(type);
|
|
282
|
-
if (bare === undefined)
|
|
283
|
-
|
|
306
|
+
const bare = declaredNameOf(this.#program, type);
|
|
307
|
+
if (bare === undefined) {
|
|
308
|
+
/**
|
|
309
|
+
* **A back edge to an INLINED type is a cycle with no name to close it**, and following it
|
|
310
|
+
* is a stack overflow rather than a wrong answer: the compiler reports "Emitter crashed!
|
|
311
|
+
* This is a bug." Tracked by identity because there is no key to track it by.
|
|
312
|
+
*/
|
|
313
|
+
const entered = this.#inlineInProgress.get(type);
|
|
314
|
+
if (entered !== undefined && this.#inProgress.size <= entered) {
|
|
315
|
+
reportDiagnostic(this.#program, {
|
|
316
|
+
code: "inline-cycle",
|
|
317
|
+
format: { type: "name" in type && type.name !== undefined ? String(type.name) : "" },
|
|
318
|
+
target: type,
|
|
319
|
+
});
|
|
320
|
+
return UNREPRESENTABLE;
|
|
321
|
+
}
|
|
322
|
+
this.#inlineInProgress.set(type, this.#inProgress.size);
|
|
323
|
+
try {
|
|
324
|
+
return this.#inline(type);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
this.#inlineInProgress.delete(type);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
284
330
|
/**
|
|
285
331
|
* **A type not reshaped at this visibility IS the canonical declaration - not a copy of it.**
|
|
286
332
|
*
|
|
@@ -335,6 +381,31 @@ export class SchemaRegistry {
|
|
|
335
381
|
...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type) } : {}),
|
|
336
382
|
...(rendered.deferred ? { annotated: true } : {}),
|
|
337
383
|
};
|
|
384
|
+
/**
|
|
385
|
+
* **Two different types can resolve to one identifier, and the module then does not parse.**
|
|
386
|
+
*
|
|
387
|
+
* The key is `(visibility, name, identity)`, so two models genuinely named the same in
|
|
388
|
+
* different namespaces are two entries - and both then emit `export const thingSchema`.
|
|
389
|
+
* Measured on one service declaring `Alpha.Thing` and `Beta.Thing`: the document published
|
|
390
|
+
* both components under their full names, the emitted module declared `thingSchema` twice,
|
|
391
|
+
* and `tsc` answered `TS2451: Cannot redeclare block-scoped variable` while `tsp compile`
|
|
392
|
+
* reported success.
|
|
393
|
+
*
|
|
394
|
+
* `duplicate-declaration` already existed for exactly this and already said exactly this, but
|
|
395
|
+
* only {@link TypeRegistry} raised it - so the contracts files were covered and
|
|
396
|
+
* `schemas.gen.ts`, which every consumer imports, was not. `@typespec/openapi3` reports the
|
|
397
|
+
* same class through `checkDuplicateTypeName`, and `@typespec/json-schema` through
|
|
398
|
+
* `duplicate-id`; neither resolves it for you, because only the author knows which name to
|
|
399
|
+
* change.
|
|
400
|
+
*/
|
|
401
|
+
const collision = this.#order.find((candidate) => candidate.identifier === identifier && candidate.source !== declaration.source);
|
|
402
|
+
if (collision !== undefined) {
|
|
403
|
+
reportDiagnostic(this.#program, {
|
|
404
|
+
code: "duplicate-declaration",
|
|
405
|
+
format: { name },
|
|
406
|
+
target: type,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
338
409
|
this.#declarations.set(key, declaration);
|
|
339
410
|
this.#order.push(declaration);
|
|
340
411
|
this.#declareRelatives(type);
|
|
@@ -372,11 +443,18 @@ export class SchemaRegistry {
|
|
|
372
443
|
* would be documented and never enforced.
|
|
373
444
|
*/
|
|
374
445
|
expressionForProperty(property) {
|
|
375
|
-
return withRefResolver(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
446
|
+
return withRefResolver(
|
|
447
|
+
/**
|
|
448
|
+
* **Every candidate goes through {@link expressionFor}, including the ones with no name.**
|
|
449
|
+
*
|
|
450
|
+
* This used to return `undefined` for an undeclared type, which sent it straight to
|
|
451
|
+
* `typeToZodBody` and past the only place a cycle is detected. A template instantiation is
|
|
452
|
+
* undeclared - `declaredNameOf` returns undefined for one, matching openapi3's inlining -
|
|
453
|
+
* so `model Box<T> { next?: Box<T> }` walked into itself until the stack ran out.
|
|
454
|
+
* `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
|
|
455
|
+
* also notices when it is already doing so.
|
|
456
|
+
*/
|
|
457
|
+
(candidate) => this.expressionFor(candidate), () => propertyToZod(this.#program, property));
|
|
380
458
|
}
|
|
381
459
|
/**
|
|
382
460
|
* A declaration for a shape the spec never wrote, under a name the document publishes.
|
|
@@ -424,7 +502,7 @@ export class SchemaRegistry {
|
|
|
424
502
|
* drift.
|
|
425
503
|
*/
|
|
426
504
|
#structuralBodyOf(type) {
|
|
427
|
-
return withTsRefResolver((candidate) => declaredNameOf(candidate), () => typeToTsBody(this.#program, type));
|
|
505
|
+
return withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => typeToTsBody(this.#program, type));
|
|
428
506
|
}
|
|
429
507
|
/**
|
|
430
508
|
* Render `type` without consulting its own declaration, but letting nested types use theirs.
|
|
@@ -437,11 +515,18 @@ export class SchemaRegistry {
|
|
|
437
515
|
* a nested occurrence resolve to the identifier while the body still renders as a body.
|
|
438
516
|
*/
|
|
439
517
|
#inline(type) {
|
|
440
|
-
return withRefResolver(
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
518
|
+
return withRefResolver(
|
|
519
|
+
/**
|
|
520
|
+
* **Every candidate goes through {@link expressionFor}, including the ones with no name.**
|
|
521
|
+
*
|
|
522
|
+
* This used to return `undefined` for an undeclared type, which sent it straight to
|
|
523
|
+
* `typeToZodBody` and past the only place a cycle is detected. A template instantiation is
|
|
524
|
+
* undeclared - `declaredNameOf` returns undefined for one, matching openapi3's inlining -
|
|
525
|
+
* so `model Box<T> { next?: Box<T> }` walked into itself until the stack ran out.
|
|
526
|
+
* `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
|
|
527
|
+
* also notices when it is already doing so.
|
|
528
|
+
*/
|
|
529
|
+
(candidate) => this.expressionFor(candidate), () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
|
|
445
530
|
}
|
|
446
531
|
/**
|
|
447
532
|
* The Zod type a property emitted as a getter is annotated with, or `undefined` for none.
|
|
@@ -461,7 +546,7 @@ export class SchemaRegistry {
|
|
|
461
546
|
#deferredAnnotationOf(property) {
|
|
462
547
|
if (property.defaultValue !== undefined)
|
|
463
548
|
return undefined;
|
|
464
|
-
const declared = withTsRefResolver((candidate) => declaredNameOf(candidate), () => propertyTypeToTs(this.#program, property));
|
|
549
|
+
const declared = withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => propertyTypeToTs(this.#program, property));
|
|
465
550
|
const core = `z.ZodType<${declared}, ${declared}>`;
|
|
466
551
|
const optional = property.optional || isOptionalAt(this.#program, property);
|
|
467
552
|
return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
|
|
@@ -538,13 +623,31 @@ export class TypeRegistry {
|
|
|
538
623
|
#declarations = new Map();
|
|
539
624
|
#order = [];
|
|
540
625
|
#inProgress = new Map();
|
|
626
|
+
/**
|
|
627
|
+
* The same in-flight tracking {@link SchemaRegistry} keeps, for the same cycle and by the same
|
|
628
|
+
* rule. Guarding one walk and not the other left the Zod side reporting `inline-cycle` correctly
|
|
629
|
+
* and the TypeScript side still running off the stack immediately afterwards, which reads to a
|
|
630
|
+
* consumer as "Emitter crashed! This is a bug." rather than as the error that was raised.
|
|
631
|
+
* Reported only by the schema walk, so one spec does not produce the same complaint twice.
|
|
632
|
+
*/
|
|
633
|
+
#inlineInProgress = new Map();
|
|
541
634
|
constructor(program) {
|
|
542
635
|
this.#program = program;
|
|
543
636
|
}
|
|
544
637
|
expressionFor(type) {
|
|
545
|
-
const bare = declaredNameOf(type);
|
|
546
|
-
if (bare === undefined)
|
|
547
|
-
|
|
638
|
+
const bare = declaredNameOf(this.#program, type);
|
|
639
|
+
if (bare === undefined) {
|
|
640
|
+
const entered = this.#inlineInProgress.get(type);
|
|
641
|
+
if (entered !== undefined && this.#inProgress.size <= entered)
|
|
642
|
+
return "never";
|
|
643
|
+
this.#inlineInProgress.set(type, this.#inProgress.size);
|
|
644
|
+
try {
|
|
645
|
+
return this.#inline(type);
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
this.#inlineInProgress.delete(type);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
548
651
|
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
549
652
|
const key = keyFor(type, at);
|
|
550
653
|
const name = nameAt(bare, at);
|
|
@@ -627,18 +730,14 @@ export class TypeRegistry {
|
|
|
627
730
|
*/
|
|
628
731
|
/** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
|
|
629
732
|
expressionForProperty(property, wireName) {
|
|
630
|
-
return withTsRefResolver(
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
return this.expressionFor(candidate);
|
|
634
|
-
}, () => propertyToTs(this.#program, property, wireName));
|
|
733
|
+
return withTsRefResolver(
|
|
734
|
+
// Through expressionFor even when undeclared, so the cycle guard above is reachable.
|
|
735
|
+
(candidate) => this.expressionFor(candidate), () => propertyToTs(this.#program, property, wireName));
|
|
635
736
|
}
|
|
636
737
|
#inline(type) {
|
|
637
|
-
return withTsRefResolver(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
return this.expressionFor(candidate);
|
|
641
|
-
}, () => typeToTsBody(this.#program, type));
|
|
738
|
+
return withTsRefResolver(
|
|
739
|
+
// Through expressionFor even when undeclared, so the cycle guard above is reachable.
|
|
740
|
+
(candidate) => this.expressionFor(candidate), () => typeToTsBody(this.#program, type));
|
|
642
741
|
}
|
|
643
742
|
/** Every declaration, dependencies first. */
|
|
644
743
|
declarations() {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type CallableMessage, type LinterRuleDefinition } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* **A `@pattern` a backtracking engine can be made to spend unbounded time on.**
|
|
4
|
+
*
|
|
5
|
+
* **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
|
|
6
|
+
* is not valid for this library; a linter says it "could be correct, but there might be room for
|
|
7
|
+
* improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
|
|
8
|
+
* and the emitted regex is exactly the one `@typespec/openapi3` publishes. What is wrong is a cost no
|
|
9
|
+
* artefact states. `@typespec/http` puts its own advisory rule in a linter for the same reason.
|
|
10
|
+
*
|
|
11
|
+
* **This is a server-side denial of service, not a style note.** Measured on a generated server
|
|
12
|
+
* under `workerd`: `@pattern("^(\w+\s?)*$")`, which reads as "words", answers a 31-byte query
|
|
13
|
+
* parameter in 7.8 seconds against 2.9 milliseconds for a conformant one, and the cost doubles per
|
|
14
|
+
* added byte. The caller needs no credential and the parameter is validated before any handler runs.
|
|
15
|
+
* That is why the package's `recommended` ruleset enables it, and why the documentation says to.
|
|
16
|
+
*
|
|
17
|
+
* **The emitted regex is not changed, and cannot be.** Anchoring or bounding it here would make the
|
|
18
|
+
* validator enforce something the document does not state. What is left is to say so while the author
|
|
19
|
+
* can still change the spec.
|
|
20
|
+
*
|
|
21
|
+
* **Reported once per DECLARATION.** A linter walks declarations rather than uses, so a scalar used
|
|
22
|
+
* in 153 places is one report - and an instantiated template's properties share their declaration's
|
|
23
|
+
* node, which is what the de-duplication keys on.
|
|
24
|
+
*/
|
|
25
|
+
export declare const redosPronePatternRule: LinterRuleDefinition<"redos-prone-pattern", {
|
|
26
|
+
readonly default: CallableMessage<["pattern"]>;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createRule, getPattern, paramMessage, } from "@typespec/compiler";
|
|
2
|
+
import { patternIsProvablySafe } from "../constraints.js";
|
|
3
|
+
/**
|
|
4
|
+
* **A `@pattern` a backtracking engine can be made to spend unbounded time on.**
|
|
5
|
+
*
|
|
6
|
+
* **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
|
|
7
|
+
* is not valid for this library; a linter says it "could be correct, but there might be room for
|
|
8
|
+
* improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
|
|
9
|
+
* and the emitted regex is exactly the one `@typespec/openapi3` publishes. What is wrong is a cost no
|
|
10
|
+
* artefact states. `@typespec/http` puts its own advisory rule in a linter for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* **This is a server-side denial of service, not a style note.** Measured on a generated server
|
|
13
|
+
* under `workerd`: `@pattern("^(\w+\s?)*$")`, which reads as "words", answers a 31-byte query
|
|
14
|
+
* parameter in 7.8 seconds against 2.9 milliseconds for a conformant one, and the cost doubles per
|
|
15
|
+
* added byte. The caller needs no credential and the parameter is validated before any handler runs.
|
|
16
|
+
* That is why the package's `recommended` ruleset enables it, and why the documentation says to.
|
|
17
|
+
*
|
|
18
|
+
* **The emitted regex is not changed, and cannot be.** Anchoring or bounding it here would make the
|
|
19
|
+
* validator enforce something the document does not state. What is left is to say so while the author
|
|
20
|
+
* can still change the spec.
|
|
21
|
+
*
|
|
22
|
+
* **Reported once per DECLARATION.** A linter walks declarations rather than uses, so a scalar used
|
|
23
|
+
* in 153 places is one report - and an instantiated template's properties share their declaration's
|
|
24
|
+
* node, which is what the de-duplication keys on.
|
|
25
|
+
*/
|
|
26
|
+
export const redosPronePatternRule = createRule({
|
|
27
|
+
name: "redos-prone-pattern",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
description: "Flag a @pattern that cannot be proven safe against catastrophic backtracking.",
|
|
30
|
+
messages: {
|
|
31
|
+
default: paramMessage `'${"pattern"}' cannot be proven safe against catastrophic backtracking: one input can match it more than one way, so a caller can choose a value that takes exponentially long to reject. The emitted validator runs it on every request, before any handler, and the pattern is published verbatim so it cannot be rewritten for you. Remove the ambiguity, usually by taking out a quantifier nested inside another or an optional separator between repeated groups, or add a SMALL '@maxLength' to bound the work.`,
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
const seen = new WeakSet();
|
|
35
|
+
const check = (target) => {
|
|
36
|
+
const pattern = getPattern(context.program, target);
|
|
37
|
+
if (pattern === undefined || patternIsProvablySafe(pattern))
|
|
38
|
+
return;
|
|
39
|
+
const declaration = target.node ?? target;
|
|
40
|
+
if (seen.has(declaration))
|
|
41
|
+
return;
|
|
42
|
+
seen.add(declaration);
|
|
43
|
+
context.reportDiagnostic({ format: { pattern }, target });
|
|
44
|
+
};
|
|
45
|
+
return { scalar: check, modelProperty: check };
|
|
46
|
+
},
|
|
47
|
+
});
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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()";
|
|
3
13
|
export declare function withDeferredAnnotator<T>(annotator: (property: ModelProperty) => string | undefined, run: () => T): T;
|
|
4
14
|
export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
|
|
5
15
|
export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
|
|
@@ -98,23 +108,6 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
|
|
|
98
108
|
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
99
109
|
*/
|
|
100
110
|
export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
|
|
101
|
-
/**
|
|
102
|
-
* **What JSON kind a value of this type arrives as, for a parameter carried in text.**
|
|
103
|
-
*
|
|
104
|
-
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
105
|
-
* to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
|
|
106
|
-
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
107
|
-
* and fails silently the moment the expression is spelled any other way.
|
|
108
|
-
*
|
|
109
|
-
* It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
|
|
110
|
-
* neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
|
|
111
|
-
* that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
|
|
112
|
-
* caller got a 400, and no document comparison could see it, because both sides agree the parameter
|
|
113
|
-
* is a number - the disagreement is with the wire.
|
|
114
|
-
*
|
|
115
|
-
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
116
|
-
* single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
|
|
117
|
-
*/
|
|
118
111
|
export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
|
|
119
112
|
/**
|
|
120
113
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|