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.
@@ -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. It is
140
- * applied in ONE place so both walks agree: `wire-contract.gen.ts` pairs a validator against a
141
- * contract type by name, and a mangling applied to one side only would break that assertion rather
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. `@typespec/openapi3`
176
- * inlines instantiations for the same reason (the public document has no `PublicSuccess*`
177
- * component), so both artefacts agree about what has a name and what does not.
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
- return undefined;
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
- return this.#inline(type);
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((candidate) => {
376
- if (declaredNameOf(candidate) === undefined)
377
- return undefined;
378
- return this.expressionFor(candidate);
379
- }, () => propertyToZod(this.#program, property));
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((candidate) => {
441
- if (declaredNameOf(candidate) === undefined)
442
- return undefined;
443
- return this.expressionFor(candidate);
444
- }, () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
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
- return this.#inline(type);
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((candidate) => {
631
- if (declaredNameOf(candidate) === undefined)
632
- return undefined;
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((candidate) => {
638
- if (declaredNameOf(candidate) === undefined)
639
- return undefined;
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];