typespec-http-zod 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/src/api.d.ts +63 -9
- package/dist/src/api.js +710 -295
- package/dist/src/constraints.d.ts +46 -0
- package/dist/src/constraints.js +237 -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 +161 -41
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +134 -0
- 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/statuses.d.ts +24 -0
- package/dist/src/statuses.js +27 -0
- package/dist/src/types.js +37 -24
- package/dist/src/uri-template.d.ts +1 -1
- package/dist/src/uri-template.js +4 -1
- package/dist/src/zod.d.ts +25 -18
- package/dist/src/zod.js +69 -14
- package/package.json +19 -16
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
|
*
|
|
@@ -133,19 +134,40 @@ const RESERVED_DECLARATION_NAMES = new Set([
|
|
|
133
134
|
"with",
|
|
134
135
|
"yield",
|
|
135
136
|
]);
|
|
137
|
+
/**
|
|
138
|
+
* **The names the emitted files declare for THEMSELVES, beside the spec's own declarations.**
|
|
139
|
+
*
|
|
140
|
+
* `requests.gen.ts` declares `Produced`, `Simplify`, `ByteStream` and its three lookups; `schemas.gen.ts`
|
|
141
|
+
* declares `ResponseArm`. A model named after one of them was declared twice: `TS2300` for a type
|
|
142
|
+
* alias, and for `WireInputs` or `WireOutputs` something worse, because two interfaces of one name
|
|
143
|
+
* MERGE - the model's properties silently became members of the lookup. So a spec's model of that
|
|
144
|
+
* name is renamed exactly as a reserved word is.
|
|
145
|
+
*
|
|
146
|
+
* Graded by `test/collisions/`, which reads the names an emitted file declares off the OUTPUT rather
|
|
147
|
+
* than off this set, so a helper added later without a line here fails a test.
|
|
148
|
+
*/
|
|
149
|
+
const EMITTED_HELPER_NAMES = new Set([
|
|
150
|
+
"ByteStream",
|
|
151
|
+
"Produced",
|
|
152
|
+
"ResponseArm",
|
|
153
|
+
"Simplify",
|
|
154
|
+
"WireInputs",
|
|
155
|
+
"WireOutputs",
|
|
156
|
+
"WireResults",
|
|
157
|
+
]);
|
|
136
158
|
/**
|
|
137
159
|
* The name a declaration is emitted under.
|
|
138
160
|
*
|
|
139
|
-
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration
|
|
140
|
-
* applied in ONE place so both walks agree:
|
|
141
|
-
* contract type by name, and a mangling applied to
|
|
142
|
-
* than the file.
|
|
161
|
+
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration, or is one
|
|
162
|
+
* the emitted file already declares for itself. It is applied in ONE place so both walks agree:
|
|
163
|
+
* `wire-contract.gen.ts` pairs a validator against a contract type by name, and a mangling applied to
|
|
164
|
+
* one side only would break that assertion rather than the file.
|
|
143
165
|
*/
|
|
144
166
|
function safeDeclarationName(name) {
|
|
145
|
-
return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
|
|
167
|
+
return RESERVED_DECLARATION_NAMES.has(name) || EMITTED_HELPER_NAMES.has(name) ? `${name}_` : name;
|
|
146
168
|
}
|
|
147
169
|
/** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
|
|
148
|
-
function declaredNameOf(type) {
|
|
170
|
+
function declaredNameOf(program, type) {
|
|
149
171
|
if (type.kind === "Enum")
|
|
150
172
|
return safeDeclarationName(type.name);
|
|
151
173
|
/**
|
|
@@ -168,18 +190,29 @@ function declaredNameOf(type) {
|
|
|
168
190
|
return spread === undefined ? undefined : safeDeclarationName(spread);
|
|
169
191
|
}
|
|
170
192
|
/**
|
|
171
|
-
* **A template INSTANTIATION carries the template's name, and it is not a declaration
|
|
193
|
+
* **A template INSTANTIATION carries the template's name, and it is not a declaration - unless
|
|
194
|
+
* `@friendlyName` gives it one of its own.**
|
|
172
195
|
*
|
|
173
196
|
* `PublicSuccess<PublicCompanyDetail>` and `PublicSuccess<PublicOfficers>` are both called
|
|
174
197
|
* `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
|
-
*
|
|
198
|
+
* file does not compile, and if it did, one shape would validate every response.
|
|
199
|
+
* `@typespec/openapi3` inlines instantiations for the same reason.
|
|
200
|
+
*
|
|
201
|
+
* **But it stops inlining one that has been named, and this used to keep inlining it.** Its rule
|
|
202
|
+
* is `shouldInline` in `@typespec/openapi`, and the first thing that function does is
|
|
203
|
+
* `if (getFriendlyName(program, type)) return false`. Measured on
|
|
204
|
+
* `@friendlyName("{name}Box", T) model Box<T>` used as `Box<string>`: openapi3 publishes a
|
|
205
|
+
* component `stringBox` and refers to it with `$ref`, while this emitter wrote the object out
|
|
206
|
+
* inline. So the two artefacts disagreed about what has a name, which is the opposite of what
|
|
207
|
+
* this docblock used to claim, and `@friendlyName` - the remedy openapi3 names for an inline
|
|
208
|
+
* cycle, and the one {@link "inline-cycle"} names here - did nothing.
|
|
178
209
|
*
|
|
179
210
|
* Invisible until a spec uses a template; a paged-response envelope is the usual first one.
|
|
180
211
|
*/
|
|
181
|
-
if (type.templateMapper !== undefined)
|
|
182
|
-
|
|
212
|
+
if (type.templateMapper !== undefined) {
|
|
213
|
+
const friendly = getFriendlyName(program, type);
|
|
214
|
+
return friendly === undefined || friendly === "" ? undefined : safeDeclarationName(friendly);
|
|
215
|
+
}
|
|
183
216
|
return safeDeclarationName(type.name);
|
|
184
217
|
}
|
|
185
218
|
const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
@@ -269,6 +302,19 @@ export class SchemaRegistry {
|
|
|
269
302
|
#inProgress = new Map();
|
|
270
303
|
/** Keys on a cycle - every one needs a structural type rather than a `z.infer` alias. */
|
|
271
304
|
#cyclic = new Set();
|
|
305
|
+
/**
|
|
306
|
+
* Inlined types currently being walked, by identity, against the number of declarations that were
|
|
307
|
+
* in flight when each was entered.
|
|
308
|
+
*
|
|
309
|
+
* **The count is what tells a real inline cycle from an ordinary recursive model.** `#inProgress`
|
|
310
|
+
* cannot serve on its own: it is keyed by declaration name and these types have none, which is
|
|
311
|
+
* the whole reason the cycle has nothing to point at. But `InnerModel[]` re-enters `Array<InnerModel>`
|
|
312
|
+
* on a perfectly ordinary recursion - array, then the model, then the model's own `children` - and
|
|
313
|
+
* that cycle closes through `InnerModel`, which IS a declaration and terminates it. Comparing the
|
|
314
|
+
* count is how this asks openapi3's question, `cycle.containsDeclaration`: if a declaration was
|
|
315
|
+
* entered since, the cycle has a name to close on and the walk may continue.
|
|
316
|
+
*/
|
|
317
|
+
#inlineInProgress = new Map();
|
|
272
318
|
constructor(program) {
|
|
273
319
|
this.#program = program;
|
|
274
320
|
}
|
|
@@ -278,9 +324,30 @@ export class SchemaRegistry {
|
|
|
278
324
|
* what lets the generated file be plain `const` declarations with no forward references.
|
|
279
325
|
*/
|
|
280
326
|
expressionFor(type) {
|
|
281
|
-
const bare = declaredNameOf(type);
|
|
282
|
-
if (bare === undefined)
|
|
283
|
-
|
|
327
|
+
const bare = declaredNameOf(this.#program, type);
|
|
328
|
+
if (bare === undefined) {
|
|
329
|
+
/**
|
|
330
|
+
* **A back edge to an INLINED type is a cycle with no name to close it**, and following it
|
|
331
|
+
* is a stack overflow rather than a wrong answer: the compiler reports "Emitter crashed!
|
|
332
|
+
* This is a bug." Tracked by identity because there is no key to track it by.
|
|
333
|
+
*/
|
|
334
|
+
const entered = this.#inlineInProgress.get(type);
|
|
335
|
+
if (entered !== undefined && this.#inProgress.size <= entered) {
|
|
336
|
+
reportDiagnostic(this.#program, {
|
|
337
|
+
code: "inline-cycle",
|
|
338
|
+
format: { type: "name" in type && type.name !== undefined ? String(type.name) : "" },
|
|
339
|
+
target: type,
|
|
340
|
+
});
|
|
341
|
+
return UNREPRESENTABLE;
|
|
342
|
+
}
|
|
343
|
+
this.#inlineInProgress.set(type, this.#inProgress.size);
|
|
344
|
+
try {
|
|
345
|
+
return this.#inline(type);
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
this.#inlineInProgress.delete(type);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
284
351
|
/**
|
|
285
352
|
* **A type not reshaped at this visibility IS the canonical declaration - not a copy of it.**
|
|
286
353
|
*
|
|
@@ -335,6 +402,31 @@ export class SchemaRegistry {
|
|
|
335
402
|
...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type) } : {}),
|
|
336
403
|
...(rendered.deferred ? { annotated: true } : {}),
|
|
337
404
|
};
|
|
405
|
+
/**
|
|
406
|
+
* **Two different types can resolve to one identifier, and the module then does not parse.**
|
|
407
|
+
*
|
|
408
|
+
* The key is `(visibility, name, identity)`, so two models genuinely named the same in
|
|
409
|
+
* different namespaces are two entries - and both then emit `export const thingSchema`.
|
|
410
|
+
* Measured on one service declaring `Alpha.Thing` and `Beta.Thing`: the document published
|
|
411
|
+
* both components under their full names, the emitted module declared `thingSchema` twice,
|
|
412
|
+
* and `tsc` answered `TS2451: Cannot redeclare block-scoped variable` while `tsp compile`
|
|
413
|
+
* reported success.
|
|
414
|
+
*
|
|
415
|
+
* `duplicate-declaration` already existed for exactly this and already said exactly this, but
|
|
416
|
+
* only {@link TypeRegistry} raised it - so the contracts files were covered and
|
|
417
|
+
* `schemas.gen.ts`, which every consumer imports, was not. `@typespec/openapi3` reports the
|
|
418
|
+
* same class through `checkDuplicateTypeName`, and `@typespec/json-schema` through
|
|
419
|
+
* `duplicate-id`; neither resolves it for you, because only the author knows which name to
|
|
420
|
+
* change.
|
|
421
|
+
*/
|
|
422
|
+
const collision = this.#order.find((candidate) => candidate.identifier === identifier && candidate.source !== declaration.source);
|
|
423
|
+
if (collision !== undefined) {
|
|
424
|
+
reportDiagnostic(this.#program, {
|
|
425
|
+
code: "duplicate-declaration",
|
|
426
|
+
format: { name },
|
|
427
|
+
target: type,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
338
430
|
this.#declarations.set(key, declaration);
|
|
339
431
|
this.#order.push(declaration);
|
|
340
432
|
this.#declareRelatives(type);
|
|
@@ -372,11 +464,18 @@ export class SchemaRegistry {
|
|
|
372
464
|
* would be documented and never enforced.
|
|
373
465
|
*/
|
|
374
466
|
expressionForProperty(property) {
|
|
375
|
-
return withRefResolver(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
467
|
+
return withRefResolver(
|
|
468
|
+
/**
|
|
469
|
+
* **Every candidate goes through {@link expressionFor}, including the ones with no name.**
|
|
470
|
+
*
|
|
471
|
+
* This used to return `undefined` for an undeclared type, which sent it straight to
|
|
472
|
+
* `typeToZodBody` and past the only place a cycle is detected. A template instantiation is
|
|
473
|
+
* undeclared - `declaredNameOf` returns undefined for one, matching openapi3's inlining -
|
|
474
|
+
* so `model Box<T> { next?: Box<T> }` walked into itself until the stack ran out.
|
|
475
|
+
* `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
|
|
476
|
+
* also notices when it is already doing so.
|
|
477
|
+
*/
|
|
478
|
+
(candidate) => this.expressionFor(candidate), () => propertyToZod(this.#program, property));
|
|
380
479
|
}
|
|
381
480
|
/**
|
|
382
481
|
* A declaration for a shape the spec never wrote, under a name the document publishes.
|
|
@@ -424,7 +523,7 @@ export class SchemaRegistry {
|
|
|
424
523
|
* drift.
|
|
425
524
|
*/
|
|
426
525
|
#structuralBodyOf(type) {
|
|
427
|
-
return withTsRefResolver((candidate) => declaredNameOf(candidate), () => typeToTsBody(this.#program, type));
|
|
526
|
+
return withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => typeToTsBody(this.#program, type));
|
|
428
527
|
}
|
|
429
528
|
/**
|
|
430
529
|
* Render `type` without consulting its own declaration, but letting nested types use theirs.
|
|
@@ -437,11 +536,18 @@ export class SchemaRegistry {
|
|
|
437
536
|
* a nested occurrence resolve to the identifier while the body still renders as a body.
|
|
438
537
|
*/
|
|
439
538
|
#inline(type) {
|
|
440
|
-
return withRefResolver(
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
539
|
+
return withRefResolver(
|
|
540
|
+
/**
|
|
541
|
+
* **Every candidate goes through {@link expressionFor}, including the ones with no name.**
|
|
542
|
+
*
|
|
543
|
+
* This used to return `undefined` for an undeclared type, which sent it straight to
|
|
544
|
+
* `typeToZodBody` and past the only place a cycle is detected. A template instantiation is
|
|
545
|
+
* undeclared - `declaredNameOf` returns undefined for one, matching openapi3's inlining -
|
|
546
|
+
* so `model Box<T> { next?: Box<T> }` walked into itself until the stack ran out.
|
|
547
|
+
* `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
|
|
548
|
+
* also notices when it is already doing so.
|
|
549
|
+
*/
|
|
550
|
+
(candidate) => this.expressionFor(candidate), () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
|
|
445
551
|
}
|
|
446
552
|
/**
|
|
447
553
|
* The Zod type a property emitted as a getter is annotated with, or `undefined` for none.
|
|
@@ -461,7 +567,7 @@ export class SchemaRegistry {
|
|
|
461
567
|
#deferredAnnotationOf(property) {
|
|
462
568
|
if (property.defaultValue !== undefined)
|
|
463
569
|
return undefined;
|
|
464
|
-
const declared = withTsRefResolver((candidate) => declaredNameOf(candidate), () => propertyTypeToTs(this.#program, property));
|
|
570
|
+
const declared = withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => propertyTypeToTs(this.#program, property));
|
|
465
571
|
const core = `z.ZodType<${declared}, ${declared}>`;
|
|
466
572
|
const optional = property.optional || isOptionalAt(this.#program, property);
|
|
467
573
|
return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
|
|
@@ -538,13 +644,31 @@ export class TypeRegistry {
|
|
|
538
644
|
#declarations = new Map();
|
|
539
645
|
#order = [];
|
|
540
646
|
#inProgress = new Map();
|
|
647
|
+
/**
|
|
648
|
+
* The same in-flight tracking {@link SchemaRegistry} keeps, for the same cycle and by the same
|
|
649
|
+
* rule. Guarding one walk and not the other left the Zod side reporting `inline-cycle` correctly
|
|
650
|
+
* and the TypeScript side still running off the stack immediately afterwards, which reads to a
|
|
651
|
+
* consumer as "Emitter crashed! This is a bug." rather than as the error that was raised.
|
|
652
|
+
* Reported only by the schema walk, so one spec does not produce the same complaint twice.
|
|
653
|
+
*/
|
|
654
|
+
#inlineInProgress = new Map();
|
|
541
655
|
constructor(program) {
|
|
542
656
|
this.#program = program;
|
|
543
657
|
}
|
|
544
658
|
expressionFor(type) {
|
|
545
|
-
const bare = declaredNameOf(type);
|
|
546
|
-
if (bare === undefined)
|
|
547
|
-
|
|
659
|
+
const bare = declaredNameOf(this.#program, type);
|
|
660
|
+
if (bare === undefined) {
|
|
661
|
+
const entered = this.#inlineInProgress.get(type);
|
|
662
|
+
if (entered !== undefined && this.#inProgress.size <= entered)
|
|
663
|
+
return "never";
|
|
664
|
+
this.#inlineInProgress.set(type, this.#inProgress.size);
|
|
665
|
+
try {
|
|
666
|
+
return this.#inline(type);
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
this.#inlineInProgress.delete(type);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
548
672
|
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
549
673
|
const key = keyFor(type, at);
|
|
550
674
|
const name = nameAt(bare, at);
|
|
@@ -627,18 +751,14 @@ export class TypeRegistry {
|
|
|
627
751
|
*/
|
|
628
752
|
/** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
|
|
629
753
|
expressionForProperty(property, wireName) {
|
|
630
|
-
return withTsRefResolver(
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
return this.expressionFor(candidate);
|
|
634
|
-
}, () => propertyToTs(this.#program, property, wireName));
|
|
754
|
+
return withTsRefResolver(
|
|
755
|
+
// Through expressionFor even when undeclared, so the cycle guard above is reachable.
|
|
756
|
+
(candidate) => this.expressionFor(candidate), () => propertyToTs(this.#program, property, wireName));
|
|
635
757
|
}
|
|
636
758
|
#inline(type) {
|
|
637
|
-
return withTsRefResolver(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
return this.expressionFor(candidate);
|
|
641
|
-
}, () => typeToTsBody(this.#program, type));
|
|
759
|
+
return withTsRefResolver(
|
|
760
|
+
// Through expressionFor even when undeclared, so the cycle guard above is reachable.
|
|
761
|
+
(candidate) => this.expressionFor(candidate), () => typeToTsBody(this.#program, type));
|
|
642
762
|
}
|
|
643
763
|
/** Every declaration, dependencies first. */
|
|
644
764
|
declarations() {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **What a result is made of: one member per declared response and media type, and the codes each
|
|
3
|
+
* one answers with.** One derivation, read by every artefact that states a result.
|
|
4
|
+
*
|
|
5
|
+
* `typespec-hono` types a handler's return as a union of these members, `typespec-http-mcp` types a
|
|
6
|
+
* tool's the same way, and `requests.gen.ts` states the same union for a backend that has no
|
|
7
|
+
* framework to import. All three used to hold their own copy of these rules, and three copies of
|
|
8
|
+
* rules that must agree are three that will not: a status one of them could see and another could
|
|
9
|
+
* not is how `WireOutputs` came to publish one success body for an operation answering two.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A status as the DOCUMENT keys it: an exact code, a `4XX` bucket, or the catch-all.
|
|
13
|
+
*
|
|
14
|
+
* `"default"` is kept as itself rather than mapped to a number. The document means "any status not
|
|
15
|
+
* listed above", and inventing one would be answering a question the contract left open.
|
|
16
|
+
*/
|
|
17
|
+
export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
18
|
+
/**
|
|
19
|
+
* The facts about one declared response that decide how its result is shaped.
|
|
20
|
+
*
|
|
21
|
+
* Structural, so both an emitted response (`EmittedResponse`, whose body is a Zod expression) and a
|
|
22
|
+
* resolved one (whose body is a TypeSpec type) satisfy it; the caller says which ones carry a body.
|
|
23
|
+
*/
|
|
24
|
+
export interface ResponseFacts {
|
|
25
|
+
readonly status: StatusKey;
|
|
26
|
+
readonly contentTypes: readonly string[];
|
|
27
|
+
readonly binary: boolean;
|
|
28
|
+
readonly streamed: boolean;
|
|
29
|
+
readonly textual: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** How a member's body is served, which decides both its type and the call that serves it. */
|
|
32
|
+
export type BodyServing = "none" | "stream" | "binary" | "json" | "text" | "unvalidated";
|
|
33
|
+
/**
|
|
34
|
+
* One member of an operation's result: one response, served as one media type.
|
|
35
|
+
*
|
|
36
|
+
* **A status offering several media types is several members**, because the body differs by media
|
|
37
|
+
* type: the Swagger Petstore answers `getPetById` with a `Pet` as `application/json` and with text as
|
|
38
|
+
* `application/xml`. One member with a union body could not say which body goes with which type.
|
|
39
|
+
*/
|
|
40
|
+
export interface ResultMember<R extends ResponseFacts> {
|
|
41
|
+
readonly response: R;
|
|
42
|
+
/** The response's position in the list it came from, so a caller can find what it names it by. */
|
|
43
|
+
readonly index: number;
|
|
44
|
+
/** The media type served, or `undefined` where the response has no body. */
|
|
45
|
+
readonly mediaType: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the result names the media type: the status offers more than one, or the document names
|
|
48
|
+
* a range such as `image/*`, which is not a type a response can be sent as.
|
|
49
|
+
*/
|
|
50
|
+
readonly choosesMediaType: boolean;
|
|
51
|
+
readonly serving: BodyServing;
|
|
52
|
+
/**
|
|
53
|
+
* **The document declares a schema for this body that nothing checks it against**: a model under a
|
|
54
|
+
* media type that is not JSON, which the handler supplies as text (`"unvalidated"`) or, under a
|
|
55
|
+
* type that is not text, as bytes (`"binary"`). A `bytes` or `Http.File` body declares nothing to
|
|
56
|
+
* check, so it is not this.
|
|
57
|
+
*/
|
|
58
|
+
readonly unvalidated: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
62
|
+
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
63
|
+
* comparison of a declared media type is of this. `application/json; charset=utf-8` and
|
|
64
|
+
* `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
|
|
65
|
+
* and the response an unvalidated string.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mediaTypeEssenceOf(type: string): string;
|
|
68
|
+
/** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
|
|
69
|
+
export declare function isJsonMediaType(type: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
|
|
72
|
+
* section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
|
|
73
|
+
*
|
|
74
|
+
* **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
|
|
75
|
+
* has no reader or serialisation this emitter can derive, so it is handed over as the text that
|
|
76
|
+
* arrived and answered as the text the handler writes. Under any other type, such as
|
|
77
|
+
* `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
|
|
78
|
+
* every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
|
|
79
|
+
* well, and is parsed rather than handed over, so it never reaches this question.
|
|
80
|
+
*/
|
|
81
|
+
export declare function isTextualMediaType(type: string): boolean;
|
|
82
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
83
|
+
export declare function isMediaRange(mediaType: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The type of the `contentType` a result names for a member.
|
|
86
|
+
*
|
|
87
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
88
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
89
|
+
* full range is `string`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function mediaTypeTypeOf(mediaType: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* Every member an operation's responses declare, in the order the responses are listed.
|
|
94
|
+
*
|
|
95
|
+
* `hasBody` says which responses carry a body, because the two kinds of record spell that
|
|
96
|
+
* differently - an emitted one has a schema, a resolved one has a type.
|
|
97
|
+
*/
|
|
98
|
+
export declare function resultMembersOf<R extends ResponseFacts>(responses: readonly R[], hasBody: (response: R) => boolean): ResultMember<R>[];
|
|
99
|
+
/**
|
|
100
|
+
* The codes a declared response answers with, written out.
|
|
101
|
+
*
|
|
102
|
+
* - An exact code is itself.
|
|
103
|
+
* - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
|
|
104
|
+
* before its range, as a server does - and, where it carries a body, less the codes that cannot.
|
|
105
|
+
* - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
|
|
106
|
+
* again less the contentless codes where it carries a body. `1xx` is not a result a handler
|
|
107
|
+
* returns.
|
|
108
|
+
*
|
|
109
|
+
* **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
|
|
110
|
+
* narrow a result on a status type that admits everything. Empty where nothing is left, which a
|
|
111
|
+
* caller renders as `never`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function statusCodesOf<R extends ResponseFacts>(response: R, responses: readonly R[], hasBody: (response: R) => boolean): readonly number[];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
2
|
+
/**
|
|
3
|
+
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
4
|
+
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
5
|
+
* comparison of a declared media type is of this. `application/json; charset=utf-8` and
|
|
6
|
+
* `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
|
|
7
|
+
* and the response an unvalidated string.
|
|
8
|
+
*/
|
|
9
|
+
export function mediaTypeEssenceOf(type) {
|
|
10
|
+
return (type.split(";")[0] ?? "").trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
/** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
|
|
13
|
+
export function isJsonMediaType(type) {
|
|
14
|
+
return /^application\/(json|.*\+json)$/.test(mediaTypeEssenceOf(type));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
|
|
18
|
+
* section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
|
|
19
|
+
*
|
|
20
|
+
* **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
|
|
21
|
+
* has no reader or serialisation this emitter can derive, so it is handed over as the text that
|
|
22
|
+
* arrived and answered as the text the handler writes. Under any other type, such as
|
|
23
|
+
* `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
|
|
24
|
+
* every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
|
|
25
|
+
* well, and is parsed rather than handed over, so it never reaches this question.
|
|
26
|
+
*/
|
|
27
|
+
export function isTextualMediaType(type) {
|
|
28
|
+
const essence = mediaTypeEssenceOf(type);
|
|
29
|
+
return essence.startsWith("text/") || essence === "application/xml" || essence.endsWith("+xml");
|
|
30
|
+
}
|
|
31
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
32
|
+
export function isMediaRange(mediaType) {
|
|
33
|
+
return mediaTypeEssenceOf(mediaType).endsWith("/*");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The type of the `contentType` a result names for a member.
|
|
37
|
+
*
|
|
38
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
39
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
40
|
+
* full range is `string`.
|
|
41
|
+
*/
|
|
42
|
+
export function mediaTypeTypeOf(mediaType) {
|
|
43
|
+
if (!isMediaRange(mediaType))
|
|
44
|
+
return JSON.stringify(mediaType);
|
|
45
|
+
if (mediaType === "*/*")
|
|
46
|
+
return "string";
|
|
47
|
+
return `\`${mediaType.slice(0, -1)}\${string}\``;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every member an operation's responses declare, in the order the responses are listed.
|
|
51
|
+
*
|
|
52
|
+
* `hasBody` says which responses carry a body, because the two kinds of record spell that
|
|
53
|
+
* differently - an emitted one has a schema, a resolved one has a type.
|
|
54
|
+
*/
|
|
55
|
+
export function resultMembersOf(responses, hasBody) {
|
|
56
|
+
return responses.flatMap((response, index) => {
|
|
57
|
+
if (response.contentTypes.length === 0) {
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
response,
|
|
61
|
+
index,
|
|
62
|
+
mediaType: undefined,
|
|
63
|
+
choosesMediaType: false,
|
|
64
|
+
serving: "none",
|
|
65
|
+
unvalidated: false,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
|
|
70
|
+
return response.contentTypes.map((mediaType) => {
|
|
71
|
+
const serving = servingOf(response, mediaType, hasBody(response));
|
|
72
|
+
return {
|
|
73
|
+
response,
|
|
74
|
+
index,
|
|
75
|
+
mediaType,
|
|
76
|
+
choosesMediaType,
|
|
77
|
+
serving,
|
|
78
|
+
unvalidated: serving === "unvalidated" || (serving === "binary" && !response.binary),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function servingOf(response, mediaType, bodied) {
|
|
84
|
+
if (!bodied)
|
|
85
|
+
return "none";
|
|
86
|
+
if (response.streamed)
|
|
87
|
+
return "stream";
|
|
88
|
+
if (response.binary)
|
|
89
|
+
return "binary";
|
|
90
|
+
if (isJsonMediaType(mediaType))
|
|
91
|
+
return "json";
|
|
92
|
+
/**
|
|
93
|
+
* **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
|
|
94
|
+
* Any other body under one - a model as `application/xml` - has no serialisation this emitter can
|
|
95
|
+
* derive, so it has to come from the application and is served unvalidated: as the text it writes
|
|
96
|
+
* under a textual media type, and as bytes under any other (see {@link isTextualMediaType}).
|
|
97
|
+
*/
|
|
98
|
+
if (response.textual)
|
|
99
|
+
return "text";
|
|
100
|
+
return isTextualMediaType(mediaType) ? "unvalidated" : "binary";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The codes a declared response answers with, written out.
|
|
104
|
+
*
|
|
105
|
+
* - An exact code is itself.
|
|
106
|
+
* - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
|
|
107
|
+
* before its range, as a server does - and, where it carries a body, less the codes that cannot.
|
|
108
|
+
* - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
|
|
109
|
+
* again less the contentless codes where it carries a body. `1xx` is not a result a handler
|
|
110
|
+
* returns.
|
|
111
|
+
*
|
|
112
|
+
* **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
|
|
113
|
+
* narrow a result on a status type that admits everything. Empty where nothing is left, which a
|
|
114
|
+
* caller renders as `never`.
|
|
115
|
+
*/
|
|
116
|
+
export function statusCodesOf(response, responses, hasBody) {
|
|
117
|
+
const { status } = response;
|
|
118
|
+
if (typeof status === "number")
|
|
119
|
+
return [status];
|
|
120
|
+
const exact = new Set(responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
|
|
121
|
+
const contentless = new Set(hasBody(response) ? CONTENTLESS_STATUS_CODES : []);
|
|
122
|
+
const classOf = (key) => STATUS_CODES[Number(key.charAt(0))];
|
|
123
|
+
const candidates = status === "default"
|
|
124
|
+
? [2, 3, 4, 5].flatMap((digit) => STATUS_CODES[digit])
|
|
125
|
+
: classOf(status);
|
|
126
|
+
const ranged = new Set(status === "default"
|
|
127
|
+
? responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
|
|
128
|
+
? classOf(candidate.status)
|
|
129
|
+
: [])
|
|
130
|
+
: []);
|
|
131
|
+
return candidates
|
|
132
|
+
.filter((code) => !exact.has(code) && !ranged.has(code) && !contentless.has(code))
|
|
133
|
+
.toSorted((a, b) => a - b);
|
|
134
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The status codes a server can answer with, by class.** A `4XX` range or a `default` response
|
|
3
|
+
* means "every code in these classes that nothing more specific claims", and a type for such a
|
|
4
|
+
* result needs those codes spelled out: `number` would admit `999`, and a type that admits a status
|
|
5
|
+
* no server can send is a type no client can narrow on.
|
|
6
|
+
*
|
|
7
|
+
* **One list, owned here, because two server emitters each held a copy.** `typespec-hono` and
|
|
8
|
+
* `typespec-http-mcp` both typed a ranged or `default` result from their own table, and the contract
|
|
9
|
+
* types in `requests.gen.ts` need the same one to state a result a backend can hand straight to
|
|
10
|
+
* either. Three copies of a table that must agree are three tables that will not.
|
|
11
|
+
*
|
|
12
|
+
* **Graded outside this package, against Hono's own `http-status.d.ts`,** by the two emitters that
|
|
13
|
+
* serve through it - an expectation that does not come from the code it grades. A code Hono adds
|
|
14
|
+
* turns their arm red here first.
|
|
15
|
+
*/
|
|
16
|
+
export declare const STATUS_CODES: {
|
|
17
|
+
readonly 1: readonly [100, 101, 102, 103];
|
|
18
|
+
readonly 2: readonly [200, 201, 202, 203, 204, 205, 206, 207, 208, 226];
|
|
19
|
+
readonly 3: readonly [300, 301, 302, 303, 304, 305, 306, 307, 308];
|
|
20
|
+
readonly 4: readonly [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451];
|
|
21
|
+
readonly 5: readonly [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511];
|
|
22
|
+
};
|
|
23
|
+
/** The codes a response cannot carry a body under, per RFC 9110: 101, 204, 205 and 304. */
|
|
24
|
+
export declare const CONTENTLESS_STATUS_CODES: readonly [101, 204, 205, 304];
|