typespec-http-zod 0.1.0 → 0.3.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 +98 -215
- package/dist/src/api.d.ts +40 -40
- package/dist/src/api.js +202 -167
- package/dist/src/constraints.d.ts +7 -7
- package/dist/src/constraints.js +8 -8
- package/dist/src/emitter.d.ts +1 -1
- package/dist/src/emitter.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/lib.d.ts +16 -34
- package/dist/src/lib.js +36 -44
- package/dist/src/registry.d.ts +59 -8
- package/dist/src/registry.js +77 -72
- package/dist/src/runtime.d.ts +16 -16
- package/dist/src/runtime.js +3 -3
- package/dist/src/streams.d.ts +10 -10
- package/dist/src/streams.js +3 -3
- package/dist/src/tsp-index.d.ts +2 -2
- package/dist/src/tsp-index.js +2 -2
- package/dist/src/types.d.ts +12 -3
- package/dist/src/types.js +67 -33
- package/dist/src/versioning.d.ts +6 -6
- package/dist/src/versioning.js +3 -3
- package/dist/src/zod.d.ts +22 -22
- package/dist/src/zod.js +201 -147
- package/lib/main.tsp +1 -1
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { type Program, type Type } from "@typespec/compiler";
|
|
2
2
|
/**
|
|
3
|
-
* Constraint decorators
|
|
3
|
+
* Constraint decorators -> the Zod modifiers that enforce them.
|
|
4
4
|
*
|
|
5
|
-
* These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`,
|
|
5
|
+
* These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, ...), which matters twice
|
|
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
|
-
*
|
|
9
|
+
* **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint -
|
|
10
10
|
* it is an arbitrary predicate, which a type language has no way to say and therefore no document
|
|
11
11
|
* can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
|
|
12
12
|
* other one and is gone: `.trim()` is a transform, and transforming a value before validating it
|
|
@@ -15,9 +15,9 @@ import { type Program, type Type } from "@typespec/compiler";
|
|
|
15
15
|
*/
|
|
16
16
|
export declare function applyConstraints(program: Program, expression: string, target: Type): string;
|
|
17
17
|
/**
|
|
18
|
-
* A `@pattern` string becomes a regex literal
|
|
18
|
+
* A `@pattern` string becomes a regex literal - **verbatim**.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
20
|
+
* **This used to add anchors, and that was wrong twice over.**
|
|
21
21
|
*
|
|
22
22
|
* The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
|
|
23
23
|
* JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
|
|
@@ -26,11 +26,11 @@ export declare function applyConstraints(program: Program, expression: string, t
|
|
|
26
26
|
*
|
|
27
27
|
* Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
|
|
28
28
|
* alternation changed meaning rather than being constrained: the spec's `\S|^$` became `^\S|^$`,
|
|
29
|
-
* which is `(^\S)|(^$)`
|
|
29
|
+
* which is `(^\S)|(^$)` - "starts with a non-space, or is empty". Measured on `" Alex "` the
|
|
30
30
|
* document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
|
|
31
31
|
* intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
|
|
32
32
|
*
|
|
33
|
-
* A pattern that means to match the whole string says so in the spec
|
|
33
|
+
* A pattern that means to match the whole string says so in the spec - `^\S(?:[\s\S]*\S)?$`, which
|
|
34
34
|
* is the standard construct for it. That way the document and the validator carry
|
|
35
35
|
* the identical expression, and the conformance differential can assert it character for character
|
|
36
36
|
* instead of guessing at an equivalence.
|
package/dist/src/constraints.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
2
2
|
/**
|
|
3
|
-
* Constraint decorators
|
|
3
|
+
* Constraint decorators -> the Zod modifiers that enforce them.
|
|
4
4
|
*
|
|
5
|
-
* These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`,
|
|
5
|
+
* These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, ...), which matters twice
|
|
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
|
-
*
|
|
9
|
+
* **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint -
|
|
10
10
|
* it is an arbitrary predicate, which a type language has no way to say and therefore no document
|
|
11
11
|
* can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
|
|
12
12
|
* other one and is gone: `.trim()` is a transform, and transforming a value before validating it
|
|
@@ -30,7 +30,7 @@ export function applyConstraints(program, expression, target) {
|
|
|
30
30
|
result += `.min(${min})`;
|
|
31
31
|
if (max !== undefined)
|
|
32
32
|
result += `.max(${max})`;
|
|
33
|
-
// Exclusive bounds are a distinct question from inclusive ones
|
|
33
|
+
// Exclusive bounds are a distinct question from inclusive ones - `z.number().positive()` is
|
|
34
34
|
// `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
|
|
35
35
|
const minExclusive = getMinValueExclusive(program, target);
|
|
36
36
|
const maxExclusive = getMaxValueExclusive(program, target);
|
|
@@ -43,9 +43,9 @@ export function applyConstraints(program, expression, target) {
|
|
|
43
43
|
return result;
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
|
-
* A `@pattern` string becomes a regex literal
|
|
46
|
+
* A `@pattern` string becomes a regex literal - **verbatim**.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
48
|
+
* **This used to add anchors, and that was wrong twice over.**
|
|
49
49
|
*
|
|
50
50
|
* The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
|
|
51
51
|
* JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
|
|
@@ -54,11 +54,11 @@ export function applyConstraints(program, expression, target) {
|
|
|
54
54
|
*
|
|
55
55
|
* Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
|
|
56
56
|
* alternation changed meaning rather than being constrained: the spec's `\S|^$` became `^\S|^$`,
|
|
57
|
-
* which is `(^\S)|(^$)`
|
|
57
|
+
* which is `(^\S)|(^$)` - "starts with a non-space, or is empty". Measured on `" Alex "` the
|
|
58
58
|
* document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
|
|
59
59
|
* intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
|
|
60
60
|
*
|
|
61
|
-
* A pattern that means to match the whole string says so in the spec
|
|
61
|
+
* A pattern that means to match the whole string says so in the spec - `^\S(?:[\s\S]*\S)?$`, which
|
|
62
62
|
* is the standard construct for it. That way the document and the validator carry
|
|
63
63
|
* the identical expression, and the conformance differential can assert it character for character
|
|
64
64
|
* instead of guessing at an equivalence.
|
package/dist/src/emitter.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { EmitContext } from "@typespec/compiler";
|
|
|
2
2
|
/**
|
|
3
3
|
* This library's own emitter entry point.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* **It may import `./api.js`, `./lib.js` and external packages, and nothing else - asserted, not
|
|
6
6
|
* intended.** An emitter built on this library is written exactly like this one, so "the public API
|
|
7
7
|
* is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
|
|
8
8
|
* this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
|
package/dist/src/emitter.js
CHANGED
|
@@ -2,7 +2,7 @@ import { emitHttpZod } from "./api.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* This library's own emitter entry point.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* **It may import `./api.js`, `./lib.js` and external packages, and nothing else - asserted, not
|
|
6
6
|
* intended.** An emitter built on this library is written exactly like this one, so "the public API
|
|
7
7
|
* is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
|
|
8
8
|
* this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The package's entry point: the public API, and the emitter written against it.
|
|
3
3
|
*
|
|
4
|
-
* Split in two deliberately. `api.ts` is everything a consumer may use
|
|
5
|
-
* which is what `typespec-hono` is
|
|
4
|
+
* Split in two deliberately. `api.ts` is everything a consumer may use - including another emitter,
|
|
5
|
+
* which is what `typespec-hono` is - and `emitter.ts` is this package's own `$onEmit`, holding the
|
|
6
6
|
* same position any consumer's would. Keeping them apart is what lets a test assert that the second
|
|
7
7
|
* reaches only into the first.
|
|
8
8
|
*/
|
package/dist/src/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The package's entry point: the public API, and the emitter written against it.
|
|
3
3
|
*
|
|
4
|
-
* Split in two deliberately. `api.ts` is everything a consumer may use
|
|
5
|
-
* which is what `typespec-hono` is
|
|
4
|
+
* Split in two deliberately. `api.ts` is everything a consumer may use - including another emitter,
|
|
5
|
+
* which is what `typespec-hono` is - and `emitter.ts` is this package's own `$onEmit`, holding the
|
|
6
6
|
* same position any consumer's would. Keeping them apart is what lets a test assert that the second
|
|
7
7
|
* reaches only into the first.
|
|
8
8
|
*/
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -13,16 +13,16 @@ export interface EmitterOptions {
|
|
|
13
13
|
* importing the server**. Resolving one from the other with `../../..` would encode a particular
|
|
14
14
|
* layout in the emitter, which is the sort of thing that breaks silently when a package moves.
|
|
15
15
|
*
|
|
16
|
-
* Omitted, the contract types are simply not emitted
|
|
16
|
+
* Omitted, the contract types are simply not emitted - the emitter stays usable in a project that
|
|
17
17
|
* wants validators and nothing else.
|
|
18
18
|
*/
|
|
19
19
|
"contracts-output-dir"?: string;
|
|
20
20
|
/**
|
|
21
21
|
* The package specifier the emitted Zod and wire assertions import their shared types from.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
23
|
+
* **There is deliberately no default.** It used to default to one repository's own package
|
|
24
24
|
* name, so a consumer who configured nothing got validators importing from a package they had
|
|
25
|
-
* never heard of
|
|
25
|
+
* never heard of - output that looks right and does not resolve. There is no honest default for
|
|
26
26
|
* "the caller's own package", so the absence is the answer: with no package named, an enum is
|
|
27
27
|
* emitted inline and the output depends on nothing.
|
|
28
28
|
*/
|
|
@@ -31,10 +31,10 @@ export interface EmitterOptions {
|
|
|
31
31
|
* Whether a model closed in the spec becomes a validator that REJECTS an undeclared property,
|
|
32
32
|
* rather than one that strips it.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
34
|
+
* **Set this to whatever `@typespec/openapi3`'s `seal-object-schemas` is set to.** The two
|
|
35
35
|
* emitters answer the same question about the same models, and this one cannot read the other's
|
|
36
36
|
* configuration. Left mismatched, the published document and the runtime disagree about whether a
|
|
37
|
-
* property may exist
|
|
37
|
+
* property may exist - and the direction matters: sealing here while the document stays silent
|
|
38
38
|
* gives callers a `400` on a payload the contract permits.
|
|
39
39
|
*
|
|
40
40
|
* Defaults to `false`, which is openapi3's own default, so the two agree until somebody changes
|
|
@@ -45,14 +45,14 @@ export interface EmitterOptions {
|
|
|
45
45
|
/**
|
|
46
46
|
* Models whose PROPERTY NAMES are also emitted as a runtime tuple.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
48
|
+
* **For a key set that is a contract fact but cannot be a `Record` key type.** TypeSpec has no
|
|
49
49
|
* key-constrained `Record` and OpenAPI's `propertyNames` is unreachable from it, so a closed set
|
|
50
|
-
* of attribute names is stated by naming the properties
|
|
50
|
+
* of attribute names is stated by naming the properties - which publishes the constraint, and
|
|
51
51
|
* leaves a consumer that needs the set at RUN time with nothing to iterate, because a generated
|
|
52
52
|
* type is not a value. Naming the model here writes the keys beside the types as well.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
* schema, states nothing in the document and enforces nothing
|
|
54
|
+
* **An option rather than a decorator, and the distinction is the whole point.** It changes no
|
|
55
|
+
* schema, states nothing in the document and enforces nothing - it selects an output. A mark in
|
|
56
56
|
* the spec that no document carries is what got this package's last four decorators deleted; see
|
|
57
57
|
* `tsp-index.ts`.
|
|
58
58
|
*
|
|
@@ -64,10 +64,10 @@ export interface EmitterOptions {
|
|
|
64
64
|
* The specifier the generated files import their runtime contract from. Defaults to
|
|
65
65
|
* `typespec-http-zod/runtime`.
|
|
66
66
|
*
|
|
67
|
-
*
|
|
67
|
+
* **This is here because the emitted response arms are annotated, and the annotation earns its
|
|
68
68
|
* keep.** `schemas.gen.ts` declares what each operation may answer with as
|
|
69
|
-
*
|
|
70
|
-
* import at all, and catches a wrong `status`
|
|
69
|
+
* `... satisfies readonly ResponseArm[]`. Measured against the alternative: `as const` needs no
|
|
70
|
+
* import at all, and catches a wrong `status` - but it is **blind** to a misspelled or omitted
|
|
71
71
|
* `schema`, because `schema: undefined` is legitimate for a bodyless response. The typo then
|
|
72
72
|
* produces a valid-looking arm that validates nothing, which is the failure this package exists to
|
|
73
73
|
* prevent. `satisfies` reports both, at the declaration, naming the key.
|
|
@@ -81,7 +81,7 @@ export interface EmitterOptions {
|
|
|
81
81
|
/**
|
|
82
82
|
* Per-`@service` overrides, keyed by the service namespace name.
|
|
83
83
|
*
|
|
84
|
-
* Two surfaces in one spec do not have to share a destination
|
|
84
|
+
* Two surfaces in one spec do not have to share a destination - an internal API and a public one
|
|
85
85
|
* typically publish to different packages, and the public one is built against by people who must
|
|
86
86
|
* never receive the internal shapes. Falling back to the top-level values keeps a single-service
|
|
87
87
|
* project configured exactly as if this key did not exist.
|
|
@@ -96,16 +96,13 @@ export interface EmitterOptions {
|
|
|
96
96
|
}>;
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
|
-
*
|
|
99
|
+
* **Exported, because an emitter built on this library must DERIVE its option schema from this
|
|
100
100
|
* one rather than restate it.** A server emitter adds its own keys and forwards the rest; two schemas
|
|
101
|
-
* that have to agree are two lists that will not, and the failure is silent
|
|
101
|
+
* that have to agree are two lists that will not, and the failure is silent - an option the consumer
|
|
102
102
|
* sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
|
|
103
103
|
*/
|
|
104
104
|
export declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
105
105
|
export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
106
|
-
"unsupported-scalar": {
|
|
107
|
-
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
|
|
108
|
-
};
|
|
109
106
|
"unsupported-type": {
|
|
110
107
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
111
108
|
};
|
|
@@ -118,9 +115,6 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
118
115
|
"empty-union": {
|
|
119
116
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
120
117
|
};
|
|
121
|
-
"circular-model": {
|
|
122
|
-
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
123
|
-
};
|
|
124
118
|
"unknown-key-vocabulary": {
|
|
125
119
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
126
120
|
};
|
|
@@ -128,10 +122,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
128
122
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
129
123
|
};
|
|
130
124
|
}, EmitterOptions, never>;
|
|
131
|
-
export declare const reportDiagnostic: <C extends "
|
|
132
|
-
"unsupported-scalar": {
|
|
133
|
-
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
|
|
134
|
-
};
|
|
125
|
+
export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
135
126
|
"unsupported-type": {
|
|
136
127
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
137
128
|
};
|
|
@@ -144,9 +135,6 @@ export declare const reportDiagnostic: <C extends "circular-model" | "empty-unio
|
|
|
144
135
|
"empty-union": {
|
|
145
136
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
146
137
|
};
|
|
147
|
-
"circular-model": {
|
|
148
|
-
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
149
|
-
};
|
|
150
138
|
"unknown-key-vocabulary": {
|
|
151
139
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
152
140
|
};
|
|
@@ -154,9 +142,6 @@ export declare const reportDiagnostic: <C extends "circular-model" | "empty-unio
|
|
|
154
142
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
155
143
|
};
|
|
156
144
|
}[C]>(program: import("@typespec/compiler").Program, diag: import("@typespec/compiler").DiagnosticReport<{
|
|
157
|
-
"unsupported-scalar": {
|
|
158
|
-
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
|
|
159
|
-
};
|
|
160
145
|
"unsupported-type": {
|
|
161
146
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
162
147
|
};
|
|
@@ -169,9 +154,6 @@ export declare const reportDiagnostic: <C extends "circular-model" | "empty-unio
|
|
|
169
154
|
"empty-union": {
|
|
170
155
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
171
156
|
};
|
|
172
|
-
"circular-model": {
|
|
173
|
-
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
174
|
-
};
|
|
175
157
|
"unknown-key-vocabulary": {
|
|
176
158
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
177
159
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* **Exported, because an emitter built on this library must DERIVE its option schema from this
|
|
4
4
|
* one rather than restate it.** A server emitter adds its own keys and forwards the rest; two schemas
|
|
5
|
-
* that have to agree are two lists that will not, and the failure is silent
|
|
5
|
+
* that have to agree are two lists that will not, and the failure is silent - an option the consumer
|
|
6
6
|
* sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
|
|
7
7
|
*/
|
|
8
8
|
export const EmitterOptionsSchema = {
|
|
@@ -38,16 +38,16 @@ export const EmitterOptionsSchema = {
|
|
|
38
38
|
/**
|
|
39
39
|
* What this emitter refuses, and why each refusal is a **diagnostic** rather than an exception.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
41
|
+
* **A thrown error abandons the compile and names no source.** Every refusal here was once
|
|
42
42
|
* `throw new UnsupportedTypeError(...)`, which reaches the user as
|
|
43
|
-
* `Emitter "
|
|
43
|
+
* `Emitter "..." crashed! This is a bug. Please contact library author` - wrong on both counts when the
|
|
44
44
|
* spec asked for something the emitter has decided not to represent. Measured across a 65-scenario
|
|
45
45
|
* corpus, four scenarios failed and a deliberate refusal (`never` has no runtime representation), a
|
|
46
46
|
* design limit and a genuine stack overflow were **indistinguishable**: all three arrived as the same
|
|
47
47
|
* "crashed" banner.
|
|
48
48
|
*
|
|
49
49
|
* A diagnostic points at the offending declaration, carries a code somebody can search for and
|
|
50
|
-
* suppress, and
|
|
50
|
+
* suppress, and - because reporting does not unwind - lets the walk continue and report *every*
|
|
51
51
|
* problem in one compile rather than the first one it meets.
|
|
52
52
|
*
|
|
53
53
|
* **Internal invariant violations stay exceptions**, deliberately: `noteExternalImport` called
|
|
@@ -55,12 +55,13 @@ export const EmitterOptionsSchema = {
|
|
|
55
55
|
* suppressible by the person writing the spec.
|
|
56
56
|
*/
|
|
57
57
|
const diagnostics = {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
/*
|
|
59
|
+
* `unsupported-scalar` was declared here and has been RETIRED. A scalar with no known base is not
|
|
60
|
+
* unrepresentable: `@typespec/openapi3` publishes `scalar Mystery;` as the empty schema `{}`, which
|
|
61
|
+
* asserts nothing and therefore accepts everything. Refusing it emitted `z.never()`, the exact
|
|
62
|
+
* inversion - measured, `"hello"`, `42` and `null` all rejected. Both walks now emit the faithful
|
|
63
|
+
* reading, `z.unknown()` and `unknown`, and openapi3 raises no diagnostic so neither does this.
|
|
64
|
+
*/
|
|
64
65
|
"unsupported-type": {
|
|
65
66
|
severity: "error",
|
|
66
67
|
messages: {
|
|
@@ -70,37 +71,37 @@ const diagnostics = {
|
|
|
70
71
|
"unsupported-default": {
|
|
71
72
|
severity: "error",
|
|
72
73
|
messages: {
|
|
73
|
-
default: paramMessage `Cannot emit a default value of ${"why"}.
|
|
74
|
+
default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. The property keeps its declared shape and loses only the fallback.`,
|
|
74
75
|
},
|
|
75
76
|
},
|
|
76
77
|
/**
|
|
77
|
-
*
|
|
78
|
+
* **A discriminated union whose discriminator the document never publishes.**
|
|
78
79
|
*
|
|
79
|
-
* `@discriminated(#{envelope: "none"})` puts the discriminator inside the variant on the wire
|
|
80
|
-
* `{kind: "cat", ...Cat}`
|
|
80
|
+
* `@discriminated(#{envelope: "none"})` puts the discriminator inside the variant on the wire -
|
|
81
|
+
* `{kind: "cat", ...Cat}` - and `@typespec/openapi3` emits `oneOf: [Cat, Dog]` with a
|
|
81
82
|
* `discriminator` keyword while never adding that property to the variant schema. OpenAPI 3.1
|
|
82
83
|
* forbids exactly this: "the `discriminator` field MUST be a required field". So the published
|
|
83
84
|
* contract cannot require the property, and under `seal-object-schemas` it forbids it outright.
|
|
84
85
|
*
|
|
85
|
-
*
|
|
86
|
+
* **Confirmed upstream, not inferred here:** `microsoft/typespec#7141`, open since 2025-04-27,
|
|
86
87
|
* labelled `[Bug]` / `design:needed` / `emitter:openapi3` and filed by a maintainer against these
|
|
87
88
|
* exact shapes. Nothing here needs raising; the refusal stands until that lands.
|
|
88
89
|
*
|
|
89
|
-
*
|
|
90
|
-
* emitter did briefly inject the discriminator itself
|
|
90
|
+
* **Refused rather than compensated for, and that distinction is the whole point.** This
|
|
91
|
+
* emitter did briefly inject the discriminator itself - the validator was right, the document was
|
|
91
92
|
* wrong, and it was recorded as a "declared divergence" with a warning and a committed list. That
|
|
92
93
|
* is a custom track with a label on it: runtime behaviour derived from nothing the document
|
|
93
94
|
* states. A validator may only enforce what the contract says.
|
|
94
95
|
*
|
|
95
|
-
* **It is avoidable, and the message says how.** Declaring the discriminator on the variant
|
|
96
|
-
* `model Cat { kind: "cat";
|
|
96
|
+
* **It is avoidable, and the message says how.** Declaring the discriminator on the variant -
|
|
97
|
+
* `model Cat { kind: "cat"; ... }` - makes openapi3 publish it as required, and everything works
|
|
97
98
|
* with no divergence and no refusal. The refusal lands only on the spelling that cannot produce
|
|
98
99
|
* an honest contract.
|
|
99
100
|
*/
|
|
100
101
|
"undeclared-discriminator": {
|
|
101
102
|
severity: "error",
|
|
102
103
|
messages: {
|
|
103
|
-
default: paramMessage `'${"name"}' serialises with '${"property"}' inside each variant, but '${"variant"}' does not declare it
|
|
104
|
+
default: paramMessage `'${"name"}' serialises with '${"property"}' inside each variant, but '${"variant"}' does not declare it - so the OpenAPI document omits it, which OpenAPI 3.1 forbids for a discriminator ("the discriminator field MUST be a required field"). Emitting a validator that required it anyway would enforce a rule the published contract does not state. Declare '${"property"}' on '${"variant"}'.`,
|
|
104
105
|
},
|
|
105
106
|
},
|
|
106
107
|
"empty-union": {
|
|
@@ -109,27 +110,18 @@ const diagnostics = {
|
|
|
109
110
|
default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against.`,
|
|
110
111
|
},
|
|
111
112
|
},
|
|
112
|
-
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* Reported rather than thrown so the walk names every such cycle instead of dying inside the
|
|
120
|
-
* first — and, before the getters existed, so an inline cycle produced this rather than a
|
|
121
|
-
* `RangeError: Maximum call stack size exceeded`.
|
|
113
|
+
/*
|
|
114
|
+
* `circular-model` was declared here and has been RETIRED, because it refused something that can
|
|
115
|
+
* be represented. It fired on a cycle whose path holds no object property - a union variant or a
|
|
116
|
+
* dictionary value - on the grounds that a getter needs a property to sit on. True, and beside the
|
|
117
|
+
* point: `z.lazy()` needs no property, and deferring the whole declaration is where the laziness
|
|
118
|
+
* goes once nothing smaller can hold it. See `registry.ts` for the measurements, including why the
|
|
119
|
+
* fix needs a structural type and an annotation rather than `z.lazy()` alone.
|
|
122
120
|
*/
|
|
123
|
-
"circular-model": {
|
|
124
|
-
severity: "error",
|
|
125
|
-
messages: {
|
|
126
|
-
default: paramMessage `'${"name"}' closes a cycle through a declaration that cannot defer it — a union variant or a dictionary value, neither of which is a property a getter can sit on, so the emitted schema would read itself while initialising. Route the cycle through a model property, or break it in the spec.`,
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
121
|
/**
|
|
130
122
|
* A `key-vocabularies` entry naming no model in the service.
|
|
131
123
|
*
|
|
132
|
-
*
|
|
124
|
+
* **Reported rather than ignored, because the failure mode is silence.** The option exists so a
|
|
133
125
|
* consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
|
|
134
126
|
* vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
|
|
135
127
|
*/
|
|
@@ -142,19 +134,19 @@ const diagnostics = {
|
|
|
142
134
|
/**
|
|
143
135
|
* A status RANGE that OpenAPI cannot key.
|
|
144
136
|
*
|
|
145
|
-
*
|
|
137
|
+
* **This replaced `mixed-error-bodies`, which was a different refusal for the same scenario and
|
|
146
138
|
* the wrong diagnosis of it.** That one said "a service must answer every failure with one body
|
|
147
|
-
* shape"
|
|
139
|
+
* shape" - an assumption this emitter no longer makes, since each declared response resolves its
|
|
148
140
|
* own body. What is genuinely unrepresentable is narrower and is not ours: OpenAPI keys a range as
|
|
149
|
-
* `1XX
|
|
141
|
+
* `1XX`...`5XX`, so `@minValue(494) @maxValue(499)` has nowhere to go.
|
|
150
142
|
*
|
|
151
|
-
*
|
|
143
|
+
* **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its
|
|
152
144
|
* `rangeToOpenAPI`. Bucketing `494-499` into `4XX` anyway would make a server claim a `400`
|
|
153
|
-
* carries that body
|
|
145
|
+
* carries that body - a rule no document states, because openapi3 declines to publish a document
|
|
154
146
|
* for this spec at all. The same source is then either representable by both emitters or neither,
|
|
155
147
|
* which is the only arrangement under which a differential between them means anything.
|
|
156
148
|
*
|
|
157
|
-
* A range that covers a bucket exactly
|
|
149
|
+
* A range that covers a bucket exactly - `@minValue(400) @maxValue(499)` - is supported and emits
|
|
158
150
|
* a `4XX` arm.
|
|
159
151
|
*/
|
|
160
152
|
"unsupported-status-code-range": {
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,14 +1,64 @@
|
|
|
1
1
|
import type { ModelProperty, Program, Type } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
|
|
4
|
+
*
|
|
5
|
+
* Without this, a model used by twelve operations is inlined twelve times: the generated file grows
|
|
6
|
+
* quadratically, and - worse - the *type* it infers is a fresh structural type at each site, so
|
|
7
|
+
* `apps/web` gets twelve `Company`s that are only accidentally the same. One declaration per model
|
|
8
|
+
* is what makes the emitted types usable as a shared vocabulary rather than as twelve coincidences.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* **A cycle with no property on its path - and it used to be refused.**
|
|
12
|
+
*
|
|
13
|
+
* **A recursive model is NOT this.** `model InnerModel { children?: InnerModel[] }` is an ordinary
|
|
14
|
+
* tree - `@typespec/openapi3` publishes a self-`$ref` for it and the document is valid - so it is
|
|
15
|
+
* emitted as a reference, made lazy by a getter on the property that closes the loop (see
|
|
16
|
+
* `modelToZod`). What this handles is a cycle with no property to hang a getter on: a named **union**
|
|
17
|
+
* whose variant refers back to a model still being rendered, or a dictionary VALUE that does.
|
|
18
|
+
*
|
|
19
|
+
* **`circular-model` claimed this was unrepresentable, and it was merely unimplemented.** Measured
|
|
20
|
+
* on Zod 4.4.3, `z.lazy()` closes every one of these at run time - a union-variant cycle and a
|
|
21
|
+
* dictionary-value cycle both parse nested values and, importantly, still **reject at depth**, so the
|
|
22
|
+
* schema has not quietly degraded to something that accepts anything.
|
|
23
|
+
*
|
|
24
|
+
* **But `z.lazy()` alone does not TYPECHECK, and that is the whole difficulty.** Measured with
|
|
25
|
+
* `tsc` under `strict`:
|
|
26
|
+
*
|
|
27
|
+
* ```
|
|
28
|
+
* TS7022: 'branchSchema' implicitly has type 'any' because it does not have a type annotation
|
|
29
|
+
* and is referenced directly or indirectly in its own initializer.
|
|
30
|
+
* TS7022: 'nodeSchema' implicitly has type 'any' ... <- it poisons the sibling too
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Inferring `any` is worse than failing to compile, because `wire-contract.gen.ts` asserts
|
|
34
|
+
* `Identical<z.infer<typeof X>, Contracts.X>` - against `any` that assertion PASSES while proving
|
|
35
|
+
* nothing, which is the one outcome this package treats as worse than a red test.
|
|
36
|
+
*
|
|
37
|
+
* The fix is the recipe Zod documents: give the deferred declaration an explicit `z.ZodType<T>` and
|
|
38
|
+
* declare `T` structurally. Measured, `tsc` exit 0 with the identity assertion still holding.
|
|
39
|
+
*
|
|
40
|
+
* **EVERY member of the cycle needs its structural type, not only the deferred one.** Leaving the
|
|
41
|
+
* others as `export type X = z.infer<typeof xSchema>` reintroduces the loop through the annotation -
|
|
42
|
+
* measured: `TS2456: Type alias 'Branch' circularly references itself` plus `TS2502` and `TS7022`. So
|
|
43
|
+
* cycle membership, not merely deferral, decides which declarations are emitted structurally.
|
|
44
|
+
*/
|
|
2
45
|
interface Declaration {
|
|
3
46
|
readonly identifier: string;
|
|
4
47
|
readonly typeName: string;
|
|
5
48
|
readonly source: string;
|
|
49
|
+
/**
|
|
50
|
+
* The structural TypeScript body for a declaration on a cycle, emitted INSTEAD of the
|
|
51
|
+
* `z.infer<typeof ...>` alias - which cannot be used here without closing the type-level loop.
|
|
52
|
+
*/
|
|
53
|
+
readonly structural?: string;
|
|
54
|
+
/** Whether the declaration is annotated `: z.ZodType<typeName>`, which `z.lazy()` requires. */
|
|
55
|
+
readonly annotated?: boolean;
|
|
6
56
|
}
|
|
7
57
|
export declare class SchemaRegistry {
|
|
8
58
|
#private;
|
|
9
59
|
constructor(program: Program);
|
|
10
60
|
/**
|
|
11
|
-
* The Zod expression for a type
|
|
61
|
+
* The Zod expression for a type - a bare identifier once the type has a declaration, otherwise
|
|
12
62
|
* inline source. Declaring is depth-first so a model's dependencies are emitted above it, which is
|
|
13
63
|
* what lets the generated file be plain `const` declarations with no forward references.
|
|
14
64
|
*/
|
|
@@ -16,7 +66,7 @@ export declare class SchemaRegistry {
|
|
|
16
66
|
/**
|
|
17
67
|
* The Zod for a single property, constraints and optionality included.
|
|
18
68
|
*
|
|
19
|
-
* Used for path/query parameters, which are `ModelProperty`s that belong to no model
|
|
69
|
+
* Used for path/query parameters, which are `ModelProperty`s that belong to no model - so they
|
|
20
70
|
* need the property-level walk rather than the type-level one, or `@minLength` on a path param
|
|
21
71
|
* would be documented and never enforced.
|
|
22
72
|
*/
|
|
@@ -24,14 +74,14 @@ export declare class SchemaRegistry {
|
|
|
24
74
|
/**
|
|
25
75
|
* A declaration for a shape the spec never wrote, under a name the document publishes.
|
|
26
76
|
*
|
|
27
|
-
* `@discriminated` with an envelope has no TypeSpec type for its wrapper
|
|
77
|
+
* `@discriminated` with an envelope has no TypeSpec type for its wrapper - openapi3 synthesises
|
|
28
78
|
* one and publishes it as `PetWithEnvelopeCat`, so there is a component with no `Type` to key on.
|
|
29
79
|
* Keyed by name instead, and pushed in the same dependency-first order as everything else, so the
|
|
30
80
|
* envelope declarations land above the union that references them.
|
|
31
81
|
*
|
|
32
82
|
* `declareNamed` exposes the same seam to the route emitter, which needs it for a **multipart**
|
|
33
83
|
* body: the document publishes `MultiPartRequest` as a component, and there is no walkable type
|
|
34
|
-
* for it
|
|
84
|
+
* for it - the parts have to be assembled from `@typespec/http`'s own view of the payload.
|
|
35
85
|
*/
|
|
36
86
|
declareNamed(name: string, build: () => string): string;
|
|
37
87
|
/** Every declaration, dependencies first. */
|
|
@@ -41,7 +91,7 @@ export interface TsDeclaration {
|
|
|
41
91
|
readonly name: string;
|
|
42
92
|
/** `true` when the body is an object literal and can be emitted as an `interface`. */
|
|
43
93
|
readonly isObject: boolean;
|
|
44
|
-
/** `true` for an enum-derived alias
|
|
94
|
+
/** `true` for an enum-derived alias - declared but not exported, see below. */
|
|
45
95
|
readonly isVocabulary: boolean;
|
|
46
96
|
readonly source: string;
|
|
47
97
|
}
|
|
@@ -49,7 +99,7 @@ export interface TsDeclaration {
|
|
|
49
99
|
* The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
|
|
50
100
|
*
|
|
51
101
|
* Kept as a separate class rather than a mode on the schema registry because the two produce
|
|
52
|
-
* different *kinds* of declaration
|
|
102
|
+
* different *kinds* of declaration - an `interface` versus a `const` - and their reference resolvers
|
|
53
103
|
* hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
|
|
54
104
|
* one artefact is named the same way in the other, so the emitted assertion can pair them by name.
|
|
55
105
|
*/
|
|
@@ -58,11 +108,12 @@ export declare class TypeRegistry {
|
|
|
58
108
|
constructor(program: Program);
|
|
59
109
|
expressionFor(type: Type): string;
|
|
60
110
|
/**
|
|
61
|
-
* The Zod-free type for a single property
|
|
111
|
+
* The Zod-free type for a single property - used for path and query parameters, which are
|
|
62
112
|
* `ModelProperty`s belonging to no model. Without routing them through the registry a named model
|
|
63
113
|
* or enum in a parameter position is inlined rather than referenced.
|
|
64
114
|
*/
|
|
65
|
-
|
|
115
|
+
/** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
|
|
116
|
+
expressionForProperty(property: ModelProperty, wireName?: string): string;
|
|
66
117
|
/** Every declaration, dependencies first. */
|
|
67
118
|
declarations(): readonly TsDeclaration[];
|
|
68
119
|
}
|