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
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
|
-
import { propertyToTs, typeToTsBody, withTsRefResolver } from "./types.js";
|
|
4
|
-
import { captureBackEdges, currentVisibility, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
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
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,41 @@ 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))));
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* The Zod type a property emitted as a getter is annotated with, or `undefined` for none.
|
|
533
|
+
*
|
|
534
|
+
* **Written here because naming a type is this class's job.** The walk in `zod.ts` produces the
|
|
535
|
+
* getter; only the registry resolves a named type to the declaration that carries it, so only it
|
|
536
|
+
* can write `Record<string, InnerModel>` rather than inlining the model and recursing forever.
|
|
537
|
+
* `types.ts` is the same walk `propertyToTs` uses, so the annotation cannot drift from the type
|
|
538
|
+
* the wire contract compares against.
|
|
539
|
+
*
|
|
540
|
+
* **A DEFAULTED property is left unannotated.** `propertyToZod` emits `.default(...)` only where
|
|
541
|
+
* `defaultOf` could render the value and reports a diagnostic where it could not, so an annotation
|
|
542
|
+
* written here would have to predict that decision; predicting it wrongly would put a type on the
|
|
543
|
+
* getter that its own value does not satisfy. A recursive property carrying a default is the one
|
|
544
|
+
* shape this leaves as it was.
|
|
545
|
+
*/
|
|
546
|
+
#deferredAnnotationOf(property) {
|
|
547
|
+
if (property.defaultValue !== undefined)
|
|
548
|
+
return undefined;
|
|
549
|
+
const declared = withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => propertyTypeToTs(this.#program, property));
|
|
550
|
+
const core = `z.ZodType<${declared}, ${declared}>`;
|
|
551
|
+
const optional = property.optional || isOptionalAt(this.#program, property);
|
|
552
|
+
return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
|
|
445
553
|
}
|
|
446
554
|
/** Every declaration, dependencies first. */
|
|
447
555
|
declarations() {
|
|
@@ -515,13 +623,31 @@ export class TypeRegistry {
|
|
|
515
623
|
#declarations = new Map();
|
|
516
624
|
#order = [];
|
|
517
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();
|
|
518
634
|
constructor(program) {
|
|
519
635
|
this.#program = program;
|
|
520
636
|
}
|
|
521
637
|
expressionFor(type) {
|
|
522
|
-
const bare = declaredNameOf(type);
|
|
523
|
-
if (bare === undefined)
|
|
524
|
-
|
|
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
|
+
}
|
|
525
651
|
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
526
652
|
const key = keyFor(type, at);
|
|
527
653
|
const name = nameAt(bare, at);
|
|
@@ -604,18 +730,14 @@ export class TypeRegistry {
|
|
|
604
730
|
*/
|
|
605
731
|
/** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
|
|
606
732
|
expressionForProperty(property, wireName) {
|
|
607
|
-
return withTsRefResolver(
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
return this.expressionFor(candidate);
|
|
611
|
-
}, () => 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));
|
|
612
736
|
}
|
|
613
737
|
#inline(type) {
|
|
614
|
-
return withTsRefResolver(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
return this.expressionFor(candidate);
|
|
618
|
-
}, () => 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));
|
|
619
741
|
}
|
|
620
742
|
/** Every declaration, dependencies first. */
|
|
621
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/runtime.d.ts
CHANGED
|
@@ -9,16 +9,12 @@ import type { ZodType } from "zod";
|
|
|
9
9
|
* the sake of two declarations. `test/packaging.test.ts` asserts the separation rather than trusting
|
|
10
10
|
* it.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The emitted `schemas.gen.ts` does not import it: the arm lists there are annotated with a copy of
|
|
13
|
+
* {@link ResponseArm} declared in that file, so the output depends on no runtime at all. This module
|
|
14
|
+
* is what an application reading those arms imports, and `armFor` is the rule it reads them by.
|
|
15
15
|
*/
|
|
16
16
|
/**
|
|
17
|
-
* One status an operation may answer with, and
|
|
18
|
-
*
|
|
19
|
-
* `when` is present only where an operation declares two success arms discriminated by a property of
|
|
20
|
-
* the result - the application reads that property, because only it knows how to see inside its own
|
|
21
|
-
* result envelope. Everything else about the choice is decided at generation time.
|
|
17
|
+
* One status an operation may answer with, and what the document publishes for it.
|
|
22
18
|
*
|
|
23
19
|
* **Failure arms are here too, and for a while they were not.** The document declares what an
|
|
24
20
|
* error response looks like, and the generated server passed on only the success statuses - so an
|
|
@@ -32,6 +28,11 @@ import type { ZodType } from "zod";
|
|
|
32
28
|
* status not otherwise listed. Sorting once at generation time is what keeps every application from
|
|
33
29
|
* re-deriving the rule - and from getting it silently wrong when a `404` and a `4XX` both apply.
|
|
34
30
|
*
|
|
31
|
+
* **Nothing here says how to choose an arm from a value.** An application names the status it is
|
|
32
|
+
* answering with, and {@link armFor} says which arm governs it. A `when` field used to name a
|
|
33
|
+
* property of the result to read the choice from; that made the choice depend on a TypeSpec property
|
|
34
|
+
* name the document never states, and it is gone.
|
|
35
|
+
*
|
|
35
36
|
* **`schema: undefined` is a statement, not an absence.** A response the document says carries no
|
|
36
37
|
* body has no validator, and that is the truthful answer. It is also why the generated arms are
|
|
37
38
|
* annotated with `satisfies readonly ResponseArm[]` rather than `as const`: measured, `as const`
|
|
@@ -40,34 +41,31 @@ import type { ZodType } from "zod";
|
|
|
40
41
|
*/
|
|
41
42
|
export interface ResponseArm {
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
+
* Every media type this response offers, including a single one.
|
|
44
45
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
46
|
+
* **A single type is carried too.** Omitting it assumed the one type is JSON, and a generic
|
|
47
|
+
* responder could then not tell a `text/plain` arm from a JSON one: the document knew and the
|
|
48
|
+
* runtime did not. Absent only where the response carries no body.
|
|
47
49
|
*/
|
|
48
50
|
readonly contentTypes?: readonly string[];
|
|
49
51
|
/**
|
|
50
|
-
* The headers this response declares,
|
|
51
|
-
*
|
|
52
|
-
* **Two names, because two different things need them.** `name` is the WIRE name, which is what
|
|
53
|
-
* the response sets; `property` is the name on the value the handler returned, which is where the
|
|
54
|
-
* value is read from. `@header("x-correlation-id") correlationId: string` is `x-correlation-id`
|
|
55
|
-
* on the wire and `correlationId` in the result.
|
|
52
|
+
* The headers this response declares, by the WIRE name the response sets.
|
|
56
53
|
*
|
|
57
|
-
* Absent where the response declares none, so
|
|
58
|
-
* thing rather than two states an application has
|
|
54
|
+
* `optional` is the document's `required: false`. Absent where the response declares none, so
|
|
55
|
+
* "none declared" and "none carried" are the same thing rather than two states an application has
|
|
56
|
+
* to tell apart.
|
|
59
57
|
*/
|
|
60
58
|
readonly headers?: readonly {
|
|
61
59
|
readonly name: string;
|
|
62
|
-
readonly
|
|
60
|
+
readonly optional: boolean;
|
|
63
61
|
}[];
|
|
64
62
|
/**
|
|
65
63
|
* The status this arm answers with: an exact code, a `4XX`-style range, or the catch-all.
|
|
66
64
|
*
|
|
67
65
|
* **`"default"` is carried as itself, not resolved to a number.** OpenAPI's `default` response
|
|
68
66
|
* means "any status not listed", and an emitter picking one would be answering a question the
|
|
69
|
-
* contract deliberately left to the application. The application
|
|
70
|
-
*
|
|
67
|
+
* contract deliberately left to the application. The application names its own status; this says
|
|
68
|
+
* which schema the body must match once it has.
|
|
71
69
|
*
|
|
72
70
|
* **A range is carried as itself for the same reason, and it used to be dropped entirely.**
|
|
73
71
|
* `@minValue(400) @maxValue(499) @statusCode _: int32` is a `4XX` entry in the document; the
|
|
@@ -78,18 +76,6 @@ export interface ResponseArm {
|
|
|
78
76
|
*/
|
|
79
77
|
readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
80
78
|
readonly schema: ZodType | undefined;
|
|
81
|
-
/**
|
|
82
|
-
* How a handler said WHICH arm it meant, where an operation declares more than one success.
|
|
83
|
-
*
|
|
84
|
-
* **`number` too, because a `@statusCode` union selects by the status itself.**
|
|
85
|
-
* `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses and its
|
|
86
|
-
* values are the statuses; the discriminator case carries a boolean or string literal off the body
|
|
87
|
-
* instead. Both answer the same question, so both use this one field.
|
|
88
|
-
*/
|
|
89
|
-
readonly when?: {
|
|
90
|
-
readonly property: string;
|
|
91
|
-
readonly value: boolean | number | string;
|
|
92
|
-
};
|
|
93
79
|
}
|
|
94
80
|
/**
|
|
95
81
|
* The arm that governs a status - **the rule the OpenAPI specification states, applied once here.**
|
package/dist/src/types.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ import { type ModelProperty, type Program, type Type } from "@typespec/compiler"
|
|
|
9
9
|
* types then failed to typecheck against a server that was correct: `z.object({ "x-thing": ... })`
|
|
10
10
|
* on one side and `{ thing: string }` on the other.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* The TypeScript type of a property's VALUE, without its key or its optionality.
|
|
14
|
+
*
|
|
15
|
+
* Shared with the annotation the registry writes on a deferred (getter) property, so the type that
|
|
16
|
+
* annotation claims is by construction the type this walk renders for the same property.
|
|
17
|
+
*/
|
|
18
|
+
export declare function propertyTypeToTs(program: Program, property: ModelProperty): string;
|
|
12
19
|
export declare function propertyToTs(program: Program, property: ModelProperty, wireName?: string): string;
|
|
13
20
|
export declare function withTsRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
|
|
14
21
|
export declare function typeToTs(program: Program, type: Type): string;
|
package/dist/src/types.js
CHANGED
|
@@ -254,14 +254,23 @@ function modelToTs(program, model) {
|
|
|
254
254
|
* types then failed to typecheck against a server that was correct: `z.object({ "x-thing": ... })`
|
|
255
255
|
* on one side and `{ thing: string }` on the other.
|
|
256
256
|
*/
|
|
257
|
+
/**
|
|
258
|
+
* The TypeScript type of a property's VALUE, without its key or its optionality.
|
|
259
|
+
*
|
|
260
|
+
* Shared with the annotation the registry writes on a deferred (getter) property, so the type that
|
|
261
|
+
* annotation claims is by construction the type this walk renders for the same property.
|
|
262
|
+
*/
|
|
263
|
+
export function propertyTypeToTs(program, property) {
|
|
264
|
+
// `@encode` changes the wire TYPE, resolved exactly as `zod.ts` resolves it - a property encoded as
|
|
265
|
+
// a string on one side and an array on the other fails the emitted assertion, and deserves to.
|
|
266
|
+
const encoded = getEncode(program, property)?.type;
|
|
267
|
+
return typeToTs(program, encoded ?? property.type);
|
|
268
|
+
}
|
|
257
269
|
export function propertyToTs(program, property, wireName) {
|
|
258
270
|
// The WIRE name, resolved exactly as `zod.ts` resolves it - `wire-contract.gen.ts` asserts the
|
|
259
271
|
// emitted Zod infers this type, so the two must name every property identically or it fails.
|
|
260
272
|
const key = objectKey(wireName ?? resolveEncodedName(program, property, "application/json"));
|
|
261
|
-
|
|
262
|
-
// a string on one side and an array on the other fails the emitted assertion, and deserves to.
|
|
263
|
-
const encoded = getEncode(program, property)?.type;
|
|
264
|
-
const value = typeToTs(program, encoded ?? property.type);
|
|
273
|
+
const value = propertyTypeToTs(program, property);
|
|
265
274
|
/**
|
|
266
275
|
* **A defaulted property used to be REQUIRED here, and that was the OUTPUT type's answer to a
|
|
267
276
|
* question this file does not ask.**
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* One `/`-delimited segment of a route, as a server has to mount it.
|
|
15
|
+
*
|
|
16
|
+
* - `literal`: fixed text.
|
|
17
|
+
* - `expression`: exactly one parameter, with the literal text written before and after it inside
|
|
18
|
+
* the same segment, its RFC 6570 operator (`""` for simple expansion) and whether it explodes. A
|
|
19
|
+
* `/` expression opens a segment of its own, and one that explodes spans every segment after it.
|
|
20
|
+
* - `unsupported`: a segment holding more than one expression, which no segment router can split.
|
|
21
|
+
*/
|
|
22
|
+
export type EmittedPathSegment = {
|
|
23
|
+
readonly kind: "literal";
|
|
24
|
+
readonly text: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "expression";
|
|
27
|
+
/** The parameter's WIRE name, the one `pathSchema` keys on. */
|
|
28
|
+
readonly parameter: string;
|
|
29
|
+
readonly prefix: string;
|
|
30
|
+
readonly suffix: string;
|
|
31
|
+
readonly operator: "" | "+" | "#" | "." | "/" | ";";
|
|
32
|
+
readonly explode: boolean;
|
|
33
|
+
readonly optional: boolean;
|
|
34
|
+
readonly reserved: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: "unsupported";
|
|
37
|
+
readonly text: string;
|
|
38
|
+
};
|
|
39
|
+
/** What the path segments need to know about each path parameter the operation resolves. */
|
|
40
|
+
export interface PathParameterFacts {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly optional: boolean;
|
|
43
|
+
readonly reserved: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** A route template as the segments a server mounts, and the literal query pairs it carries. */
|
|
46
|
+
export declare function routeTemplateOf(uriTemplate: string, parameters: readonly PathParameterFacts[]): {
|
|
47
|
+
readonly segments: readonly EmittedPathSegment[];
|
|
48
|
+
readonly literalQuery: readonly (readonly [string, string])[];
|
|
49
|
+
};
|
|
50
|
+
/** What a value decodes into: one value, a list, or name/value pairs (a record or a model). */
|
|
51
|
+
export type ExpansionShape = "scalar" | "list" | "record";
|
|
52
|
+
/**
|
|
53
|
+
* Whether a path value needs decoding at all. **An ordinary whole-segment parameter does not**, and
|
|
54
|
+
* leaving its validator untouched keeps every spec without an RFC 6570 form byte-identical.
|
|
55
|
+
*/
|
|
56
|
+
export declare function needsExpansionDecoding(segment: Extract<EmittedPathSegment, {
|
|
57
|
+
kind: "expression";
|
|
58
|
+
}>, shape: ExpansionShape): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The call that decodes one expansion, as a `z.preprocess` argument. The decoder itself is
|
|
61
|
+
* {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function uriExpansionCall(spec: {
|
|
64
|
+
readonly prefix: string;
|
|
65
|
+
readonly suffix: string;
|
|
66
|
+
readonly operator: string;
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly explode: boolean;
|
|
69
|
+
readonly shape: ExpansionShape;
|
|
70
|
+
readonly values: "number" | "boolean" | "";
|
|
71
|
+
}): string;
|
|
72
|
+
/**
|
|
73
|
+
* **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
|
|
74
|
+
* sits in (or, for an exploding `/` expression, every segment after its literal), and this undoes
|
|
75
|
+
* the expansion: the literal around it, the operator's leading character, a matrix parameter's
|
|
76
|
+
* `name=`, then the joiner the operator and explode modifier chose.
|
|
77
|
+
*
|
|
78
|
+
* Text that is not the expansion the route declares is REFUSED with an issue of its own, not passed
|
|
79
|
+
* on: a string parameter's schema would accept `primitive;other=a` as the string it is, so leaving
|
|
80
|
+
* the refusal to the schema admitted a request the route does not match. A list's element values
|
|
81
|
+
* are decoded by the element decoder inside, as a query list's are; a record's values are decoded
|
|
82
|
+
* here, because no schema expression reaches them one by one.
|
|
83
|
+
*/
|
|
84
|
+
export declare const URI_EXPANSION_SOURCE = "\n/** RFC 6570 section 3.2 in reverse: one expression's expansion, decoded into the value it expanded. */\nfunction uriExpansion(\n\traw: unknown,\n\tctx: { addIssue(issue: { code: \"custom\"; message: string }): void },\n\tspec: {\n\t\treadonly prefix: string;\n\t\treadonly suffix: string;\n\t\treadonly operator: string;\n\t\treadonly name: string;\n\t\treadonly explode: boolean;\n\t\treadonly shape: \"scalar\" | \"list\" | \"record\";\n\t\treadonly values: \"number\" | \"boolean\" | \"\";\n\t},\n): unknown {\n\tif (typeof raw !== \"string\") return raw;\n\tconst refuse = (): unknown => {\n\t\tctx.addIssue({ code: \"custom\", message: `Not the expansion of {${spec.operator}${spec.name}${spec.explode ? \"*\" : \"\"}} this route declares` });\n\t\treturn z.NEVER;\n\t};\n\tif (raw.length < spec.prefix.length + spec.suffix.length) return refuse();\n\tif (!raw.startsWith(spec.prefix) || !raw.endsWith(spec.suffix)) return refuse();\n\tlet text = raw.slice(spec.prefix.length, raw.length - spec.suffix.length);\n\tif (spec.operator === \".\" || spec.operator === \";\") {\n\t\tif (!text.startsWith(spec.operator)) return refuse();\n\t\ttext = text.slice(1);\n\t}\n\tif (spec.operator === \";\" && !(spec.shape === \"record\" && spec.explode)) {\n\t\tconst named = text\n\t\t\t.split(\";\")\n\t\t\t.map((part) => (part === spec.name ? \"\" : part.startsWith(`${spec.name}=`) ? part.slice(spec.name.length + 1) : undefined));\n\t\tif (named.some((part) => part === undefined)) return refuse();\n\t\tif (spec.shape === \"list\" && spec.explode) return named;\n\t\tif (named.length !== 1) return refuse();\n\t\ttext = named[0] ?? \"\";\n\t}\n\tif (spec.shape === \"scalar\") return text;\n\tconst joiner = spec.explode && (spec.operator === \".\" || spec.operator === \"/\" || spec.operator === \";\") ? spec.operator : \",\";\n\tif (spec.shape === \"list\") return text === \"\" ? [] : text.split(joiner);\n\tconst value = (item: string): unknown =>\n\t\tspec.values === \"number\" && item.trim() !== \"\" && Number.isFinite(Number(item))\n\t\t\t? Number(item)\n\t\t\t: spec.values === \"boolean\" && (item === \"true\" || item === \"false\")\n\t\t\t\t? item === \"true\"\n\t\t\t\t: item;\n\tconst entries: [string, unknown][] = [];\n\tif (spec.explode) {\n\t\tfor (const pair of text === \"\" ? [] : text.split(joiner)) {\n\t\t\tconst at = pair.indexOf(\"=\");\n\t\t\tif (at === -1) return refuse();\n\t\t\tentries.push([pair.slice(0, at), value(pair.slice(at + 1))]);\n\t\t}\n\t} else {\n\t\tconst items = text === \"\" ? [] : text.split(\",\");\n\t\tif (items.length % 2 !== 0) return refuse();\n\t\tfor (let index = 0; index < items.length; index += 2) {\n\t\t\tentries.push([items[index] ?? \"\", value(items[index + 1] ?? \"\")]);\n\t\t}\n\t}\n\treturn Object.fromEntries(entries);\n}\n";
|
|
85
|
+
/**
|
|
86
|
+
* The call that gathers a form-exploded record or model back under its parameter name. The
|
|
87
|
+
* gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
|
|
88
|
+
*/
|
|
89
|
+
export declare function explodedQueryCall(spec: {
|
|
90
|
+
readonly name: string;
|
|
91
|
+
/** For a record, every OTHER query parameter's wire name; its own keys are whatever is left. */
|
|
92
|
+
readonly others: readonly string[];
|
|
93
|
+
/** For a model, its own properties' wire names; `undefined` for a record. */
|
|
94
|
+
readonly keys: readonly string[] | undefined;
|
|
95
|
+
readonly numbers: readonly string[];
|
|
96
|
+
readonly booleans: readonly string[];
|
|
97
|
+
/** For a record, how every value decodes. */
|
|
98
|
+
readonly values: "number" | "boolean" | "";
|
|
99
|
+
}): string;
|
|
100
|
+
/**
|
|
101
|
+
* **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
|
|
102
|
+
* `?field=status&value=active`, so there is no `param` key for a query validator to find. This
|
|
103
|
+
* gathers those keys back under the parameter's name before the query object is validated: a model
|
|
104
|
+
* takes exactly its declared properties, a record every key no other query parameter claims.
|
|
105
|
+
*/
|
|
106
|
+
export declare const EXPLODED_QUERY_SOURCE = "\n/** RFC 6570 form expansion of an exploded record or model, gathered back under its parameter. */\nfunction explodedQuery(\n\traw: unknown,\n\tspec: {\n\t\treadonly name: string;\n\t\treadonly others: readonly string[];\n\t\treadonly keys: readonly string[] | null;\n\t\treadonly numbers: readonly string[];\n\t\treadonly booleans: readonly string[];\n\t\treadonly values: \"number\" | \"boolean\" | \"\";\n\t},\n): unknown {\n\tif (raw === null || typeof raw !== \"object\" || Array.isArray(raw) || spec.name in raw) return raw;\n\tconst gathered: Record<string, unknown> = {};\n\tconst rest: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(raw)) {\n\t\tconst own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);\n\t\tif (!own) {\n\t\t\trest[key] = value;\n\t\t\tcontinue;\n\t\t}\n\t\tconst kind = spec.values !== \"\" ? spec.values : spec.numbers.includes(key) ? \"number\" : spec.booleans.includes(key) ? \"boolean\" : \"\";\n\t\tgathered[key] =\n\t\t\ttypeof value !== \"string\"\n\t\t\t\t? value\n\t\t\t\t: kind === \"number\" && value.trim() !== \"\" && Number.isFinite(Number(value))\n\t\t\t\t\t? Number(value)\n\t\t\t\t\t: kind === \"boolean\" && (value === \"true\" || value === \"false\")\n\t\t\t\t\t\t? value === \"true\"\n\t\t\t\t\t\t: value;\n\t}\n\treturn Object.keys(gathered).length === 0 ? raw : { ...rest, [spec.name]: gathered };\n}\n";
|