typespec-http-zod 0.27.0 → 0.29.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.
@@ -0,0 +1,135 @@
1
+ import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
2
+ import { literal } from "./literal.js";
3
+ /**
4
+ * **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
5
+ * 8.3.1). Two media types are the same type exactly when their essences are equal, so every
6
+ * comparison of a declared media type is of this. `application/json; charset=utf-8` and
7
+ * `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
8
+ * and the response an unvalidated string.
9
+ */
10
+ export function mediaTypeEssenceOf(type) {
11
+ return (type.split(";")[0] ?? "").trim().toLowerCase();
12
+ }
13
+ /** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
14
+ export function isJsonMediaType(type) {
15
+ return /^application\/(json|.*\+json)$/.test(mediaTypeEssenceOf(type));
16
+ }
17
+ /**
18
+ * **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
19
+ * section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
20
+ *
21
+ * **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
22
+ * has no reader or serialisation this emitter can derive, so it is handed over as the text that
23
+ * arrived and answered as the text the handler writes. Under any other type, such as
24
+ * `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
25
+ * every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
26
+ * well, and is parsed rather than handed over, so it never reaches this question.
27
+ */
28
+ export function isTextualMediaType(type) {
29
+ const essence = mediaTypeEssenceOf(type);
30
+ return essence.startsWith("text/") || essence === "application/xml" || essence.endsWith("+xml");
31
+ }
32
+ /** A media range such as `image/*`, rather than a type a response can be sent as. */
33
+ export function isMediaRange(mediaType) {
34
+ return mediaTypeEssenceOf(mediaType).endsWith("/*");
35
+ }
36
+ /**
37
+ * The type of the `contentType` a result names for a member.
38
+ *
39
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
40
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
41
+ * full range is `string`.
42
+ */
43
+ export function mediaTypeTypeOf(mediaType) {
44
+ if (!isMediaRange(mediaType))
45
+ return literal(mediaType);
46
+ if (mediaType === "*/*")
47
+ return "string";
48
+ return `\`${mediaType.slice(0, -1)}\${string}\``;
49
+ }
50
+ /**
51
+ * Every member an operation's responses declare, in the order the responses are listed.
52
+ *
53
+ * `hasBody` says which responses carry a body, because the two kinds of record spell that
54
+ * differently - an emitted one has a schema, a resolved one has a type.
55
+ */
56
+ export function resultMembersOf(responses, hasBody) {
57
+ return responses.flatMap((response, index) => {
58
+ if (response.contentTypes.length === 0) {
59
+ return [
60
+ {
61
+ response,
62
+ index,
63
+ mediaType: undefined,
64
+ choosesMediaType: false,
65
+ serving: "none",
66
+ unvalidated: false,
67
+ },
68
+ ];
69
+ }
70
+ const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
71
+ return response.contentTypes.map((mediaType) => {
72
+ const serving = servingOf(response, mediaType, hasBody(response));
73
+ return {
74
+ response,
75
+ index,
76
+ mediaType,
77
+ choosesMediaType,
78
+ serving,
79
+ unvalidated: serving === "unvalidated" || (serving === "binary" && !response.binary),
80
+ };
81
+ });
82
+ });
83
+ }
84
+ function servingOf(response, mediaType, bodied) {
85
+ if (!bodied)
86
+ return "none";
87
+ if (response.streamed)
88
+ return "stream";
89
+ if (response.binary)
90
+ return "binary";
91
+ if (isJsonMediaType(mediaType))
92
+ return "json";
93
+ /**
94
+ * **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
95
+ * Any other body under one - a model as `application/xml` - has no serialisation this emitter can
96
+ * derive, so it has to come from the application and is served unvalidated: as the text it writes
97
+ * under a textual media type, and as bytes under any other (see {@link isTextualMediaType}).
98
+ */
99
+ if (response.textual)
100
+ return "text";
101
+ return isTextualMediaType(mediaType) ? "unvalidated" : "binary";
102
+ }
103
+ /**
104
+ * The codes a declared response answers with, written out.
105
+ *
106
+ * - An exact code is itself.
107
+ * - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
108
+ * before its range, as a server does - and, where it carries a body, less the codes that cannot.
109
+ * - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
110
+ * again less the contentless codes where it carries a body. `1xx` is not a result a handler
111
+ * returns.
112
+ *
113
+ * **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
114
+ * narrow a result on a status type that admits everything. Empty where nothing is left, which a
115
+ * caller renders as `never`.
116
+ */
117
+ export function statusCodesOf(response, responses, hasBody) {
118
+ const { status } = response;
119
+ if (typeof status === "number")
120
+ return [status];
121
+ const exact = new Set(responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
122
+ const contentless = new Set(hasBody(response) ? CONTENTLESS_STATUS_CODES : []);
123
+ const classOf = (key) => STATUS_CODES[Number(key.charAt(0))];
124
+ const candidates = status === "default"
125
+ ? [2, 3, 4, 5].flatMap((digit) => STATUS_CODES[digit])
126
+ : classOf(status);
127
+ const ranged = new Set(status === "default"
128
+ ? responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
129
+ ? classOf(candidate.status)
130
+ : [])
131
+ : []);
132
+ return candidates
133
+ .filter((code) => !exact.has(code) && !ranged.has(code) && !contentless.has(code))
134
+ .toSorted((a, b) => a - b);
135
+ }
@@ -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];
@@ -0,0 +1,27 @@
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 const STATUS_CODES = {
17
+ 1: [100, 101, 102, 103],
18
+ 2: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226],
19
+ 3: [300, 301, 302, 303, 304, 305, 306, 307, 308],
20
+ 4: [
21
+ 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
22
+ 421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
23
+ ],
24
+ 5: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
25
+ };
26
+ /** The codes a response cannot carry a body under, per RFC 9110: 101, 204, 205 and 304. */
27
+ export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
package/dist/src/types.js CHANGED
@@ -1,6 +1,7 @@
1
- import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
1
+ import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
2
2
  import { reportDiagnostic } from "./lib.js";
3
- import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
3
+ import { literal } from "./literal.js";
4
+ import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
4
5
  /**
5
6
  * TypeSpec type -> plain TypeScript type text.
6
7
  *
@@ -12,9 +13,10 @@ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isO
12
13
  * importing the other.
13
14
  *
14
15
  * **It must agree with `zod.ts` exactly**, because the emitted assertion compares them. Every rule
15
- * here has a counterpart there: `utcDateTime` is a string on both sides, `?` becomes
16
- * `?: T | undefined` because `exactOptionalPropertyTypes` is on, and a property the document leaves
17
- * out of `required` is optional here whether its absence is spelled `?` or covered by a default.
16
+ * here has a counterpart there: `utcDateTime` is a string on both sides, `?` is `?: T` because
17
+ * `.exactOptional()` refuses an explicit `undefined` and `?: T | undefined` only where `.default()`
18
+ * accepts one, and a property the document leaves out of `required` is optional here whether its
19
+ * absence is spelled `?` or covered by a default.
18
20
  *
19
21
  * **The assertion pairs this walk against `z.input`, the type the validator ACCEPTS**, and not
20
22
  * against `z.infer`, which is what it produces. These types are the floor a producer supplies, and
@@ -72,7 +74,7 @@ function refuse(program, type, why, remedy = "") {
72
74
  }
73
75
  function scalarToTs(program, scalar) {
74
76
  // A scalar declaration can carry the encoding itself - see `scalarToZod`.
75
- const encoded = getEncode(program, scalar)?.type;
77
+ const encoded = encodedTypeOf(program, scalar);
76
78
  if (encoded !== undefined && encoded !== scalar)
77
79
  return scalarToTs(program, encoded);
78
80
  let current = scalar;
@@ -101,7 +103,7 @@ function scalarToTs(program, scalar) {
101
103
  }
102
104
  function enumToTs(target) {
103
105
  const values = [...target.members.values()].map((member) => member.value ?? member.name);
104
- return values.map((value) => JSON.stringify(value)).join(" | ");
106
+ return values.map((value) => literal(value)).join(" | ");
105
107
  }
106
108
  function unionToTs(program, union) {
107
109
  /**
@@ -114,7 +116,7 @@ function unionToTs(program, union) {
114
116
  if (discriminated !== undefined && discriminated.defaultVariant === undefined) {
115
117
  const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
116
118
  const arms = [...discriminated.variants.entries()].map(([name, variant]) => {
117
- const tag = `${objectKey(discriminatorPropertyName)}: ${JSON.stringify(name)};`;
119
+ const tag = `${objectKey(discriminatorPropertyName)}: ${literal(name)};`;
118
120
  const inner = typeToTs(program, variant);
119
121
  // No injection: with no envelope the variant declares the discriminator itself, or the union
120
122
  // is refused in `zod.ts`. Nothing is added that the document does not publish.
@@ -261,9 +263,14 @@ function modelToTs(program, model) {
261
263
  * annotation claims is by construction the type this walk renders for the same property.
262
264
  */
263
265
  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;
266
+ /**
267
+ * **`@encode` changes the wire TYPE, and `encodedTypeOf` is the one rule for when it does.** This
268
+ * read `getEncode(...).type` directly, so it missed the exception the Zod walk makes: an array
269
+ * encoding on a QUERY parameter is a style of flattening, not a type, and the validator splits it
270
+ * back into the declared list. So `WireInputs` said `colors: string` for a pipe-delimited query
271
+ * whose handler is given `string[]` - two walks of one property, one of them knowing a rule.
272
+ */
273
+ const encoded = encodedTypeOf(program, property);
267
274
  return typeToTs(program, encoded ?? property.type);
268
275
  }
269
276
  export function propertyToTs(program, property, wireName) {
@@ -297,21 +304,28 @@ export function propertyToTs(program, property, wireName) {
297
304
  * `.default(...)` for it - so the two walks agree without either one restating the rule.
298
305
  */
299
306
  /**
300
- * **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
307
+ * **`?: T`, and `?: T | undefined` only where the validator emits `.default(...)`. The contract
308
+ * type is the validator's INPUT, `z.input`, at every property.**
309
+ *
310
+ * `.exactOptional()` refuses `{ note: undefined }` and `.default(v)` accepts it, so those are the
311
+ * two answers; {@link emitsDefault} says which one this property got. `wire-contract.gen.ts`
312
+ * compares the two walks with nothing widened, so a disagreement here does not compile.
301
313
  *
302
- * It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
303
- * it would make this walk agree with `z.infer<...>` on the schemas side. **Measured, it
304
- * breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
305
- * what you were given - stopped compiling, because what a handler RECEIVES carries
306
- * `?: T | undefined` and what it would then have to SUPPLY did not.
314
+ * **This was `?: T | undefined` for every optional, kept deliberately on three reasons that were
315
+ * each measured false** once `0.24.0` moved the validator to `.exactOptional()`:
307
316
  *
308
- * That is the difference from the index signature and from `readonly`, which look like the same
309
- * class and are not: removing those two makes MORE values assignable, and removing this one makes
310
- * fewer. `{ retiredOn: undefined }` serialises to `{}`, identical to omitting the key, so
311
- * permitting it costs the wire nothing and refusing it costs every producer a conditional spread.
317
+ * - the echo handler `(ctx, input) => ok(input)` broke without it, because what a handler
318
+ * received carried `| undefined`. What it receives is `z.infer` of `.exactOptional()` now,
319
+ * `?: T`, and the echo compiles;
320
+ * - `{ note: undefined }` serialises identically to omitting the key. A server checks a body
321
+ * before it serialises it: a value typed by `WireOutputs` reached `servedBody` and answered
322
+ * 500 for a body whose bytes were valid, and across a service binding it is never serialised;
323
+ * - refusing it costs a producer a conditional spread. That spread infers `{ note?: string }` on
324
+ * TypeScript 5.9 and 7.0 and assigns to the exact type, and it is what a producer under
325
+ * `exactOptionalPropertyTypes` writes for the handler's own return type anyway.
312
326
  *
313
- * The two emitted surfaces differ here on purpose, exactly as they do on openness:
314
- * `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
327
+ * Two consumers reported the disagreement independently - one carried a `Loosen<T>` to compare the
328
+ * two surfaces - and openapi-typescript, `hc` and `z.infer` all spell an optional `?: T`.
315
329
  */
316
330
  /**
317
331
  * **Optional AT THIS POSITION, not merely optional in the declaration.** A PATCH body's properties
@@ -319,9 +333,9 @@ export function propertyToTs(program, property, wireName) {
319
333
  * what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
320
334
  * update contract demanded every field while the validator beside it accepted any subset.
321
335
  */
322
- return property.optional || isOptionalAt(program, property)
323
- ? `${key}?: ${value} | undefined;`
324
- : `${key}: ${value};`;
336
+ if (!(property.optional || isOptionalAt(program, property)))
337
+ return `${key}: ${value};`;
338
+ return emitsDefault(program, property) ? `${key}?: ${value} | undefined;` : `${key}?: ${value};`;
325
339
  }
326
340
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
327
341
  let resolveRef = () => undefined;
@@ -356,13 +370,13 @@ export function typeToTsBody(program, type) {
356
370
  case "Union":
357
371
  return unionToTs(program, type);
358
372
  case "String":
359
- return JSON.stringify(type.value);
373
+ return literal(type.value);
360
374
  case "Number":
361
375
  return String(type.value);
362
376
  case "Boolean":
363
377
  return String(type.value);
364
378
  case "EnumMember":
365
- return JSON.stringify(type.value ?? type.name);
379
+ return literal(type.value ?? type.name);
366
380
  // A named union variant is a referable type - `DogKind.Golden`. See the note in `zod.ts`.
367
381
  case "UnionVariant":
368
382
  return typeToTs(program, type.type);
@@ -1,15 +1,3 @@
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
1
  /**
14
2
  * One `/`-delimited segment of a route, as a server has to mount it.
15
3
  *
@@ -103,4 +91,4 @@ export declare function explodedQueryCall(spec: {
103
91
  * gathers those keys back under the parameter's name before the query object is validated: a model
104
92
  * takes exactly its declared properties, a record every key no other query parameter claims.
105
93
  */
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";
94
+ 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)) 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\t// Already gathered only where an OBJECT sits under the name: a query value from the wire is a\n\t\t// string or a list of them, and `{ param: 0 }` expands to `?param=0`.\n\t\tif (key === spec.name && value !== null && typeof value === \"object\" && !Array.isArray(value)) return 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";
@@ -1,3 +1,4 @@
1
+ import { literal } from "./literal.js";
1
2
  /**
2
3
  * RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
3
4
  *
@@ -128,7 +129,7 @@ export function needsExpansionDecoding(segment, shape) {
128
129
  * {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
129
130
  */
130
131
  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
+ return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${literal(spec.prefix)}, suffix: ${literal(spec.suffix)}, operator: ${literal(spec.operator)}, name: ${literal(spec.name)}, explode: ${spec.explode}, shape: ${literal(spec.shape)}, values: ${literal(spec.values)} })`;
132
133
  }
133
134
  /**
134
135
  * **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
@@ -209,7 +210,7 @@ function uriExpansion(
209
210
  * gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
210
211
  */
211
212
  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
+ return `(raw) => explodedQuery(raw, { name: ${literal(spec.name)}, others: ${literal(spec.others)}, keys: ${literal(spec.keys ?? null)}, numbers: ${literal(spec.numbers)}, booleans: ${literal(spec.booleans)}, values: ${literal(spec.values)} })`;
213
214
  }
214
215
  /**
215
216
  * **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
@@ -230,10 +231,13 @@ function explodedQuery(
230
231
  readonly values: "number" | "boolean" | "";
231
232
  },
232
233
  ): unknown {
233
- if (raw === null || typeof raw !== "object" || Array.isArray(raw) || spec.name in raw) return raw;
234
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
234
235
  const gathered: Record<string, unknown> = {};
235
236
  const rest: Record<string, unknown> = {};
236
237
  for (const [key, value] of Object.entries(raw)) {
238
+ // Already gathered only where an OBJECT sits under the name: a query value from the wire is a
239
+ // string or a list of them, and \`{ param: 0 }\` expands to \`?param=0\`.
240
+ if (key === spec.name && value !== null && typeof value === "object" && !Array.isArray(value)) return raw;
237
241
  const own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);
238
242
  if (!own) {
239
243
  rest[key] = value;
package/dist/src/zod.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Enum, type Model, type ModelProperty, type Program, type Type } from "@typespec/compiler";
1
+ import { type Enum, type Model, type ModelProperty, type Program, type Scalar, type Type } from "@typespec/compiler";
2
2
  import { Visibility } from "@typespec/http";
3
3
  /**
4
4
  * What a walk returns after reporting a refusal.
@@ -75,6 +75,40 @@ export declare function withDeclarationSite<T>(site: Type | undefined, run: () =
75
75
  * walked, which does.
76
76
  */
77
77
  export declare function refusalTarget(type: Type): Type;
78
+ /**
79
+ * **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
80
+ *
81
+ * `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
82
+ * into the validators: a surface publishing its input schema FROM them - an MCP server's
83
+ * `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
84
+ * for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
85
+ * the client checking that published schema before it called.
86
+ *
87
+ * `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
88
+ * every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
89
+ * it by default. It returns a clone and changes nothing a parse decides.
90
+ *
91
+ * **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
92
+ * `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
93
+ * `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
94
+ * applied where a declaration's body, a property's value or a scalar's schema is finished, and
95
+ * nowhere else.
96
+ *
97
+ * Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
98
+ * `@doc("")` is published as `""`, and so is it here.
99
+ */
100
+ export declare function described(expression: string, doc: string | undefined): string;
101
+ /**
102
+ * The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
103
+ * chain, and never a standard type's.
104
+ *
105
+ * Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
106
+ * schema, description included, and overwrites what it declares itself - so `scalar B extends A`
107
+ * with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
108
+ * going to be inlined in the spec and we don't want the description of the scalar to show up".
109
+ * Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
110
+ */
111
+ export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
78
112
  export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
79
113
  /**
80
114
  * Every property the model declares, **including the ones it inherits**.
@@ -143,7 +177,21 @@ export declare function objectKey(name: string): string;
143
177
  * comment indented differently from the member it documents is the tell that a generator wrote it.
144
178
  */
145
179
  export declare function jsDocComment(text: string | undefined, indent: string): string;
180
+ export declare function encodedTypeOf(program: Program, target: ModelProperty | Scalar): Scalar | undefined;
146
181
  export declare function propertyToZod(program: Program, property: ModelProperty): string;
182
+ /**
183
+ * **Whether `propertyToZod` emits `.default(...)` for this property, which is exactly whether its
184
+ * INPUT admits an explicit `undefined`.**
185
+ *
186
+ * `.exactOptional()` refuses `{ p: undefined }`; `.default(v)` accepts it and fills `v`. The contract
187
+ * types in `requests.gen.ts` state the input, so they have to know which of the two this emitted -
188
+ * and asking this function rather than restating its rule is what keeps the two walks agreeing.
189
+ * A default on a REQUIRED property emits neither (`default-on-required-property`), and one whose
190
+ * value cannot be rendered emits no `.default()` (`unsupported-default`); both fall out here.
191
+ *
192
+ * Pure: it reports nothing, so the type walk can ask it without duplicating a diagnostic.
193
+ */
194
+ export declare function emitsDefault(program: Program, property: ModelProperty): boolean;
147
195
  export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
148
196
  /** Called by the registry when it hands out the identifier of a declaration that is still open. */
149
197
  export declare function noteBackEdge(): void;