typespec-http-zod 0.23.0 → 0.25.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 +61 -7
- package/dist/src/api.d.ts +34 -12
- package/dist/src/api.js +315 -114
- package/dist/src/constraints.d.ts +7 -6
- package/dist/src/constraints.js +7 -6
- package/dist/src/lib.d.ts +43 -1
- package/dist/src/lib.js +46 -0
- package/dist/src/registry.d.ts +41 -0
- package/dist/src/registry.js +80 -19
- package/dist/src/types.js +29 -8
- package/dist/src/zod.d.ts +27 -0
- package/dist/src/zod.js +64 -3
- package/package.json +4 -3
|
@@ -6,12 +6,13 @@ import { type Program, type Type } from "@typespec/compiler";
|
|
|
6
6
|
* over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
|
|
7
7
|
* documents them without any help from us.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* other
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* **This package ships no decorators of its own, and the two it used to have are gone.** `@refine`
|
|
10
|
+
* was an arbitrary predicate, which a type language has no way to say and therefore no document can
|
|
11
|
+
* publish, so a validator enforcing it enforced something no caller could read. `@trimmed` was the
|
|
12
|
+
* other: `.trim()` is a transform, and transforming a value before validating it makes the runtime
|
|
13
|
+
* accept inputs the published contract rejects. The spec states `@pattern` instead, which openapi3
|
|
14
|
+
* publishes. `src/tsp-index.ts` exports `$lib` and no `$decorators`, and `vocabulary.test.ts`
|
|
15
|
+
* asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
|
|
15
16
|
*/
|
|
16
17
|
export declare function applyConstraints(program: Program, expression: string, target: Type): string;
|
|
17
18
|
/**
|
package/dist/src/constraints.js
CHANGED
|
@@ -6,12 +6,13 @@ import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getM
|
|
|
6
6
|
* over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
|
|
7
7
|
* documents them without any help from us.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* other
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* **This package ships no decorators of its own, and the two it used to have are gone.** `@refine`
|
|
10
|
+
* was an arbitrary predicate, which a type language has no way to say and therefore no document can
|
|
11
|
+
* publish, so a validator enforcing it enforced something no caller could read. `@trimmed` was the
|
|
12
|
+
* other: `.trim()` is a transform, and transforming a value before validating it makes the runtime
|
|
13
|
+
* accept inputs the published contract rejects. The spec states `@pattern` instead, which openapi3
|
|
14
|
+
* publishes. `src/tsp-index.ts` exports `$lib` and no `$decorators`, and `vocabulary.test.ts`
|
|
15
|
+
* asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
|
|
15
16
|
*/
|
|
16
17
|
export function applyConstraints(program, expression, target) {
|
|
17
18
|
let result = expression;
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -42,6 +42,38 @@ export interface EmitterOptions {
|
|
|
42
42
|
* stating it outright rather than an emitter option deciding.
|
|
43
43
|
*/
|
|
44
44
|
"seal-object-schemas"?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Whether every emitted validator is wrapped in `z.compile()`.
|
|
47
|
+
*
|
|
48
|
+
* **A pre-compiled schema parses the same values to the same results, only faster.** Zod walks the
|
|
49
|
+
* schema once and emits a flat, loop-free function through `new Function`, then falls back to the
|
|
50
|
+
* ordinary parser for anything it cannot handle - so errors, parsed output and JSON Schema
|
|
51
|
+
* serialisation are unchanged. Measured on an emitted five-property model at zod 4.5.2:
|
|
52
|
+
* `safeParse` goes from 371 ns to 51 ns.
|
|
53
|
+
*
|
|
54
|
+
* **Off by default, because it is a trade rather than a free win.** Compilation happens at module
|
|
55
|
+
* scope, so it costs startup time in proportion to the number of schemas, and buys nothing until
|
|
56
|
+
* enough requests arrive to pay that back. Measured on two emitted modules at zod 4.5.2: 25
|
|
57
|
+
* declarations in 3.3 ms and 73 in 5.7 ms, so 0.08-0.13 ms each depending on how much the schema
|
|
58
|
+
* does.
|
|
59
|
+
*
|
|
60
|
+
* **Measured at scale rather than extrapolated:** a generated 580-operation service (58 models,
|
|
61
|
+
* 1218 declarations, 638 of them compiled) evaluates its schema module in ~93 ms uncompiled and
|
|
62
|
+
* ~132 ms compiled - so about **40 ms of extra startup**, roughly a tenth of a Cloudflare Worker's
|
|
63
|
+
* 400 ms startup budget. It boots and serves on `workerd` at that size with the fast path live. It also needs
|
|
64
|
+
* `new Function`, which a CSP or no-eval environment refuses; Zod degrades to the uncompiled
|
|
65
|
+
* schema there rather than throwing, so setting this is never unsafe, merely sometimes pointless.
|
|
66
|
+
*
|
|
67
|
+
* **On Cloudflare Workers this is the only route to a compiled schema.** `new Function` is
|
|
68
|
+
* permitted during a Worker's STARTUP phase (`allow_eval_during_startup`, the default from
|
|
69
|
+
* compatibility date 2025-06-01) and module scope is startup, so wrapping here works. Zod's own
|
|
70
|
+
* `import "zod/compile"` does not: it compiles on first PARSE, which happens inside a request,
|
|
71
|
+
* where the runtime refuses. Only the emitter owns the module scope these schemas are built in.
|
|
72
|
+
*
|
|
73
|
+
* A recursive model emits `z.lazy()`, which cannot be compiled. Zod returns it uncompiled and it
|
|
74
|
+
* goes on parsing normally, so the option is safe to set on any spec.
|
|
75
|
+
*/
|
|
76
|
+
"compile-schemas"?: boolean;
|
|
45
77
|
/**
|
|
46
78
|
* Models whose PROPERTY NAMES are also emitted as a runtime tuple.
|
|
47
79
|
*
|
|
@@ -100,6 +132,7 @@ export interface EmitterOptions {
|
|
|
100
132
|
"contracts-output-dir"?: string;
|
|
101
133
|
"contracts-package"?: string;
|
|
102
134
|
"seal-object-schemas"?: boolean;
|
|
135
|
+
"compile-schemas"?: boolean;
|
|
103
136
|
"key-vocabularies"?: string[];
|
|
104
137
|
"runtime-module"?: string;
|
|
105
138
|
"regenerate-hint"?: string;
|
|
@@ -122,6 +155,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
122
155
|
"default-on-required-property": {
|
|
123
156
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
124
157
|
};
|
|
158
|
+
"truncated-doc-comment": {
|
|
159
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
|
|
160
|
+
};
|
|
125
161
|
"undeclared-discriminator": {
|
|
126
162
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
127
163
|
};
|
|
@@ -144,7 +180,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
144
180
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
145
181
|
};
|
|
146
182
|
}, EmitterOptions, never>;
|
|
147
|
-
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
183
|
+
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "truncated-doc-comment" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
148
184
|
"unsupported-type": {
|
|
149
185
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
150
186
|
};
|
|
@@ -154,6 +190,9 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
154
190
|
"default-on-required-property": {
|
|
155
191
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
156
192
|
};
|
|
193
|
+
"truncated-doc-comment": {
|
|
194
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
|
|
195
|
+
};
|
|
157
196
|
"undeclared-discriminator": {
|
|
158
197
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
159
198
|
};
|
|
@@ -185,6 +224,9 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
185
224
|
"default-on-required-property": {
|
|
186
225
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
187
226
|
};
|
|
227
|
+
"truncated-doc-comment": {
|
|
228
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
|
|
229
|
+
};
|
|
188
230
|
"undeclared-discriminator": {
|
|
189
231
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
190
232
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -12,6 +12,7 @@ export const EmitterOptionsSchema = {
|
|
|
12
12
|
"contracts-output-dir": { type: "string", nullable: true },
|
|
13
13
|
"contracts-package": { type: "string", nullable: true },
|
|
14
14
|
"seal-object-schemas": { type: "boolean", nullable: true },
|
|
15
|
+
"compile-schemas": { type: "boolean", nullable: true },
|
|
15
16
|
"key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
|
|
16
17
|
"runtime-module": { type: "string", nullable: true },
|
|
17
18
|
"regenerate-hint": { type: "string", nullable: true },
|
|
@@ -26,6 +27,7 @@ export const EmitterOptionsSchema = {
|
|
|
26
27
|
"contracts-output-dir": { type: "string", nullable: true },
|
|
27
28
|
"contracts-package": { type: "string", nullable: true },
|
|
28
29
|
"seal-object-schemas": { type: "boolean", nullable: true },
|
|
30
|
+
"compile-schemas": { type: "boolean", nullable: true },
|
|
29
31
|
"key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
|
|
30
32
|
"runtime-module": { type: "string", nullable: true },
|
|
31
33
|
"regenerate-hint": { type: "string", nullable: true },
|
|
@@ -112,6 +114,50 @@ const diagnostics = {
|
|
|
112
114
|
default: paramMessage `'${"name"}' is required, so its default can never apply: a required property is never absent. The OpenAPI document publishes it in 'required' and carries the default only as an annotation, and this validator does the same - a request omitting '${"name"}' is refused. Declare it optional, '${"name"}?: ... = ...', if callers may omit it.`,
|
|
113
115
|
},
|
|
114
116
|
},
|
|
117
|
+
/**
|
|
118
|
+
* **A doc comment the compiler truncated, so the description this emitter passes on is cut short.**
|
|
119
|
+
*
|
|
120
|
+
* **An unescaped `@` in a doc comment begins a doc tag, and everything from there to the end of
|
|
121
|
+
* the comment leaves the description.** Measured against `@typespec/compiler@1.15.0` through its
|
|
122
|
+
* own parser: `parseDocContent` breaks its loop on `Token.At` outside a code fence, and the
|
|
123
|
+
* doc-mode scanner turns every `@` into that token. `parseDocTag` then parses the remainder as a
|
|
124
|
+
* tag and, for a name it does not know, keeps it as `DocUnknownTag` - which `getDoc` never reads.
|
|
125
|
+
* No diagnostic is raised anywhere in that path.
|
|
126
|
+
*
|
|
127
|
+
* So it is not only `{@link}`. `support@example.com` in prose truncates at `support`, and
|
|
128
|
+
* "use @maxLength to bound it" truncates at "use", with no brace anywhere to notice. A consumer
|
|
129
|
+
* measured 14 of 1,006 published descriptions cut this way and found it by reading the emitted
|
|
130
|
+
* document by hand.
|
|
131
|
+
*
|
|
132
|
+
* **The check reads the AST, because the truncated string cannot answer.** By the time `getDoc`
|
|
133
|
+
* returns, the `@` is gone, so a check over the emitted text would find nothing to match - and
|
|
134
|
+
* pattern-matching emitted text is the thing this package refuses to decide anything by. The fact
|
|
135
|
+
* is on the node: a tag that is not `param`, `template`, `prop`, `returns`, `return` or `errors`
|
|
136
|
+
* parses as `DocUnknownTag`. That is exact, with no heuristic and no false positive on the four
|
|
137
|
+
* spellings that survive: a brace in prose, a backticked brace, `\{`, and `\@`.
|
|
138
|
+
*
|
|
139
|
+
* **Gated on the description actually coming from the comment**, via `getDocData().source`. A type
|
|
140
|
+
* carrying an `@doc(...)` decorator takes its description from there, where `@` inside a string
|
|
141
|
+
* literal means nothing, so warning would be a false positive - and a warning that fires on
|
|
142
|
+
* correct specs is one a consumer turns off wholesale.
|
|
143
|
+
*
|
|
144
|
+
* **A WARNING, for the reason `default-on-required-property` is one.** The spec is representable:
|
|
145
|
+
* `@typespec/openapi3` publishes the same truncated description happily, so refusing it would make
|
|
146
|
+
* one spec emit under one emitter and not the other. An error would also set `program.hasError()`
|
|
147
|
+
* and cost the whole document over one comment. What is wrong is not that it cannot be served, but
|
|
148
|
+
* that the author has written something that does not say what they meant.
|
|
149
|
+
*
|
|
150
|
+
* **Upstream this is intended, and the escape is sanctioned.** `microsoft/typespec#3374` reported
|
|
151
|
+
* exactly this, naming both `{@link}` and email addresses, and was closed as a duplicate of
|
|
152
|
+
* `#1358`, which is open on the Backlog. `#3375` then added the `\@` escape deliberately. So the
|
|
153
|
+
* remedy this message names is upstream's own, and only the silence is unaccounted for.
|
|
154
|
+
*/
|
|
155
|
+
"truncated-doc-comment": {
|
|
156
|
+
severity: "warning",
|
|
157
|
+
messages: {
|
|
158
|
+
default: paramMessage `'${"name"}' has a doc comment that stops at '@${"tag"}': an unescaped '@' begins a doc tag, so everything after it is dropped from the description this emitter and the OpenAPI document beside it both publish. Use '\\@${"tag"}' to escape it, wrap it in backticks, or set the description with '@doc(...)' instead.`,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
115
161
|
/**
|
|
116
162
|
* **A discriminated union whose discriminator the document never publishes.**
|
|
117
163
|
*
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelProperty, Program, Type } from "@typespec/compiler";
|
|
2
|
+
import { Visibility } from "@typespec/http";
|
|
2
3
|
/**
|
|
3
4
|
* Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
|
|
4
5
|
*
|
|
@@ -54,6 +55,46 @@ interface Declaration {
|
|
|
54
55
|
/** Whether the declaration is annotated `: z.ZodType<typeName>`, which `z.lazy()` requires. */
|
|
55
56
|
readonly annotated?: boolean;
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* One model can need MORE THAN ONE declaration.
|
|
60
|
+
*
|
|
61
|
+
* **Keyed on `(type, visibility)`, because a model is not one shape.** `@visibility` and HTTP
|
|
62
|
+
* metadata both mean the same declaration projects differently by position: `@typespec/openapi3`
|
|
63
|
+
* emits `VisibilityModel` and `VisibilityModelCreate` as separate components for exactly this
|
|
64
|
+
* reason. Keyed on the type alone, whichever position was walked first won and every other position
|
|
65
|
+
* silently got its shape - measured on the corpus, one strict model carrying all six lifecycle
|
|
66
|
+
* properties, so a create request was required to send read-only fields.
|
|
67
|
+
*
|
|
68
|
+
* The suffix rule is openapi3's: canonical (`Read`) keeps the bare name, and another visibility only
|
|
69
|
+
* earns a suffix when the model is actually TRANSFORMED at it. Same rule, so the identifiers line up
|
|
70
|
+
* with the component names the document publishes.
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* The visibility a type is actually DECLARED at, which is not always the one requested.
|
|
74
|
+
*
|
|
75
|
+
* **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
|
|
76
|
+
* on the requested visibility while only suffixing transformed types produced two cache entries
|
|
77
|
+
* sharing one identifier and an emitted file that stopped parsing:
|
|
78
|
+
* `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
|
|
79
|
+
* and a response, which is what the `Model` guard is for.
|
|
80
|
+
*
|
|
81
|
+
* It is the same collapse `@typespec/openapi3` performs before naming a component, and
|
|
82
|
+
* `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
|
|
83
|
+
*
|
|
84
|
+
* **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
|
|
85
|
+
* validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
|
|
86
|
+
* is a pairing that silently compares the wrong two things.
|
|
87
|
+
*/
|
|
88
|
+
export declare function visibilityFor(program: Program, type: Type, requested: Visibility): Visibility;
|
|
89
|
+
/**
|
|
90
|
+
* A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
|
|
91
|
+
*
|
|
92
|
+
* The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
|
|
93
|
+
* canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
|
|
94
|
+
* argument, so these identifiers line up with the component names the document publishes rather than
|
|
95
|
+
* merely resembling them.
|
|
96
|
+
*/
|
|
97
|
+
export declare function nameAt(bare: string, at: Visibility): string;
|
|
57
98
|
export declare class SchemaRegistry {
|
|
58
99
|
#private;
|
|
59
100
|
constructor(program: Program);
|
package/dist/src/registry.js
CHANGED
|
@@ -78,11 +78,22 @@ const spreadSourceName = (type) => spreadSourceOf(type)?.name;
|
|
|
78
78
|
* so a word missing from the list fails a test rather than reaching a consumer - the list is the
|
|
79
79
|
* implementation and the compiler is the oracle.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
83
|
-
*
|
|
81
|
+
* Most contextual keywords are absent, because they are legal: `interface`, `string`, `number`,
|
|
82
|
+
* `any`, `never` and `public` all name a type without complaint.
|
|
83
|
+
*
|
|
84
|
+
* **Two contextual words are NOT legal, and both were missing from this set.** `as` and `yield` are
|
|
85
|
+
* refused in the declaration positions this emitter writes -- `export type as = ...` is
|
|
86
|
+
* `TS1005: '{' expected`, and `yield` is `TS1214: 'yield' is a reserved word in strict mode` as both
|
|
87
|
+
* a type alias and an interface. Either one made the whole emitted file unparseable, because the
|
|
88
|
+
* parser gives up after the first.
|
|
89
|
+
*
|
|
90
|
+
* **A word belongs here when `tsc` refuses it in EITHER position**, since both are emitted for the
|
|
91
|
+
* same model. Measured that way against tsc 7.0.2, one invocation per word: of the 56 candidates,
|
|
92
|
+
* 39 are refused and 39 are listed. `yield` had been recorded here as measured-legal and is not,
|
|
93
|
+
* which is why the arm that grades this set now takes its words from somewhere else entirely.
|
|
84
94
|
*/
|
|
85
95
|
const RESERVED_DECLARATION_NAMES = new Set([
|
|
96
|
+
"as",
|
|
86
97
|
"await",
|
|
87
98
|
"break",
|
|
88
99
|
"case",
|
|
@@ -120,6 +131,7 @@ const RESERVED_DECLARATION_NAMES = new Set([
|
|
|
120
131
|
"void",
|
|
121
132
|
"while",
|
|
122
133
|
"with",
|
|
134
|
+
"yield",
|
|
123
135
|
]);
|
|
124
136
|
/**
|
|
125
137
|
* The name a declaration is emitted under.
|
|
@@ -185,6 +197,40 @@ const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
|
185
197
|
* earns a suffix when the model is actually TRANSFORMED at it. Same rule, so the identifiers line up
|
|
186
198
|
* with the component names the document publishes.
|
|
187
199
|
*/
|
|
200
|
+
/**
|
|
201
|
+
* The visibility a type is actually DECLARED at, which is not always the one requested.
|
|
202
|
+
*
|
|
203
|
+
* **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
|
|
204
|
+
* on the requested visibility while only suffixing transformed types produced two cache entries
|
|
205
|
+
* sharing one identifier and an emitted file that stopped parsing:
|
|
206
|
+
* `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
|
|
207
|
+
* and a response, which is what the `Model` guard is for.
|
|
208
|
+
*
|
|
209
|
+
* It is the same collapse `@typespec/openapi3` performs before naming a component, and
|
|
210
|
+
* `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
|
|
211
|
+
*
|
|
212
|
+
* **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
|
|
213
|
+
* validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
|
|
214
|
+
* is a pairing that silently compares the wrong two things.
|
|
215
|
+
*/
|
|
216
|
+
export function visibilityFor(program, type, requested) {
|
|
217
|
+
return type.kind === "Model" &&
|
|
218
|
+
requested !== Visibility.Read &&
|
|
219
|
+
isTransformedBy(program, type, requested)
|
|
220
|
+
? requested
|
|
221
|
+
: Visibility.Read;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
|
|
225
|
+
*
|
|
226
|
+
* The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
|
|
227
|
+
* canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
|
|
228
|
+
* argument, so these identifiers line up with the component names the document publishes rather than
|
|
229
|
+
* merely resembling them.
|
|
230
|
+
*/
|
|
231
|
+
export function nameAt(bare, at) {
|
|
232
|
+
return `${bare}${at === Visibility.Read ? "" : getVisibilitySuffix(at, Visibility.Read)}`;
|
|
233
|
+
}
|
|
188
234
|
function keyFor(type, visibility) {
|
|
189
235
|
/**
|
|
190
236
|
* **Keyed on the SPREAD SOURCE where there is one, or one component gets two declarations.**
|
|
@@ -244,12 +290,7 @@ export class SchemaRegistry {
|
|
|
244
290
|
* request and a response. Collapsing onto `Read` first is what makes the key and the name agree,
|
|
245
291
|
* and it is the same collapse openapi3 performs before naming a component.
|
|
246
292
|
*/
|
|
247
|
-
const
|
|
248
|
-
const at = type.kind === "Model" &&
|
|
249
|
-
requested !== Visibility.Read &&
|
|
250
|
-
isTransformedBy(this.#program, type, requested)
|
|
251
|
-
? requested
|
|
252
|
-
: Visibility.Read;
|
|
293
|
+
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
253
294
|
const key = keyFor(type, at);
|
|
254
295
|
const existing = this.#declarations.get(key);
|
|
255
296
|
if (existing !== undefined)
|
|
@@ -275,7 +316,7 @@ export class SchemaRegistry {
|
|
|
275
316
|
this.#cyclic.add(inFlight);
|
|
276
317
|
return pending;
|
|
277
318
|
}
|
|
278
|
-
const name =
|
|
319
|
+
const name = nameAt(bare, at);
|
|
279
320
|
const identifier = `${lowerFirst(name)}Schema`;
|
|
280
321
|
this.#inProgress.set(key, identifier);
|
|
281
322
|
const rendered = captureBackEdges(() => this.#inline(type));
|
|
@@ -459,17 +500,32 @@ function isSingleObjectLiteral(source) {
|
|
|
459
500
|
}
|
|
460
501
|
export class TypeRegistry {
|
|
461
502
|
#program;
|
|
503
|
+
/**
|
|
504
|
+
* **Keyed on `(type, visibility)`, exactly as {@link SchemaRegistry} is, and for the same reason.**
|
|
505
|
+
*
|
|
506
|
+
* One model is not one shape. Keyed on the `Type` alone, whichever position was walked first won
|
|
507
|
+
* and every other position silently got its shape - measured on `type/model/visibility`, where a
|
|
508
|
+
* `GET` reached the model first and the create body's contract type came out carrying the read
|
|
509
|
+
* projection. The two registries pair BY NAME in the emitted assertion, so they have to agree
|
|
510
|
+
* about how many declarations there are before the pairing means anything.
|
|
511
|
+
*
|
|
512
|
+
* Both now go through the shared {@link visibilityFor} and {@link nameAt}: paraphrasing either
|
|
513
|
+
* rule in one walk is how a validator ends up compared against a type describing something else.
|
|
514
|
+
*/
|
|
462
515
|
#declarations = new Map();
|
|
463
516
|
#order = [];
|
|
464
|
-
#inProgress = new
|
|
517
|
+
#inProgress = new Map();
|
|
465
518
|
constructor(program) {
|
|
466
519
|
this.#program = program;
|
|
467
520
|
}
|
|
468
521
|
expressionFor(type) {
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
522
|
+
const bare = declaredNameOf(type);
|
|
523
|
+
if (bare === undefined)
|
|
471
524
|
return this.#inline(type);
|
|
472
|
-
const
|
|
525
|
+
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
526
|
+
const key = keyFor(type, at);
|
|
527
|
+
const name = nameAt(bare, at);
|
|
528
|
+
const existing = this.#declarations.get(key);
|
|
473
529
|
if (existing !== undefined)
|
|
474
530
|
return existing.name;
|
|
475
531
|
/**
|
|
@@ -477,12 +533,17 @@ export class TypeRegistry {
|
|
|
477
533
|
* may refer to itself - `interface InnerModel { children?: InnerModel[] }` is the ordinary way
|
|
478
534
|
* to write a tree - so the name alone is the whole answer. The Zod registry has to defer the
|
|
479
535
|
* same edge behind a getter only because a `const` cannot read itself while initialising.
|
|
536
|
+
*
|
|
537
|
+
* The in-flight name is the SUFFIXED one, not the bare one: a recursive model reached at
|
|
538
|
+
* `Create` must refer to `ThingCreate`, or it self-references a declaration that is not the
|
|
539
|
+
* one being written.
|
|
480
540
|
*/
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
541
|
+
const pending = this.#inProgress.get(key);
|
|
542
|
+
if (pending !== undefined)
|
|
543
|
+
return pending;
|
|
544
|
+
this.#inProgress.set(key, name);
|
|
484
545
|
const source = this.#inline(type);
|
|
485
|
-
this.#inProgress.delete(
|
|
546
|
+
this.#inProgress.delete(key);
|
|
486
547
|
const declaration = {
|
|
487
548
|
name,
|
|
488
549
|
/**
|
|
@@ -508,7 +569,7 @@ export class TypeRegistry {
|
|
|
508
569
|
isVocabulary: type.kind === "Enum",
|
|
509
570
|
source,
|
|
510
571
|
};
|
|
511
|
-
this.#declarations.set(
|
|
572
|
+
this.#declarations.set(key, declaration);
|
|
512
573
|
/**
|
|
513
574
|
* **Declared once per NAME, not once per `Type`, and the two are not the same thing.**
|
|
514
575
|
*
|
package/dist/src/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
|
-
import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
3
|
+
import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
4
|
/**
|
|
5
5
|
* TypeSpec type -> plain TypeScript type text.
|
|
6
6
|
*
|
|
@@ -196,7 +196,20 @@ function modelToTs(program, model) {
|
|
|
196
196
|
* that catches this emitter disagreeing with itself, and it cannot catch a disagreement that
|
|
197
197
|
* aborts the compile first.
|
|
198
198
|
*/
|
|
199
|
+
/**
|
|
200
|
+
* **Metadata is filtered out here, as it is on the Zod side, and the two must agree.**
|
|
201
|
+
*
|
|
202
|
+
* `metadataInfo.isPayloadProperty` is what decides whether a property is in the BODY at this
|
|
203
|
+
* position: a `@query` or `@header` property is carried separately, and an `@invisible` one is
|
|
204
|
+
* carried nowhere. `modelToZod` has asked since it was written; this walk never did, so a
|
|
205
|
+
* contract type listed properties the validator beside it refuses as unknown keys.
|
|
206
|
+
*
|
|
207
|
+
* **Applied AFTER the dictionary check above, deliberately.** That check reads the UNFILTERED
|
|
208
|
+
* list, because a model whose only properties are metadata is still a declared object rather than
|
|
209
|
+
* a dictionary, and filtering first would flip it to `Record<string, T>` on one side alone.
|
|
210
|
+
*/
|
|
199
211
|
const entries = declared
|
|
212
|
+
.filter((property) => isPayloadProperty(program, property))
|
|
200
213
|
.filter((property) => !isNeverType(property.type))
|
|
201
214
|
.map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
|
|
202
215
|
/**
|
|
@@ -226,8 +239,8 @@ function modelToTs(program, model) {
|
|
|
226
239
|
* of the failure - see `test/contractshape/`.
|
|
227
240
|
*
|
|
228
241
|
* What arrives is still described honestly, on the surface derived from the validator:
|
|
229
|
-
* `schemas.gen.ts` exports `
|
|
230
|
-
*
|
|
242
|
+
* `schemas.gen.ts` exports `z.infer<typeof x>`, which carries the index signature because a
|
|
243
|
+
* `.loose()` parse really does. Two surfaces, two directions, neither lying.
|
|
231
244
|
*/
|
|
232
245
|
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
233
246
|
}
|
|
@@ -266,9 +279,9 @@ export function propertyToTs(program, property, wireName) {
|
|
|
266
279
|
* optionality per property fixes every depth at once, because there is no depth to reach.
|
|
267
280
|
*
|
|
268
281
|
* **The two directions of the emitted set differ here on purpose**, exactly as they do on
|
|
269
|
-
* openness: `schemas.gen.ts` exports `
|
|
270
|
-
*
|
|
271
|
-
*
|
|
282
|
+
* openness: `schemas.gen.ts` exports `z.infer<...>`, the narrow view of what ARRIVES, in which a
|
|
283
|
+
* default has fired and the property is present. `wire-contract.gen.ts` pairs this walk against
|
|
284
|
+
* `z.input` for the same reason.
|
|
272
285
|
*
|
|
273
286
|
* A defaulted property that reaches here is one the document publishes as optional, because
|
|
274
287
|
* `propertyToZod` reports `default-on-required-property` for the other spelling and emits no
|
|
@@ -278,7 +291,7 @@ export function propertyToTs(program, property, wireName) {
|
|
|
278
291
|
* **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
|
|
279
292
|
*
|
|
280
293
|
* It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
|
|
281
|
-
* it would make this walk agree with `
|
|
294
|
+
* it would make this walk agree with `z.infer<...>` on the schemas side. **Measured, it
|
|
282
295
|
* breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
|
|
283
296
|
* what you were given - stopped compiling, because what a handler RECEIVES carries
|
|
284
297
|
* `?: T | undefined` and what it would then have to SUPPLY did not.
|
|
@@ -291,7 +304,15 @@ export function propertyToTs(program, property, wireName) {
|
|
|
291
304
|
* The two emitted surfaces differ here on purpose, exactly as they do on openness:
|
|
292
305
|
* `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
|
|
293
306
|
*/
|
|
294
|
-
|
|
307
|
+
/**
|
|
308
|
+
* **Optional AT THIS POSITION, not merely optional in the declaration.** A PATCH body's properties
|
|
309
|
+
* are optional whether or not the spec marked them so, which is `metadataInfo.isOptional` and is
|
|
310
|
+
* what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
|
|
311
|
+
* update contract demanded every field while the validator beside it accepted any subset.
|
|
312
|
+
*/
|
|
313
|
+
return property.optional || isOptionalAt(program, property)
|
|
314
|
+
? `${key}?: ${value} | undefined;`
|
|
315
|
+
: `${key}: ${value};`;
|
|
295
316
|
}
|
|
296
317
|
/** Installed by the registry so a named model is declared once and referenced everywhere else. */
|
|
297
318
|
let resolveRef = () => undefined;
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { type Enum, type Model, type ModelProperty, type Program, type Type } fr
|
|
|
2
2
|
import { Visibility } from "@typespec/http";
|
|
3
3
|
export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
|
|
4
4
|
export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
|
|
5
|
+
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
6
|
+
export declare function isOptionalAt(program: Program, property: ModelProperty): boolean;
|
|
7
|
+
/** Whether a property belongs in the BODY at the current position, rather than being metadata. */
|
|
8
|
+
export declare function isPayloadProperty(program: Program, property: ModelProperty): boolean;
|
|
5
9
|
/** Whether this model is shaped differently at `visibility` than at the canonical `Read`. */
|
|
6
10
|
export declare function isTransformedBy(program: Program, model: Model, at: Visibility): boolean;
|
|
7
11
|
/** The visibility the current walk is emitting for - the registry keys declarations on it. */
|
|
@@ -122,6 +126,29 @@ export declare function wireKindOf(program: Program, type: Type): "number" | "bo
|
|
|
122
126
|
* Exported so there is one answer to this question. There were two sites and only one of them knew.
|
|
123
127
|
*/
|
|
124
128
|
export declare function objectKey(name: string): string;
|
|
129
|
+
/**
|
|
130
|
+
* A doc string as a JSDoc comment, indented, or `""` when there is none.
|
|
131
|
+
*
|
|
132
|
+
* **Spec-authored text going into emitted source is the same question `objectKey` answers for a
|
|
133
|
+
* property name, and it had the same two-sites-one-answer shape.** A summary was interpolated raw
|
|
134
|
+
* into a `/** ... *\/` block by this estate's two server emitters. Measured: `@summary("a *\/ b")` is
|
|
135
|
+
* an ordinary string literal, so the text reaches here intact, closes the comment early and turns
|
|
136
|
+
* the remainder into code. `TS1131`, `TS1434`, `TS1161` - one operation costing the whole file,
|
|
137
|
+
* exactly like a model named `as`.
|
|
138
|
+
*
|
|
139
|
+
* **The bar is idiomatic output, not merely parseable output.** Two consequences:
|
|
140
|
+
*
|
|
141
|
+
* - A terminator is escaped as `*\/`, which is how TypeScript itself writes one inside a comment, so
|
|
142
|
+
* the sentence survives and reads correctly. Dropping or replacing the characters would lose an
|
|
143
|
+
* author's words to a mechanical problem they cannot see from their spec.
|
|
144
|
+
* - A multi-line description is rendered as a multi-line BLOCK. Interpolating the newlines into a
|
|
145
|
+
* one-line form produces a comment whose continuation lines carry no ` * `, which no formatter
|
|
146
|
+
* would emit and no reviewer would write.
|
|
147
|
+
*
|
|
148
|
+
* `indent` is passed rather than assumed because the two call sites sit at different depths, and a
|
|
149
|
+
* comment indented differently from the member it documents is the tell that a generator wrote it.
|
|
150
|
+
*/
|
|
151
|
+
export declare function jsDocComment(text: string | undefined, indent: string): string;
|
|
125
152
|
export declare function propertyToZod(program: Program, property: ModelProperty): string;
|
|
126
153
|
export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
|
|
127
154
|
/** Called by the registry when it hands out the identifier of a declaration that is still open. */
|
package/dist/src/zod.js
CHANGED
|
@@ -139,12 +139,12 @@ export function withVisibility(program, next, run) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
142
|
-
function isOptionalAt(program, property) {
|
|
142
|
+
export function isOptionalAt(program, property) {
|
|
143
143
|
metadataInfo ??= createMetadataInfo(program);
|
|
144
144
|
return metadataInfo.isOptional(property, visibility);
|
|
145
145
|
}
|
|
146
146
|
/** Whether a property belongs in the BODY at the current position, rather than being metadata. */
|
|
147
|
-
function isPayloadProperty(program, property) {
|
|
147
|
+
export function isPayloadProperty(program, property) {
|
|
148
148
|
metadataInfo ??= createMetadataInfo(program);
|
|
149
149
|
return metadataInfo.isPayloadProperty(property, visibility);
|
|
150
150
|
}
|
|
@@ -874,6 +874,49 @@ export function wireKindOf(program, type) {
|
|
|
874
874
|
export function objectKey(name) {
|
|
875
875
|
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
876
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* A doc string as a JSDoc comment, indented, or `""` when there is none.
|
|
879
|
+
*
|
|
880
|
+
* **Spec-authored text going into emitted source is the same question `objectKey` answers for a
|
|
881
|
+
* property name, and it had the same two-sites-one-answer shape.** A summary was interpolated raw
|
|
882
|
+
* into a `/** ... *\/` block by this estate's two server emitters. Measured: `@summary("a *\/ b")` is
|
|
883
|
+
* an ordinary string literal, so the text reaches here intact, closes the comment early and turns
|
|
884
|
+
* the remainder into code. `TS1131`, `TS1434`, `TS1161` - one operation costing the whole file,
|
|
885
|
+
* exactly like a model named `as`.
|
|
886
|
+
*
|
|
887
|
+
* **The bar is idiomatic output, not merely parseable output.** Two consequences:
|
|
888
|
+
*
|
|
889
|
+
* - A terminator is escaped as `*\/`, which is how TypeScript itself writes one inside a comment, so
|
|
890
|
+
* the sentence survives and reads correctly. Dropping or replacing the characters would lose an
|
|
891
|
+
* author's words to a mechanical problem they cannot see from their spec.
|
|
892
|
+
* - A multi-line description is rendered as a multi-line BLOCK. Interpolating the newlines into a
|
|
893
|
+
* one-line form produces a comment whose continuation lines carry no ` * `, which no formatter
|
|
894
|
+
* would emit and no reviewer would write.
|
|
895
|
+
*
|
|
896
|
+
* `indent` is passed rather than assumed because the two call sites sit at different depths, and a
|
|
897
|
+
* comment indented differently from the member it documents is the tell that a generator wrote it.
|
|
898
|
+
*/
|
|
899
|
+
export function jsDocComment(text, indent) {
|
|
900
|
+
if (text === undefined)
|
|
901
|
+
return "";
|
|
902
|
+
const lines = text
|
|
903
|
+
.replaceAll("*/", "*\\/")
|
|
904
|
+
.split("\n")
|
|
905
|
+
.map((line) => line.trimEnd());
|
|
906
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
907
|
+
lines.pop();
|
|
908
|
+
while (lines.length > 0 && lines[0] === "")
|
|
909
|
+
lines.shift();
|
|
910
|
+
if (lines.length === 0)
|
|
911
|
+
return "";
|
|
912
|
+
const [only] = lines;
|
|
913
|
+
if (lines.length === 1 && only !== undefined)
|
|
914
|
+
return `${indent}/** ${only} */\n`;
|
|
915
|
+
const body = lines
|
|
916
|
+
.map((line) => (line === "" ? `${indent} *` : `${indent} * ${line}`))
|
|
917
|
+
.join("\n");
|
|
918
|
+
return `${indent}/**\n${body}\n${indent} */\n`;
|
|
919
|
+
}
|
|
877
920
|
/**
|
|
878
921
|
* The name a property carries **on the wire**, which is not always the name the spec gave it.
|
|
879
922
|
*
|
|
@@ -1021,7 +1064,25 @@ export function propertyToZod(program, property) {
|
|
|
1021
1064
|
const defaulted = defaultOf(program, property);
|
|
1022
1065
|
if (defaulted !== undefined)
|
|
1023
1066
|
return `${base}.default(${defaulted})`;
|
|
1024
|
-
|
|
1067
|
+
/**
|
|
1068
|
+
* **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
|
|
1069
|
+
*
|
|
1070
|
+
* `.optional()` additionally accepts an explicit `undefined`, and **JSON cannot carry one** - so
|
|
1071
|
+
* for a body the validator was admitting a value no conformant request can contain, and for a
|
|
1072
|
+
* parameter one no transport produces. The document says exactly "absent or a value" by leaving
|
|
1073
|
+
* the property out of `required`, and this is the construct that says the same.
|
|
1074
|
+
*
|
|
1075
|
+
* **The emitted TYPE has said this since `0.17.0`; only the validator disagreed.** `Exact<>` was
|
|
1076
|
+
* written to strip `| undefined` back off `z.infer`, with a docblock arguing precisely this - so
|
|
1077
|
+
* one program published a type refusing `{ note: undefined }` beside a validator accepting it, and
|
|
1078
|
+
* nothing compared them. `z.infer` of `.exactOptional()` is `note?: T` natively and at every depth,
|
|
1079
|
+
* which a shallow mapped type never managed, so `Exact<>` is deleted rather than kept beside it.
|
|
1080
|
+
*
|
|
1081
|
+
* Measured on zod 4.5.2 before the change: `z.toJSONSchema` is byte-identical for the two, so the
|
|
1082
|
+
* differential's serialisation axis is untouched, and `def.type` is still `"optional"`, so the
|
|
1083
|
+
* shape describers read it unchanged.
|
|
1084
|
+
*/
|
|
1085
|
+
return optional ? `${base}.exactOptional()` : base;
|
|
1025
1086
|
}
|
|
1026
1087
|
/**
|
|
1027
1088
|
* A default value as a JS literal, or `undefined` where this emitter cannot render one.
|