typespec-http-zod 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/lib.d.ts CHANGED
@@ -92,24 +92,6 @@ export interface EmitterOptions {
92
92
  * indistinguishable from one that is empty.
93
93
  */
94
94
  "key-vocabularies"?: string[];
95
- /**
96
- * The specifier the generated files import their runtime contract from. Defaults to
97
- * `typespec-http-zod/runtime`.
98
- *
99
- * **This is here because the emitted response arms are annotated, and the annotation earns its
100
- * keep.** `schemas.gen.ts` declares what each operation may answer with as
101
- * `... satisfies readonly ResponseArm[]`. Measured against the alternative: `as const` needs no
102
- * import at all, and catches a wrong `status` - but it is **blind** to a misspelled or omitted
103
- * `schema`, because `schema: undefined` is legitimate for a bodyless response. The typo then
104
- * produces a valid-looking arm that validates nothing, which is the failure this package exists to
105
- * prevent. `satisfies` reports both, at the declaration, naming the key.
106
- *
107
- * An emitter built on this library points the default at its own runtime, which re-exports these
108
- * names; an application that substitutes its own result or context types points it at its own
109
- * module and re-declares them. That is also what makes the emitted output loadable from this
110
- * package's own tests, where the package name does not resolve.
111
- */
112
- "runtime-module"?: string;
113
95
  /**
114
96
  * The command that regenerates these files, written into every banner.
115
97
  *
@@ -134,7 +116,6 @@ export interface EmitterOptions {
134
116
  "seal-object-schemas"?: boolean;
135
117
  "compile-schemas"?: boolean;
136
118
  "key-vocabularies"?: string[];
137
- "runtime-module"?: string;
138
119
  "regenerate-hint"?: string;
139
120
  }>;
140
121
  }
package/dist/src/lib.js CHANGED
@@ -14,7 +14,6 @@ export const EmitterOptionsSchema = {
14
14
  "seal-object-schemas": { type: "boolean", nullable: true },
15
15
  "compile-schemas": { type: "boolean", nullable: true },
16
16
  "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
17
- "runtime-module": { type: "string", nullable: true },
18
17
  "regenerate-hint": { type: "string", nullable: true },
19
18
  services: {
20
19
  type: "object",
@@ -29,7 +28,6 @@ export const EmitterOptionsSchema = {
29
28
  "seal-object-schemas": { type: "boolean", nullable: true },
30
29
  "compile-schemas": { type: "boolean", nullable: true },
31
30
  "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
32
- "runtime-module": { type: "string", nullable: true },
33
31
  "regenerate-hint": { type: "string", nullable: true },
34
32
  },
35
33
  required: [],
@@ -1,7 +1,7 @@
1
1
  import { getVisibilitySuffix, Visibility } from "@typespec/http";
2
2
  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";
3
+ import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
4
+ import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
5
5
  /**
6
6
  * The name an ANONYMOUS payload model inherits from the single model it spreads.
7
7
  *
@@ -441,7 +441,30 @@ export class SchemaRegistry {
441
441
  if (declaredNameOf(candidate) === undefined)
442
442
  return undefined;
443
443
  return this.expressionFor(candidate);
444
- }, () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type)));
444
+ }, () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
445
+ }
446
+ /**
447
+ * The Zod type a property emitted as a getter is annotated with, or `undefined` for none.
448
+ *
449
+ * **Written here because naming a type is this class's job.** The walk in `zod.ts` produces the
450
+ * getter; only the registry resolves a named type to the declaration that carries it, so only it
451
+ * can write `Record<string, InnerModel>` rather than inlining the model and recursing forever.
452
+ * `types.ts` is the same walk `propertyToTs` uses, so the annotation cannot drift from the type
453
+ * the wire contract compares against.
454
+ *
455
+ * **A DEFAULTED property is left unannotated.** `propertyToZod` emits `.default(...)` only where
456
+ * `defaultOf` could render the value and reports a diagnostic where it could not, so an annotation
457
+ * written here would have to predict that decision; predicting it wrongly would put a type on the
458
+ * getter that its own value does not satisfy. A recursive property carrying a default is the one
459
+ * shape this leaves as it was.
460
+ */
461
+ #deferredAnnotationOf(property) {
462
+ if (property.defaultValue !== undefined)
463
+ return undefined;
464
+ const declared = withTsRefResolver((candidate) => declaredNameOf(candidate), () => propertyTypeToTs(this.#program, property));
465
+ const core = `z.ZodType<${declared}, ${declared}>`;
466
+ const optional = property.optional || isOptionalAt(this.#program, property);
467
+ return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
445
468
  }
446
469
  /** Every declaration, dependencies first. */
447
470
  declarations() {
@@ -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";
@@ -0,0 +1,254 @@
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
+ const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"];
14
+ function parseUriTemplate(template) {
15
+ const parts = [];
16
+ for (const [, rawExpression, literal] of template.matchAll(/\{([^{}]+)\}|([^{}]+)/g)) {
17
+ if (rawExpression === undefined) {
18
+ parts.push(literal ?? "");
19
+ continue;
20
+ }
21
+ let expression = rawExpression;
22
+ let operator;
23
+ const first = expression[0];
24
+ if (first !== undefined && OPERATORS.includes(first)) {
25
+ operator = first;
26
+ expression = expression.slice(1);
27
+ }
28
+ for (const item of expression.split(",")) {
29
+ const match = /([^:*]*)(?::(\d+)|(\*))?/.exec(item);
30
+ parts.push({ name: match?.[1] ?? item, operator, explode: match?.[3] !== undefined });
31
+ }
32
+ }
33
+ return parts;
34
+ }
35
+ /** A route template as the segments a server mounts, and the literal query pairs it carries. */
36
+ export function routeTemplateOf(uriTemplate, parameters) {
37
+ const facts = new Map(parameters.map((parameter) => [parameter.name, parameter]));
38
+ const raw = [];
39
+ const literalQuery = [];
40
+ let current;
41
+ let inQuery = false;
42
+ const open = () => {
43
+ current = [];
44
+ raw.push(current);
45
+ return current;
46
+ };
47
+ for (const part of parseUriTemplate(uriTemplate)) {
48
+ if (typeof part === "string") {
49
+ let text = part;
50
+ if (inQuery) {
51
+ literalQuery.push(...pairsOf(text));
52
+ continue;
53
+ }
54
+ if (text.includes("?")) {
55
+ inQuery = true;
56
+ literalQuery.push(...pairsOf(text.slice(text.indexOf("?") + 1)));
57
+ text = text.slice(0, text.indexOf("?"));
58
+ }
59
+ text.split("/").forEach((piece, index) => {
60
+ const target = index === 0 ? (current ?? (piece === "" ? undefined : open())) : open();
61
+ if (piece !== "" && target !== undefined)
62
+ target.push(piece);
63
+ });
64
+ continue;
65
+ }
66
+ // `{?x}` and `{&x}` are query parameters, which a query validator reads; they are not path.
67
+ if (part.operator === "?" || part.operator === "&")
68
+ continue;
69
+ if (part.operator === "/")
70
+ open().push(part);
71
+ else
72
+ (current ?? open()).push(part);
73
+ }
74
+ const segments = raw
75
+ .filter((parts) => parts.length > 0)
76
+ .map((parts) => {
77
+ const expressions = parts.filter((part) => typeof part !== "string");
78
+ const text = (from) => from.filter((part) => typeof part === "string").join("");
79
+ const [only] = expressions;
80
+ if (only === undefined)
81
+ return { kind: "literal", text: text(parts) };
82
+ if (expressions.length > 1) {
83
+ return {
84
+ kind: "unsupported",
85
+ text: parts
86
+ .map((part) => typeof part === "string" ? part : `{${part.operator ?? ""}${part.name}}`)
87
+ .join(""),
88
+ };
89
+ }
90
+ const at = parts.indexOf(only);
91
+ const fact = facts.get(only.name);
92
+ return {
93
+ kind: "expression",
94
+ parameter: only.name,
95
+ prefix: text(parts.slice(0, at)),
96
+ suffix: text(parts.slice(at + 1)),
97
+ operator: only.operator === "?" || only.operator === "&" ? "" : (only.operator ?? ""),
98
+ explode: only.explode,
99
+ optional: fact?.optional ?? false,
100
+ reserved: only.operator === "+" || only.operator === "#" || fact?.reserved === true,
101
+ };
102
+ });
103
+ return { segments, literalQuery };
104
+ }
105
+ /** `a=1&b=2` -> the pairs it spells; a piece with no `=` is a name with an empty value. */
106
+ function pairsOf(text) {
107
+ return text
108
+ .split("&")
109
+ .filter((piece) => piece !== "")
110
+ .map((piece) => {
111
+ const at = piece.indexOf("=");
112
+ return at === -1 ? [piece, ""] : [piece.slice(0, at), piece.slice(at + 1)];
113
+ });
114
+ }
115
+ /**
116
+ * Whether a path value needs decoding at all. **An ordinary whole-segment parameter does not**, and
117
+ * leaving its validator untouched keeps every spec without an RFC 6570 form byte-identical.
118
+ */
119
+ export function needsExpansionDecoding(segment, shape) {
120
+ return (segment.prefix !== "" ||
121
+ segment.suffix !== "" ||
122
+ segment.operator === "." ||
123
+ segment.operator === ";" ||
124
+ shape !== "scalar");
125
+ }
126
+ /**
127
+ * The call that decodes one expansion, as a `z.preprocess` argument. The decoder itself is
128
+ * {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
129
+ */
130
+ export function uriExpansionCall(spec) {
131
+ return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${JSON.stringify(spec.prefix)}, suffix: ${JSON.stringify(spec.suffix)}, operator: ${JSON.stringify(spec.operator)}, name: ${JSON.stringify(spec.name)}, explode: ${spec.explode}, shape: ${JSON.stringify(spec.shape)}, values: ${JSON.stringify(spec.values)} })`;
132
+ }
133
+ /**
134
+ * **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
135
+ * sits in (or, for an exploding `/` expression, every segment after its literal), and this undoes
136
+ * the expansion: the literal around it, the operator's leading character, a matrix parameter's
137
+ * `name=`, then the joiner the operator and explode modifier chose.
138
+ *
139
+ * Text that is not the expansion the route declares is REFUSED with an issue of its own, not passed
140
+ * on: a string parameter's schema would accept `primitive;other=a` as the string it is, so leaving
141
+ * the refusal to the schema admitted a request the route does not match. A list's element values
142
+ * are decoded by the element decoder inside, as a query list's are; a record's values are decoded
143
+ * here, because no schema expression reaches them one by one.
144
+ */
145
+ export const URI_EXPANSION_SOURCE = `
146
+ /** RFC 6570 section 3.2 in reverse: one expression's expansion, decoded into the value it expanded. */
147
+ function uriExpansion(
148
+ raw: unknown,
149
+ ctx: { addIssue(issue: { code: "custom"; message: string }): void },
150
+ spec: {
151
+ readonly prefix: string;
152
+ readonly suffix: string;
153
+ readonly operator: string;
154
+ readonly name: string;
155
+ readonly explode: boolean;
156
+ readonly shape: "scalar" | "list" | "record";
157
+ readonly values: "number" | "boolean" | "";
158
+ },
159
+ ): unknown {
160
+ if (typeof raw !== "string") return raw;
161
+ const refuse = (): unknown => {
162
+ ctx.addIssue({ code: "custom", message: \`Not the expansion of {\${spec.operator}\${spec.name}\${spec.explode ? "*" : ""}} this route declares\` });
163
+ return z.NEVER;
164
+ };
165
+ if (raw.length < spec.prefix.length + spec.suffix.length) return refuse();
166
+ if (!raw.startsWith(spec.prefix) || !raw.endsWith(spec.suffix)) return refuse();
167
+ let text = raw.slice(spec.prefix.length, raw.length - spec.suffix.length);
168
+ if (spec.operator === "." || spec.operator === ";") {
169
+ if (!text.startsWith(spec.operator)) return refuse();
170
+ text = text.slice(1);
171
+ }
172
+ if (spec.operator === ";" && !(spec.shape === "record" && spec.explode)) {
173
+ const named = text
174
+ .split(";")
175
+ .map((part) => (part === spec.name ? "" : part.startsWith(\`\${spec.name}=\`) ? part.slice(spec.name.length + 1) : undefined));
176
+ if (named.some((part) => part === undefined)) return refuse();
177
+ if (spec.shape === "list" && spec.explode) return named;
178
+ if (named.length !== 1) return refuse();
179
+ text = named[0] ?? "";
180
+ }
181
+ if (spec.shape === "scalar") return text;
182
+ const joiner = spec.explode && (spec.operator === "." || spec.operator === "/" || spec.operator === ";") ? spec.operator : ",";
183
+ if (spec.shape === "list") return text === "" ? [] : text.split(joiner);
184
+ const value = (item: string): unknown =>
185
+ spec.values === "number" && item.trim() !== "" && Number.isFinite(Number(item))
186
+ ? Number(item)
187
+ : spec.values === "boolean" && (item === "true" || item === "false")
188
+ ? item === "true"
189
+ : item;
190
+ const entries: [string, unknown][] = [];
191
+ if (spec.explode) {
192
+ for (const pair of text === "" ? [] : text.split(joiner)) {
193
+ const at = pair.indexOf("=");
194
+ if (at === -1) return refuse();
195
+ entries.push([pair.slice(0, at), value(pair.slice(at + 1))]);
196
+ }
197
+ } else {
198
+ const items = text === "" ? [] : text.split(",");
199
+ if (items.length % 2 !== 0) return refuse();
200
+ for (let index = 0; index < items.length; index += 2) {
201
+ entries.push([items[index] ?? "", value(items[index + 1] ?? "")]);
202
+ }
203
+ }
204
+ return Object.fromEntries(entries);
205
+ }
206
+ `;
207
+ /**
208
+ * The call that gathers a form-exploded record or model back under its parameter name. The
209
+ * gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
210
+ */
211
+ export function explodedQueryCall(spec) {
212
+ return `(raw) => explodedQuery(raw, { name: ${JSON.stringify(spec.name)}, others: ${JSON.stringify(spec.others)}, keys: ${JSON.stringify(spec.keys ?? null)}, numbers: ${JSON.stringify(spec.numbers)}, booleans: ${JSON.stringify(spec.booleans)}, values: ${JSON.stringify(spec.values)} })`;
213
+ }
214
+ /**
215
+ * **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
216
+ * `?field=status&value=active`, so there is no `param` key for a query validator to find. This
217
+ * gathers those keys back under the parameter's name before the query object is validated: a model
218
+ * takes exactly its declared properties, a record every key no other query parameter claims.
219
+ */
220
+ export const EXPLODED_QUERY_SOURCE = `
221
+ /** RFC 6570 form expansion of an exploded record or model, gathered back under its parameter. */
222
+ function explodedQuery(
223
+ raw: unknown,
224
+ spec: {
225
+ readonly name: string;
226
+ readonly others: readonly string[];
227
+ readonly keys: readonly string[] | null;
228
+ readonly numbers: readonly string[];
229
+ readonly booleans: readonly string[];
230
+ readonly values: "number" | "boolean" | "";
231
+ },
232
+ ): unknown {
233
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw) || spec.name in raw) return raw;
234
+ const gathered: Record<string, unknown> = {};
235
+ const rest: Record<string, unknown> = {};
236
+ for (const [key, value] of Object.entries(raw)) {
237
+ const own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);
238
+ if (!own) {
239
+ rest[key] = value;
240
+ continue;
241
+ }
242
+ const kind = spec.values !== "" ? spec.values : spec.numbers.includes(key) ? "number" : spec.booleans.includes(key) ? "boolean" : "";
243
+ gathered[key] =
244
+ typeof value !== "string"
245
+ ? value
246
+ : kind === "number" && value.trim() !== "" && Number.isFinite(Number(value))
247
+ ? Number(value)
248
+ : kind === "boolean" && (value === "true" || value === "false")
249
+ ? value === "true"
250
+ : value;
251
+ }
252
+ return Object.keys(gathered).length === 0 ? raw : { ...rest, [spec.name]: gathered };
253
+ }
254
+ `;
package/dist/src/zod.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type Enum, type Model, type ModelProperty, type Program, type Type } from "@typespec/compiler";
2
2
  import { Visibility } from "@typespec/http";
3
+ export declare function withDeferredAnnotator<T>(annotator: (property: ModelProperty) => string | undefined, run: () => T): T;
3
4
  export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
4
5
  export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
5
6
  /** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */