typespec-http-zod 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/src/api.d.ts +42 -9
- package/dist/src/api.js +619 -295
- package/dist/src/constraints.d.ts +1 -1
- package/dist/src/constraints.js +14 -10
- package/dist/src/registry.js +26 -5
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +134 -0
- package/dist/src/statuses.d.ts +24 -0
- package/dist/src/statuses.js +27 -0
- package/dist/src/types.js +37 -24
- package/dist/src/uri-template.d.ts +1 -1
- package/dist/src/uri-template.js +4 -1
- package/dist/src/zod.d.ts +15 -1
- package/dist/src/zod.js +19 -1
- package/package.json +14 -14
|
@@ -30,7 +30,7 @@ export declare function applyConstraints(program: Program, expression: string, t
|
|
|
30
30
|
*
|
|
31
31
|
* **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
|
|
32
32
|
* `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
|
|
33
|
-
* the
|
|
33
|
+
* the step budget is judged the same way as one it decides against. Measured over a realistic spread of
|
|
34
34
|
* 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
|
|
35
35
|
* overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
|
|
36
36
|
* ordinary and takes **11 seconds** on a 29-character input.
|
package/dist/src/constraints.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
2
|
-
import { isSafePattern } from "redos-detector";
|
|
2
|
+
import { defaultMaxSteps, isSafePattern } from "redos-detector";
|
|
3
3
|
import { reportDiagnostic } from "./lib.js";
|
|
4
4
|
/**
|
|
5
5
|
* Constraint decorators -> the Zod modifiers that enforce them.
|
|
@@ -87,18 +87,21 @@ function encodesAsString(program, target) {
|
|
|
87
87
|
return (own ?? scalar)?.type.name === "string";
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
|
-
* How
|
|
90
|
+
* How much analysis one pattern gets before the answer is "could not prove it": a number of steps.
|
|
91
91
|
*
|
|
92
|
-
* **Bounded, because the analysis is itself exponential in the bad case
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* **Bounded, because the analysis is itself exponential in the bad case, and bounded in STEPS,
|
|
93
|
+
* because a verdict must not depend on the machine.** It was 500 ms of wall-clock time, and on a
|
|
94
|
+
* loaded CI host `^([0-9a-f]{40}|[0-9a-f]{64})$` took 93 to 403 ms and once ran out: the same spec
|
|
95
|
+
* warned on one build and not the next. A step count is the same everywhere. `redos-detector`'s own
|
|
96
|
+
* default; measured, `^(a|a)+$` spends it in 1.7 seconds idle and 6 under load, and every ordinary
|
|
97
|
+
* pattern decides in milliseconds. A pattern that cannot be decided inside it is one this emitter
|
|
98
|
+
* cannot certify, which is what the warning says.
|
|
96
99
|
*/
|
|
97
|
-
const
|
|
100
|
+
const ANALYSIS_STEPS = defaultMaxSteps;
|
|
98
101
|
/**
|
|
99
102
|
* Patterns already judged, keyed on the text, which is the only input the verdict depends on.
|
|
100
103
|
*
|
|
101
|
-
* The analysis costs up to {@link
|
|
104
|
+
* The analysis costs up to {@link ANALYSIS_STEPS} steps a pattern, and one pattern text commonly
|
|
102
105
|
* recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
|
|
103
106
|
* consumer spec - so each distinct text is analysed once per process.
|
|
104
107
|
*/
|
|
@@ -162,7 +165,7 @@ function firstReportOf(program, code, target) {
|
|
|
162
165
|
*
|
|
163
166
|
* **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
|
|
164
167
|
* `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
|
|
165
|
-
* the
|
|
168
|
+
* the step budget is judged the same way as one it decides against. Measured over a realistic spread of
|
|
166
169
|
* 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
|
|
167
170
|
* overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
|
|
168
171
|
* ordinary and takes **11 seconds** on a 29-character input.
|
|
@@ -173,7 +176,8 @@ export function patternIsProvablySafe(pattern) {
|
|
|
173
176
|
try {
|
|
174
177
|
safe = isSafePattern(pattern, {
|
|
175
178
|
unicode: unicodeSafe(pattern),
|
|
176
|
-
|
|
179
|
+
maxSteps: ANALYSIS_STEPS,
|
|
180
|
+
timeout: Number.POSITIVE_INFINITY,
|
|
177
181
|
}).safe;
|
|
178
182
|
}
|
|
179
183
|
catch {
|
package/dist/src/registry.js
CHANGED
|
@@ -134,16 +134,37 @@ const RESERVED_DECLARATION_NAMES = new Set([
|
|
|
134
134
|
"with",
|
|
135
135
|
"yield",
|
|
136
136
|
]);
|
|
137
|
+
/**
|
|
138
|
+
* **The names the emitted files declare for THEMSELVES, beside the spec's own declarations.**
|
|
139
|
+
*
|
|
140
|
+
* `requests.gen.ts` declares `Produced`, `Simplify`, `ByteStream` and its three lookups; `schemas.gen.ts`
|
|
141
|
+
* declares `ResponseArm`. A model named after one of them was declared twice: `TS2300` for a type
|
|
142
|
+
* alias, and for `WireInputs` or `WireOutputs` something worse, because two interfaces of one name
|
|
143
|
+
* MERGE - the model's properties silently became members of the lookup. So a spec's model of that
|
|
144
|
+
* name is renamed exactly as a reserved word is.
|
|
145
|
+
*
|
|
146
|
+
* Graded by `test/collisions/`, which reads the names an emitted file declares off the OUTPUT rather
|
|
147
|
+
* than off this set, so a helper added later without a line here fails a test.
|
|
148
|
+
*/
|
|
149
|
+
const EMITTED_HELPER_NAMES = new Set([
|
|
150
|
+
"ByteStream",
|
|
151
|
+
"Produced",
|
|
152
|
+
"ResponseArm",
|
|
153
|
+
"Simplify",
|
|
154
|
+
"WireInputs",
|
|
155
|
+
"WireOutputs",
|
|
156
|
+
"WireResults",
|
|
157
|
+
]);
|
|
137
158
|
/**
|
|
138
159
|
* The name a declaration is emitted under.
|
|
139
160
|
*
|
|
140
|
-
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration
|
|
141
|
-
* applied in ONE place so both walks agree:
|
|
142
|
-
* contract type by name, and a mangling applied to
|
|
143
|
-
* than the file.
|
|
161
|
+
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration, or is one
|
|
162
|
+
* the emitted file already declares for itself. It is applied in ONE place so both walks agree:
|
|
163
|
+
* `wire-contract.gen.ts` pairs a validator against a contract type by name, and a mangling applied to
|
|
164
|
+
* one side only would break that assertion rather than the file.
|
|
144
165
|
*/
|
|
145
166
|
function safeDeclarationName(name) {
|
|
146
|
-
return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
|
|
167
|
+
return RESERVED_DECLARATION_NAMES.has(name) || EMITTED_HELPER_NAMES.has(name) ? `${name}_` : name;
|
|
147
168
|
}
|
|
148
169
|
/** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
|
|
149
170
|
function declaredNameOf(program, type) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **What a result is made of: one member per declared response and media type, and the codes each
|
|
3
|
+
* one answers with.** One derivation, read by every artefact that states a result.
|
|
4
|
+
*
|
|
5
|
+
* `typespec-hono` types a handler's return as a union of these members, `typespec-http-mcp` types a
|
|
6
|
+
* tool's the same way, and `requests.gen.ts` states the same union for a backend that has no
|
|
7
|
+
* framework to import. All three used to hold their own copy of these rules, and three copies of
|
|
8
|
+
* rules that must agree are three that will not: a status one of them could see and another could
|
|
9
|
+
* not is how `WireOutputs` came to publish one success body for an operation answering two.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A status as the DOCUMENT keys it: an exact code, a `4XX` bucket, or the catch-all.
|
|
13
|
+
*
|
|
14
|
+
* `"default"` is kept as itself rather than mapped to a number. The document means "any status not
|
|
15
|
+
* listed above", and inventing one would be answering a question the contract left open.
|
|
16
|
+
*/
|
|
17
|
+
export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
18
|
+
/**
|
|
19
|
+
* The facts about one declared response that decide how its result is shaped.
|
|
20
|
+
*
|
|
21
|
+
* Structural, so both an emitted response (`EmittedResponse`, whose body is a Zod expression) and a
|
|
22
|
+
* resolved one (whose body is a TypeSpec type) satisfy it; the caller says which ones carry a body.
|
|
23
|
+
*/
|
|
24
|
+
export interface ResponseFacts {
|
|
25
|
+
readonly status: StatusKey;
|
|
26
|
+
readonly contentTypes: readonly string[];
|
|
27
|
+
readonly binary: boolean;
|
|
28
|
+
readonly streamed: boolean;
|
|
29
|
+
readonly textual: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** How a member's body is served, which decides both its type and the call that serves it. */
|
|
32
|
+
export type BodyServing = "none" | "stream" | "binary" | "json" | "text" | "unvalidated";
|
|
33
|
+
/**
|
|
34
|
+
* One member of an operation's result: one response, served as one media type.
|
|
35
|
+
*
|
|
36
|
+
* **A status offering several media types is several members**, because the body differs by media
|
|
37
|
+
* type: the Swagger Petstore answers `getPetById` with a `Pet` as `application/json` and with text as
|
|
38
|
+
* `application/xml`. One member with a union body could not say which body goes with which type.
|
|
39
|
+
*/
|
|
40
|
+
export interface ResultMember<R extends ResponseFacts> {
|
|
41
|
+
readonly response: R;
|
|
42
|
+
/** The response's position in the list it came from, so a caller can find what it names it by. */
|
|
43
|
+
readonly index: number;
|
|
44
|
+
/** The media type served, or `undefined` where the response has no body. */
|
|
45
|
+
readonly mediaType: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the result names the media type: the status offers more than one, or the document names
|
|
48
|
+
* a range such as `image/*`, which is not a type a response can be sent as.
|
|
49
|
+
*/
|
|
50
|
+
readonly choosesMediaType: boolean;
|
|
51
|
+
readonly serving: BodyServing;
|
|
52
|
+
/**
|
|
53
|
+
* **The document declares a schema for this body that nothing checks it against**: a model under a
|
|
54
|
+
* media type that is not JSON, which the handler supplies as text (`"unvalidated"`) or, under a
|
|
55
|
+
* type that is not text, as bytes (`"binary"`). A `bytes` or `Http.File` body declares nothing to
|
|
56
|
+
* check, so it is not this.
|
|
57
|
+
*/
|
|
58
|
+
readonly unvalidated: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
62
|
+
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
63
|
+
* comparison of a declared media type is of this. `application/json; charset=utf-8` and
|
|
64
|
+
* `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
|
|
65
|
+
* and the response an unvalidated string.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mediaTypeEssenceOf(type: string): string;
|
|
68
|
+
/** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
|
|
69
|
+
export declare function isJsonMediaType(type: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
|
|
72
|
+
* section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
|
|
73
|
+
*
|
|
74
|
+
* **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
|
|
75
|
+
* has no reader or serialisation this emitter can derive, so it is handed over as the text that
|
|
76
|
+
* arrived and answered as the text the handler writes. Under any other type, such as
|
|
77
|
+
* `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
|
|
78
|
+
* every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
|
|
79
|
+
* well, and is parsed rather than handed over, so it never reaches this question.
|
|
80
|
+
*/
|
|
81
|
+
export declare function isTextualMediaType(type: string): boolean;
|
|
82
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
83
|
+
export declare function isMediaRange(mediaType: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The type of the `contentType` a result names for a member.
|
|
86
|
+
*
|
|
87
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
88
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
89
|
+
* full range is `string`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function mediaTypeTypeOf(mediaType: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* Every member an operation's responses declare, in the order the responses are listed.
|
|
94
|
+
*
|
|
95
|
+
* `hasBody` says which responses carry a body, because the two kinds of record spell that
|
|
96
|
+
* differently - an emitted one has a schema, a resolved one has a type.
|
|
97
|
+
*/
|
|
98
|
+
export declare function resultMembersOf<R extends ResponseFacts>(responses: readonly R[], hasBody: (response: R) => boolean): ResultMember<R>[];
|
|
99
|
+
/**
|
|
100
|
+
* The codes a declared response answers with, written out.
|
|
101
|
+
*
|
|
102
|
+
* - An exact code is itself.
|
|
103
|
+
* - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
|
|
104
|
+
* before its range, as a server does - and, where it carries a body, less the codes that cannot.
|
|
105
|
+
* - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
|
|
106
|
+
* again less the contentless codes where it carries a body. `1xx` is not a result a handler
|
|
107
|
+
* returns.
|
|
108
|
+
*
|
|
109
|
+
* **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
|
|
110
|
+
* narrow a result on a status type that admits everything. Empty where nothing is left, which a
|
|
111
|
+
* caller renders as `never`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function statusCodesOf<R extends ResponseFacts>(response: R, responses: readonly R[], hasBody: (response: R) => boolean): readonly number[];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
2
|
+
/**
|
|
3
|
+
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
4
|
+
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
5
|
+
* comparison of a declared media type is of this. `application/json; charset=utf-8` and
|
|
6
|
+
* `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
|
|
7
|
+
* and the response an unvalidated string.
|
|
8
|
+
*/
|
|
9
|
+
export function mediaTypeEssenceOf(type) {
|
|
10
|
+
return (type.split(";")[0] ?? "").trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
/** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
|
|
13
|
+
export function isJsonMediaType(type) {
|
|
14
|
+
return /^application\/(json|.*\+json)$/.test(mediaTypeEssenceOf(type));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
|
|
18
|
+
* section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
|
|
19
|
+
*
|
|
20
|
+
* **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
|
|
21
|
+
* has no reader or serialisation this emitter can derive, so it is handed over as the text that
|
|
22
|
+
* arrived and answered as the text the handler writes. Under any other type, such as
|
|
23
|
+
* `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
|
|
24
|
+
* every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
|
|
25
|
+
* well, and is parsed rather than handed over, so it never reaches this question.
|
|
26
|
+
*/
|
|
27
|
+
export function isTextualMediaType(type) {
|
|
28
|
+
const essence = mediaTypeEssenceOf(type);
|
|
29
|
+
return essence.startsWith("text/") || essence === "application/xml" || essence.endsWith("+xml");
|
|
30
|
+
}
|
|
31
|
+
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
32
|
+
export function isMediaRange(mediaType) {
|
|
33
|
+
return mediaTypeEssenceOf(mediaType).endsWith("/*");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The type of the `contentType` a result names for a member.
|
|
37
|
+
*
|
|
38
|
+
* **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
|
|
39
|
+
* under it does not compile, and TypeScript still narrows a literal member away from it. Only the
|
|
40
|
+
* full range is `string`.
|
|
41
|
+
*/
|
|
42
|
+
export function mediaTypeTypeOf(mediaType) {
|
|
43
|
+
if (!isMediaRange(mediaType))
|
|
44
|
+
return JSON.stringify(mediaType);
|
|
45
|
+
if (mediaType === "*/*")
|
|
46
|
+
return "string";
|
|
47
|
+
return `\`${mediaType.slice(0, -1)}\${string}\``;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every member an operation's responses declare, in the order the responses are listed.
|
|
51
|
+
*
|
|
52
|
+
* `hasBody` says which responses carry a body, because the two kinds of record spell that
|
|
53
|
+
* differently - an emitted one has a schema, a resolved one has a type.
|
|
54
|
+
*/
|
|
55
|
+
export function resultMembersOf(responses, hasBody) {
|
|
56
|
+
return responses.flatMap((response, index) => {
|
|
57
|
+
if (response.contentTypes.length === 0) {
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
response,
|
|
61
|
+
index,
|
|
62
|
+
mediaType: undefined,
|
|
63
|
+
choosesMediaType: false,
|
|
64
|
+
serving: "none",
|
|
65
|
+
unvalidated: false,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
|
|
70
|
+
return response.contentTypes.map((mediaType) => {
|
|
71
|
+
const serving = servingOf(response, mediaType, hasBody(response));
|
|
72
|
+
return {
|
|
73
|
+
response,
|
|
74
|
+
index,
|
|
75
|
+
mediaType,
|
|
76
|
+
choosesMediaType,
|
|
77
|
+
serving,
|
|
78
|
+
unvalidated: serving === "unvalidated" || (serving === "binary" && !response.binary),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function servingOf(response, mediaType, bodied) {
|
|
84
|
+
if (!bodied)
|
|
85
|
+
return "none";
|
|
86
|
+
if (response.streamed)
|
|
87
|
+
return "stream";
|
|
88
|
+
if (response.binary)
|
|
89
|
+
return "binary";
|
|
90
|
+
if (isJsonMediaType(mediaType))
|
|
91
|
+
return "json";
|
|
92
|
+
/**
|
|
93
|
+
* **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
|
|
94
|
+
* Any other body under one - a model as `application/xml` - has no serialisation this emitter can
|
|
95
|
+
* derive, so it has to come from the application and is served unvalidated: as the text it writes
|
|
96
|
+
* under a textual media type, and as bytes under any other (see {@link isTextualMediaType}).
|
|
97
|
+
*/
|
|
98
|
+
if (response.textual)
|
|
99
|
+
return "text";
|
|
100
|
+
return isTextualMediaType(mediaType) ? "unvalidated" : "binary";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The codes a declared response answers with, written out.
|
|
104
|
+
*
|
|
105
|
+
* - An exact code is itself.
|
|
106
|
+
* - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
|
|
107
|
+
* before its range, as a server does - and, where it carries a body, less the codes that cannot.
|
|
108
|
+
* - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
|
|
109
|
+
* again less the contentless codes where it carries a body. `1xx` is not a result a handler
|
|
110
|
+
* returns.
|
|
111
|
+
*
|
|
112
|
+
* **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
|
|
113
|
+
* narrow a result on a status type that admits everything. Empty where nothing is left, which a
|
|
114
|
+
* caller renders as `never`.
|
|
115
|
+
*/
|
|
116
|
+
export function statusCodesOf(response, responses, hasBody) {
|
|
117
|
+
const { status } = response;
|
|
118
|
+
if (typeof status === "number")
|
|
119
|
+
return [status];
|
|
120
|
+
const exact = new Set(responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
|
|
121
|
+
const contentless = new Set(hasBody(response) ? CONTENTLESS_STATUS_CODES : []);
|
|
122
|
+
const classOf = (key) => STATUS_CODES[Number(key.charAt(0))];
|
|
123
|
+
const candidates = status === "default"
|
|
124
|
+
? [2, 3, 4, 5].flatMap((digit) => STATUS_CODES[digit])
|
|
125
|
+
: classOf(status);
|
|
126
|
+
const ranged = new Set(status === "default"
|
|
127
|
+
? responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
|
|
128
|
+
? classOf(candidate.status)
|
|
129
|
+
: [])
|
|
130
|
+
: []);
|
|
131
|
+
return candidates
|
|
132
|
+
.filter((code) => !exact.has(code) && !ranged.has(code) && !contentless.has(code))
|
|
133
|
+
.toSorted((a, b) => a - b);
|
|
134
|
+
}
|
|
@@ -0,0 +1,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,6 @@
|
|
|
1
|
-
import { getDiscriminatedUnion,
|
|
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 { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
4
|
/**
|
|
5
5
|
* TypeSpec type -> plain TypeScript type text.
|
|
6
6
|
*
|
|
@@ -12,9 +12,10 @@ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isO
|
|
|
12
12
|
* importing the other.
|
|
13
13
|
*
|
|
14
14
|
* **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
|
|
15
|
+
* here has a counterpart there: `utcDateTime` is a string on both sides, `?` is `?: T` because
|
|
16
|
+
* `.exactOptional()` refuses an explicit `undefined` and `?: T | undefined` only where `.default()`
|
|
17
|
+
* accepts one, and a property the document leaves out of `required` is optional here whether its
|
|
18
|
+
* absence is spelled `?` or covered by a default.
|
|
18
19
|
*
|
|
19
20
|
* **The assertion pairs this walk against `z.input`, the type the validator ACCEPTS**, and not
|
|
20
21
|
* against `z.infer`, which is what it produces. These types are the floor a producer supplies, and
|
|
@@ -72,7 +73,7 @@ function refuse(program, type, why, remedy = "") {
|
|
|
72
73
|
}
|
|
73
74
|
function scalarToTs(program, scalar) {
|
|
74
75
|
// A scalar declaration can carry the encoding itself - see `scalarToZod`.
|
|
75
|
-
const encoded =
|
|
76
|
+
const encoded = encodedTypeOf(program, scalar);
|
|
76
77
|
if (encoded !== undefined && encoded !== scalar)
|
|
77
78
|
return scalarToTs(program, encoded);
|
|
78
79
|
let current = scalar;
|
|
@@ -261,9 +262,14 @@ function modelToTs(program, model) {
|
|
|
261
262
|
* annotation claims is by construction the type this walk renders for the same property.
|
|
262
263
|
*/
|
|
263
264
|
export function propertyTypeToTs(program, property) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
/**
|
|
266
|
+
* **`@encode` changes the wire TYPE, and `encodedTypeOf` is the one rule for when it does.** This
|
|
267
|
+
* read `getEncode(...).type` directly, so it missed the exception the Zod walk makes: an array
|
|
268
|
+
* encoding on a QUERY parameter is a style of flattening, not a type, and the validator splits it
|
|
269
|
+
* back into the declared list. So `WireInputs` said `colors: string` for a pipe-delimited query
|
|
270
|
+
* whose handler is given `string[]` - two walks of one property, one of them knowing a rule.
|
|
271
|
+
*/
|
|
272
|
+
const encoded = encodedTypeOf(program, property);
|
|
267
273
|
return typeToTs(program, encoded ?? property.type);
|
|
268
274
|
}
|
|
269
275
|
export function propertyToTs(program, property, wireName) {
|
|
@@ -297,21 +303,28 @@ export function propertyToTs(program, property, wireName) {
|
|
|
297
303
|
* `.default(...)` for it - so the two walks agree without either one restating the rule.
|
|
298
304
|
*/
|
|
299
305
|
/**
|
|
300
|
-
* **`?: T
|
|
306
|
+
* **`?: T`, and `?: T | undefined` only where the validator emits `.default(...)`. The contract
|
|
307
|
+
* type is the validator's INPUT, `z.input`, at every property.**
|
|
308
|
+
*
|
|
309
|
+
* `.exactOptional()` refuses `{ note: undefined }` and `.default(v)` accepts it, so those are the
|
|
310
|
+
* two answers; {@link emitsDefault} says which one this property got. `wire-contract.gen.ts`
|
|
311
|
+
* compares the two walks with nothing widened, so a disagreement here does not compile.
|
|
301
312
|
*
|
|
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.
|
|
313
|
+
* **This was `?: T | undefined` for every optional, kept deliberately on three reasons that were
|
|
314
|
+
* each measured false** once `0.24.0` moved the validator to `.exactOptional()`:
|
|
307
315
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
316
|
+
* - the echo handler `(ctx, input) => ok(input)` broke without it, because what a handler
|
|
317
|
+
* received carried `| undefined`. What it receives is `z.infer` of `.exactOptional()` now,
|
|
318
|
+
* `?: T`, and the echo compiles;
|
|
319
|
+
* - `{ note: undefined }` serialises identically to omitting the key. A server checks a body
|
|
320
|
+
* before it serialises it: a value typed by `WireOutputs` reached `servedBody` and answered
|
|
321
|
+
* 500 for a body whose bytes were valid, and across a service binding it is never serialised;
|
|
322
|
+
* - refusing it costs a producer a conditional spread. That spread infers `{ note?: string }` on
|
|
323
|
+
* TypeScript 5.9 and 7.0 and assigns to the exact type, and it is what a producer under
|
|
324
|
+
* `exactOptionalPropertyTypes` writes for the handler's own return type anyway.
|
|
312
325
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
326
|
+
* Two consumers reported the disagreement independently - one carried a `Loosen<T>` to compare the
|
|
327
|
+
* two surfaces - and openapi-typescript, `hc` and `z.infer` all spell an optional `?: T`.
|
|
315
328
|
*/
|
|
316
329
|
/**
|
|
317
330
|
* **Optional AT THIS POSITION, not merely optional in the declaration.** A PATCH body's properties
|
|
@@ -319,9 +332,9 @@ export function propertyToTs(program, property, wireName) {
|
|
|
319
332
|
* what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
|
|
320
333
|
* update contract demanded every field while the validator beside it accepted any subset.
|
|
321
334
|
*/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
335
|
+
if (!(property.optional || isOptionalAt(program, property)))
|
|
336
|
+
return `${key}: ${value};`;
|
|
337
|
+
return emitsDefault(program, property) ? `${key}?: ${value} | undefined;` : `${key}?: ${value};`;
|
|
325
338
|
}
|
|
326
339
|
/** Installed by the registry so a named model is declared once and referenced everywhere else. */
|
|
327
340
|
let resolveRef = () => undefined;
|
|
@@ -103,4 +103,4 @@ export declare function explodedQueryCall(spec: {
|
|
|
103
103
|
* gathers those keys back under the parameter's name before the query object is validated: a model
|
|
104
104
|
* takes exactly its declared properties, a record every key no other query parameter claims.
|
|
105
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)
|
|
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)) 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
|
@@ -230,10 +230,13 @@ function explodedQuery(
|
|
|
230
230
|
readonly values: "number" | "boolean" | "";
|
|
231
231
|
},
|
|
232
232
|
): unknown {
|
|
233
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)
|
|
233
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
234
234
|
const gathered: Record<string, unknown> = {};
|
|
235
235
|
const rest: Record<string, unknown> = {};
|
|
236
236
|
for (const [key, value] of Object.entries(raw)) {
|
|
237
|
+
// Already gathered only where an OBJECT sits under the name: a query value from the wire is a
|
|
238
|
+
// string or a list of them, and \`{ param: 0 }\` expands to \`?param=0\`.
|
|
239
|
+
if (key === spec.name && value !== null && typeof value === "object" && !Array.isArray(value)) return raw;
|
|
237
240
|
const own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);
|
|
238
241
|
if (!own) {
|
|
239
242
|
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.
|
|
@@ -143,7 +143,21 @@ export declare function objectKey(name: string): string;
|
|
|
143
143
|
* comment indented differently from the member it documents is the tell that a generator wrote it.
|
|
144
144
|
*/
|
|
145
145
|
export declare function jsDocComment(text: string | undefined, indent: string): string;
|
|
146
|
+
export declare function encodedTypeOf(program: Program, target: ModelProperty | Scalar): Scalar | undefined;
|
|
146
147
|
export declare function propertyToZod(program: Program, property: ModelProperty): string;
|
|
148
|
+
/**
|
|
149
|
+
* **Whether `propertyToZod` emits `.default(...)` for this property, which is exactly whether its
|
|
150
|
+
* INPUT admits an explicit `undefined`.**
|
|
151
|
+
*
|
|
152
|
+
* `.exactOptional()` refuses `{ p: undefined }`; `.default(v)` accepts it and fills `v`. The contract
|
|
153
|
+
* types in `requests.gen.ts` state the input, so they have to know which of the two this emitted -
|
|
154
|
+
* and asking this function rather than restating its rule is what keeps the two walks agreeing.
|
|
155
|
+
* A default on a REQUIRED property emits neither (`default-on-required-property`), and one whose
|
|
156
|
+
* value cannot be rendered emits no `.default()` (`unsupported-default`); both fall out here.
|
|
157
|
+
*
|
|
158
|
+
* Pure: it reports nothing, so the type walk can ask it without duplicating a diagnostic.
|
|
159
|
+
*/
|
|
160
|
+
export declare function emitsDefault(program: Program, property: ModelProperty): boolean;
|
|
147
161
|
export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
|
|
148
162
|
/** Called by the registry when it hands out the identifier of a declaration that is still open. */
|
|
149
163
|
export declare function noteBackEdge(): void;
|
package/dist/src/zod.js
CHANGED
|
@@ -1063,7 +1063,7 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
|
|
|
1063
1063
|
"ArrayEncoding.commaDelimited",
|
|
1064
1064
|
"ArrayEncoding.newlineDelimited",
|
|
1065
1065
|
]);
|
|
1066
|
-
function encodedTypeOf(program, target) {
|
|
1066
|
+
export function encodedTypeOf(program, target) {
|
|
1067
1067
|
const encoded = getEncode(program, target);
|
|
1068
1068
|
if (encoded === undefined)
|
|
1069
1069
|
return undefined;
|
|
@@ -1209,6 +1209,24 @@ function renderValue(value) {
|
|
|
1209
1209
|
}
|
|
1210
1210
|
return undefined;
|
|
1211
1211
|
}
|
|
1212
|
+
/**
|
|
1213
|
+
* **Whether `propertyToZod` emits `.default(...)` for this property, which is exactly whether its
|
|
1214
|
+
* INPUT admits an explicit `undefined`.**
|
|
1215
|
+
*
|
|
1216
|
+
* `.exactOptional()` refuses `{ p: undefined }`; `.default(v)` accepts it and fills `v`. The contract
|
|
1217
|
+
* types in `requests.gen.ts` state the input, so they have to know which of the two this emitted -
|
|
1218
|
+
* and asking this function rather than restating its rule is what keeps the two walks agreeing.
|
|
1219
|
+
* A default on a REQUIRED property emits neither (`default-on-required-property`), and one whose
|
|
1220
|
+
* value cannot be rendered emits no `.default()` (`unsupported-default`); both fall out here.
|
|
1221
|
+
*
|
|
1222
|
+
* Pure: it reports nothing, so the type walk can ask it without duplicating a diagnostic.
|
|
1223
|
+
*/
|
|
1224
|
+
export function emitsDefault(program, property) {
|
|
1225
|
+
const optional = property.optional || isOptionalAt(program, property);
|
|
1226
|
+
return (optional &&
|
|
1227
|
+
property.defaultValue !== undefined &&
|
|
1228
|
+
renderValue(property.defaultValue) !== undefined);
|
|
1229
|
+
}
|
|
1212
1230
|
/** A property's declared default, as a JS literal, or `undefined` when it has none. */
|
|
1213
1231
|
function defaultOf(program, property) {
|
|
1214
1232
|
const value = property.defaultValue;
|