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.
- package/README.md +6 -6
- package/dist/src/api.d.ts +61 -9
- package/dist/src/api.js +767 -308
- package/dist/src/constraints.d.ts +1 -1
- package/dist/src/constraints.js +59 -21
- package/dist/src/lib.d.ts +19 -1
- package/dist/src/lib.js +42 -0
- package/dist/src/literal.d.ts +27 -0
- package/dist/src/literal.js +33 -0
- package/dist/src/registry.js +54 -10
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +135 -0
- package/dist/src/statuses.d.ts +24 -0
- package/dist/src/statuses.js +27 -0
- package/dist/src/types.js +42 -28
- package/dist/src/uri-template.d.ts +1 -13
- package/dist/src/uri-template.js +7 -3
- package/dist/src/zod.d.ts +49 -1
- package/dist/src/zod.js +224 -24
- package/package.json +14 -14
|
@@ -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,
|
|
1
|
+
import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
|
-
import {
|
|
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, `?`
|
|
16
|
-
*
|
|
17
|
-
* out of `required` is optional here whether its
|
|
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 =
|
|
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) =>
|
|
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)}: ${
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
*
|
|
303
|
-
*
|
|
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
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
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
|
-
*
|
|
314
|
-
*
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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
|
|
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)
|
|
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";
|
package/dist/src/uri-template.js
CHANGED
|
@@ -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: ${
|
|
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: ${
|
|
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)
|
|
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;
|