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.
@@ -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. `@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.
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
- return undefined;
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
- return this.#inline(type);
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((candidate) => {
376
- if (declaredNameOf(candidate) === undefined)
377
- return undefined;
378
- return this.expressionFor(candidate);
379
- }, () => propertyToZod(this.#program, property));
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((candidate) => {
441
- if (declaredNameOf(candidate) === undefined)
442
- return undefined;
443
- return this.expressionFor(candidate);
444
- }, () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type)));
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
- return this.#inline(type);
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((candidate) => {
608
- if (declaredNameOf(candidate) === undefined)
609
- return undefined;
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((candidate) => {
615
- if (declaredNameOf(candidate) === undefined)
616
- return undefined;
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
+ });
@@ -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
- * A consumer may substitute this entirely: the `runtime-module` option decides the specifier the
13
- * generated files import from, so an application that wants its own declarations points at its own
14
- * module and re-declares these names.
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 the schema the document publishes for it.
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
- * The media types this response offers, where the document names more than one.
44
+ * Every media type this response offers, including a single one.
44
45
  *
45
- * Absent where it names one, which is what an application already assumes. Present, it is the set
46
- * to negotiate against: `selectContentType` takes the caller's `Accept` and these.
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, as the document publishes them.
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 "none declared" and "none carried" are the same
58
- * thing rather than two states an application has to tell apart.
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 property: string;
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 maps its own failure to a status;
70
- * this says which schema the body must match once it has.
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.**
@@ -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
- // `@encode` changes the wire TYPE, resolved exactly as `zod.ts` resolves it - a property encoded as
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";