typespec-http-zod 0.1.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/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/src/api.d.ts +304 -0
- package/dist/src/api.js +1425 -0
- package/dist/src/constraints.d.ts +38 -0
- package/dist/src/constraints.js +68 -0
- package/dist/src/emitter.d.ts +13 -0
- package/dist/src/emitter.js +15 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/lib.d.ts +181 -0
- package/dist/src/lib.js +172 -0
- package/dist/src/registry.d.ts +69 -0
- package/dist/src/registry.js +421 -0
- package/dist/src/runtime.d.ts +84 -0
- package/dist/src/runtime.js +25 -0
- package/dist/src/streams.d.ts +40 -0
- package/dist/src/streams.js +40 -0
- package/dist/src/tsp-index.d.ts +12 -0
- package/dist/src/tsp-index.js +12 -0
- package/dist/src/types.d.ts +10 -0
- package/dist/src/types.js +254 -0
- package/dist/src/versioning.d.ts +58 -0
- package/dist/src/versioning.js +57 -0
- package/dist/src/zod.d.ts +124 -0
- package/dist/src/zod.js +1015 -0
- package/lib/main.tsp +4 -0
- package/package.json +94 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Program, type Type } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* Constraint decorators → the Zod modifiers that enforce them.
|
|
4
|
+
*
|
|
5
|
+
* These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, …), which matters twice
|
|
6
|
+
* over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
|
|
7
|
+
* documents them without any help from us.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint —
|
|
10
|
+
* it is an arbitrary predicate, which a type language has no way to say and therefore no document
|
|
11
|
+
* can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
|
|
12
|
+
* other one and is gone: `.trim()` is a transform, and transforming a value before validating it
|
|
13
|
+
* makes the runtime accept inputs the published contract rejects. The spec states `@pattern`
|
|
14
|
+
* instead, which openapi3 publishes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyConstraints(program: Program, expression: string, target: Type): string;
|
|
17
|
+
/**
|
|
18
|
+
* A `@pattern` string becomes a regex literal — **verbatim**.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **This used to add anchors, and that was wrong twice over.**
|
|
21
|
+
*
|
|
22
|
+
* The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
|
|
23
|
+
* JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
|
|
24
|
+
* anchored"* (2020-12 validation, `pattern`). So the document published one expression and the
|
|
25
|
+
* validator enforced another, and the caller who read the document was the one who got the 400.
|
|
26
|
+
*
|
|
27
|
+
* Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
|
|
28
|
+
* alternation changed meaning rather than being constrained: the spec's `\S|^$` became `^\S|^$`,
|
|
29
|
+
* which is `(^\S)|(^$)` — "starts with a non-space, or is empty". Measured on `" Alex "` the
|
|
30
|
+
* document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
|
|
31
|
+
* intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
|
|
32
|
+
*
|
|
33
|
+
* A pattern that means to match the whole string says so in the spec — `^\S(?:[\s\S]*\S)?$`, which
|
|
34
|
+
* is the standard construct for it. That way the document and the validator carry
|
|
35
|
+
* the identical expression, and the conformance differential can assert it character for character
|
|
36
|
+
* instead of guessing at an equivalence.
|
|
37
|
+
*/
|
|
38
|
+
export declare function patternToRegex(pattern: string): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* Constraint decorators → the Zod modifiers that enforce them.
|
|
4
|
+
*
|
|
5
|
+
* These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, …), which matters twice
|
|
6
|
+
* over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
|
|
7
|
+
* documents them without any help from us.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint —
|
|
10
|
+
* it is an arbitrary predicate, which a type language has no way to say and therefore no document
|
|
11
|
+
* can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
|
|
12
|
+
* other one and is gone: `.trim()` is a transform, and transforming a value before validating it
|
|
13
|
+
* makes the runtime accept inputs the published contract rejects. The spec states `@pattern`
|
|
14
|
+
* instead, which openapi3 publishes.
|
|
15
|
+
*/
|
|
16
|
+
export function applyConstraints(program, expression, target) {
|
|
17
|
+
let result = expression;
|
|
18
|
+
const minLength = getMinLength(program, target);
|
|
19
|
+
const maxLength = getMaxLength(program, target);
|
|
20
|
+
const minValue = getMinValue(program, target);
|
|
21
|
+
const maxValue = getMaxValue(program, target);
|
|
22
|
+
const minItems = getMinItems(program, target);
|
|
23
|
+
const maxItems = getMaxItems(program, target);
|
|
24
|
+
const pattern = getPattern(program, target);
|
|
25
|
+
// `@minLength`/`@minValue`/`@minItems` all land on Zod's `.min()`; they are distinguished by the
|
|
26
|
+
// type they are legal on, which TypeSpec has already checked by the time we get here.
|
|
27
|
+
const min = minLength ?? minValue ?? minItems;
|
|
28
|
+
const max = maxLength ?? maxValue ?? maxItems;
|
|
29
|
+
if (min !== undefined)
|
|
30
|
+
result += `.min(${min})`;
|
|
31
|
+
if (max !== undefined)
|
|
32
|
+
result += `.max(${max})`;
|
|
33
|
+
// Exclusive bounds are a distinct question from inclusive ones — `z.number().positive()` is
|
|
34
|
+
// `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
|
|
35
|
+
const minExclusive = getMinValueExclusive(program, target);
|
|
36
|
+
const maxExclusive = getMaxValueExclusive(program, target);
|
|
37
|
+
if (minExclusive !== undefined)
|
|
38
|
+
result += `.gt(${minExclusive})`;
|
|
39
|
+
if (maxExclusive !== undefined)
|
|
40
|
+
result += `.lt(${maxExclusive})`;
|
|
41
|
+
if (pattern !== undefined)
|
|
42
|
+
result += `.regex(${patternToRegex(pattern)})`;
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A `@pattern` string becomes a regex literal — **verbatim**.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ **This used to add anchors, and that was wrong twice over.**
|
|
49
|
+
*
|
|
50
|
+
* The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
|
|
51
|
+
* JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
|
|
52
|
+
* anchored"* (2020-12 validation, `pattern`). So the document published one expression and the
|
|
53
|
+
* validator enforced another, and the caller who read the document was the one who got the 400.
|
|
54
|
+
*
|
|
55
|
+
* Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
|
|
56
|
+
* alternation changed meaning rather than being constrained: the spec's `\S|^$` became `^\S|^$`,
|
|
57
|
+
* which is `(^\S)|(^$)` — "starts with a non-space, or is empty". Measured on `" Alex "` the
|
|
58
|
+
* document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
|
|
59
|
+
* intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
|
|
60
|
+
*
|
|
61
|
+
* A pattern that means to match the whole string says so in the spec — `^\S(?:[\s\S]*\S)?$`, which
|
|
62
|
+
* is the standard construct for it. That way the document and the validator carry
|
|
63
|
+
* the identical expression, and the conformance differential can assert it character for character
|
|
64
|
+
* instead of guessing at an equivalence.
|
|
65
|
+
*/
|
|
66
|
+
export function patternToRegex(pattern) {
|
|
67
|
+
return `/${pattern.replace(/\//g, "\\/")}/`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EmitContext } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* This library's own emitter entry point.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **It may import `./api.js`, `./lib.js` and external packages, and nothing else — asserted, not
|
|
6
|
+
* intended.** An emitter built on this library is written exactly like this one, so "the public API
|
|
7
|
+
* is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
|
|
8
|
+
* this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
|
|
9
|
+
*
|
|
10
|
+
* That it is one line is the point, not a shortcut: everything this package does is reachable
|
|
11
|
+
* through a single published call, so a server generator adds its own file and nothing else.
|
|
12
|
+
*/
|
|
13
|
+
export declare function $onEmit(context: EmitContext): Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { emitHttpZod } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* This library's own emitter entry point.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **It may import `./api.js`, `./lib.js` and external packages, and nothing else — asserted, not
|
|
6
|
+
* intended.** An emitter built on this library is written exactly like this one, so "the public API
|
|
7
|
+
* is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
|
|
8
|
+
* this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
|
|
9
|
+
*
|
|
10
|
+
* That it is one line is the point, not a shortcut: everything this package does is reachable
|
|
11
|
+
* through a single published call, so a server generator adds its own file and nothing else.
|
|
12
|
+
*/
|
|
13
|
+
export async function $onEmit(context) {
|
|
14
|
+
await emitHttpZod(context);
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's entry point: the public API, and the emitter written against it.
|
|
3
|
+
*
|
|
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
|
+
* same position any consumer's would. Keeping them apart is what lets a test assert that the second
|
|
7
|
+
* reaches only into the first.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./api.js";
|
|
10
|
+
export { $onEmit } from "./emitter.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's entry point: the public API, and the emitter written against it.
|
|
3
|
+
*
|
|
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
|
+
* same position any consumer's would. Keeping them apart is what lets a test assert that the second
|
|
7
|
+
* reaches only into the first.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./api.js";
|
|
10
|
+
export { $onEmit } from "./emitter.js";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { type JSONSchemaType } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* The library definition, kept in its own module so `tsp-index.ts` and the emitter can both reach it
|
|
4
|
+
* without importing each other.
|
|
5
|
+
*/
|
|
6
|
+
export interface EmitterOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Where the framework-free contract types go, when they should not sit beside the validators.
|
|
9
|
+
*
|
|
10
|
+
* A second output directory rather than a path relative to `emitter-output-dir`, because the two
|
|
11
|
+
* artefacts often belong to different packages on purpose: the Zod lives beside the server that
|
|
12
|
+
* runs it, and the shared type lives wherever the rest of the codebase can import it **without
|
|
13
|
+
* importing the server**. Resolving one from the other with `../../..` would encode a particular
|
|
14
|
+
* layout in the emitter, which is the sort of thing that breaks silently when a package moves.
|
|
15
|
+
*
|
|
16
|
+
* Omitted, the contract types are simply not emitted — the emitter stays usable in a project that
|
|
17
|
+
* wants validators and nothing else.
|
|
18
|
+
*/
|
|
19
|
+
"contracts-output-dir"?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The package specifier the emitted Zod and wire assertions import their shared types from.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ **There is deliberately no default.** It used to default to one repository's own package
|
|
24
|
+
* name, so a consumer who configured nothing got validators importing from a package they had
|
|
25
|
+
* never heard of — output that looks right and does not resolve. There is no honest default for
|
|
26
|
+
* "the caller's own package", so the absence is the answer: with no package named, an enum is
|
|
27
|
+
* emitted inline and the output depends on nothing.
|
|
28
|
+
*/
|
|
29
|
+
"contracts-package"?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a model closed in the spec becomes a validator that REJECTS an undeclared property,
|
|
32
|
+
* rather than one that strips it.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ **Set this to whatever `@typespec/openapi3`'s `seal-object-schemas` is set to.** The two
|
|
35
|
+
* emitters answer the same question about the same models, and this one cannot read the other's
|
|
36
|
+
* configuration. Left mismatched, the published document and the runtime disagree about whether a
|
|
37
|
+
* property may exist — and the direction matters: sealing here while the document stays silent
|
|
38
|
+
* gives callers a `400` on a payload the contract permits.
|
|
39
|
+
*
|
|
40
|
+
* Defaults to `false`, which is openapi3's own default, so the two agree until somebody changes
|
|
41
|
+
* one of them. A model that says `...Record<never>` is closed either way; that is the spec
|
|
42
|
+
* stating it outright rather than an emitter option deciding.
|
|
43
|
+
*/
|
|
44
|
+
"seal-object-schemas"?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Models whose PROPERTY NAMES are also emitted as a runtime tuple.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ **For a key set that is a contract fact but cannot be a `Record` key type.** TypeSpec has no
|
|
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 — which publishes the constraint, and
|
|
51
|
+
* leaves a consumer that needs the set at RUN time with nothing to iterate, because a generated
|
|
52
|
+
* type is not a value. Naming the model here writes the keys beside the types as well.
|
|
53
|
+
*
|
|
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
|
+
* the spec that no document carries is what got this package's last four decorators deleted; see
|
|
57
|
+
* `tsp-index.ts`.
|
|
58
|
+
*
|
|
59
|
+
* A name matching no model is reported, not ignored: a vocabulary that is silently absent is
|
|
60
|
+
* indistinguishable from one that is empty.
|
|
61
|
+
*/
|
|
62
|
+
"key-vocabularies"?: string[];
|
|
63
|
+
/**
|
|
64
|
+
* The specifier the generated files import their runtime contract from. Defaults to
|
|
65
|
+
* `typespec-http-zod/runtime`.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ **This is here because the emitted response arms are annotated, and the annotation earns its
|
|
68
|
+
* keep.** `schemas.gen.ts` declares what each operation may answer with as
|
|
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
|
+
* `schema`, because `schema: undefined` is legitimate for a bodyless response. The typo then
|
|
72
|
+
* produces a valid-looking arm that validates nothing, which is the failure this package exists to
|
|
73
|
+
* prevent. `satisfies` reports both, at the declaration, naming the key.
|
|
74
|
+
*
|
|
75
|
+
* An emitter built on this library points the default at its own runtime, which re-exports these
|
|
76
|
+
* names; an application that substitutes its own result or context types points it at its own
|
|
77
|
+
* module and re-declares them. That is also what makes the emitted output loadable from this
|
|
78
|
+
* package's own tests, where the package name does not resolve.
|
|
79
|
+
*/
|
|
80
|
+
"runtime-module"?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Per-`@service` overrides, keyed by the service namespace name.
|
|
83
|
+
*
|
|
84
|
+
* Two surfaces in one spec do not have to share a destination — an internal API and a public one
|
|
85
|
+
* typically publish to different packages, and the public one is built against by people who must
|
|
86
|
+
* never receive the internal shapes. Falling back to the top-level values keeps a single-service
|
|
87
|
+
* project configured exactly as if this key did not exist.
|
|
88
|
+
*/
|
|
89
|
+
services?: Record<string, {
|
|
90
|
+
"output-dir"?: string;
|
|
91
|
+
"contracts-output-dir"?: string;
|
|
92
|
+
"contracts-package"?: string;
|
|
93
|
+
"seal-object-schemas"?: boolean;
|
|
94
|
+
"key-vocabularies"?: string[];
|
|
95
|
+
"runtime-module"?: string;
|
|
96
|
+
}>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* ⚠️ **Exported, because an emitter built on this library must DERIVE its option schema from this
|
|
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 — an option the consumer
|
|
102
|
+
* sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
|
|
103
|
+
*/
|
|
104
|
+
export declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
105
|
+
export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
106
|
+
"unsupported-scalar": {
|
|
107
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
|
|
108
|
+
};
|
|
109
|
+
"unsupported-type": {
|
|
110
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
111
|
+
};
|
|
112
|
+
"unsupported-default": {
|
|
113
|
+
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
114
|
+
};
|
|
115
|
+
"undeclared-discriminator": {
|
|
116
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
117
|
+
};
|
|
118
|
+
"empty-union": {
|
|
119
|
+
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
120
|
+
};
|
|
121
|
+
"circular-model": {
|
|
122
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
123
|
+
};
|
|
124
|
+
"unknown-key-vocabulary": {
|
|
125
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
126
|
+
};
|
|
127
|
+
"unsupported-status-code-range": {
|
|
128
|
+
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
129
|
+
};
|
|
130
|
+
}, EmitterOptions, never>;
|
|
131
|
+
export declare const reportDiagnostic: <C extends "circular-model" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-scalar" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
132
|
+
"unsupported-scalar": {
|
|
133
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
|
|
134
|
+
};
|
|
135
|
+
"unsupported-type": {
|
|
136
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
137
|
+
};
|
|
138
|
+
"unsupported-default": {
|
|
139
|
+
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
140
|
+
};
|
|
141
|
+
"undeclared-discriminator": {
|
|
142
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
143
|
+
};
|
|
144
|
+
"empty-union": {
|
|
145
|
+
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
146
|
+
};
|
|
147
|
+
"circular-model": {
|
|
148
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
149
|
+
};
|
|
150
|
+
"unknown-key-vocabulary": {
|
|
151
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
152
|
+
};
|
|
153
|
+
"unsupported-status-code-range": {
|
|
154
|
+
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
155
|
+
};
|
|
156
|
+
}[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
|
+
"unsupported-type": {
|
|
161
|
+
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
|
|
162
|
+
};
|
|
163
|
+
"unsupported-default": {
|
|
164
|
+
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
165
|
+
};
|
|
166
|
+
"undeclared-discriminator": {
|
|
167
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
168
|
+
};
|
|
169
|
+
"empty-union": {
|
|
170
|
+
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
171
|
+
};
|
|
172
|
+
"circular-model": {
|
|
173
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
174
|
+
};
|
|
175
|
+
"unknown-key-vocabulary": {
|
|
176
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
|
|
177
|
+
};
|
|
178
|
+
"unsupported-status-code-range": {
|
|
179
|
+
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
180
|
+
};
|
|
181
|
+
}, C, M>) => void;
|
package/dist/src/lib.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* ⚠️ **Exported, because an emitter built on this library must DERIVE its option schema from this
|
|
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 — an option the consumer
|
|
6
|
+
* sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
|
|
7
|
+
*/
|
|
8
|
+
export const EmitterOptionsSchema = {
|
|
9
|
+
type: "object",
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
"contracts-output-dir": { type: "string", nullable: true },
|
|
13
|
+
"contracts-package": { type: "string", nullable: true },
|
|
14
|
+
"seal-object-schemas": { type: "boolean", nullable: true },
|
|
15
|
+
"key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
|
|
16
|
+
"runtime-module": { type: "string", nullable: true },
|
|
17
|
+
services: {
|
|
18
|
+
type: "object",
|
|
19
|
+
nullable: true,
|
|
20
|
+
additionalProperties: {
|
|
21
|
+
type: "object",
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
properties: {
|
|
24
|
+
"output-dir": { type: "string", nullable: true },
|
|
25
|
+
"contracts-output-dir": { type: "string", nullable: true },
|
|
26
|
+
"contracts-package": { type: "string", nullable: true },
|
|
27
|
+
"seal-object-schemas": { type: "boolean", nullable: true },
|
|
28
|
+
"key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
|
|
29
|
+
"runtime-module": { type: "string", nullable: true },
|
|
30
|
+
},
|
|
31
|
+
required: [],
|
|
32
|
+
},
|
|
33
|
+
required: [],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: [],
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* What this emitter refuses, and why each refusal is a **diagnostic** rather than an exception.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ **A thrown error abandons the compile and names no source.** Every refusal here was once
|
|
42
|
+
* `throw new UnsupportedTypeError(...)`, which reaches the user as
|
|
43
|
+
* `Emitter "…" crashed! This is a bug. Please contact library author` — wrong on both counts when the
|
|
44
|
+
* spec asked for something the emitter has decided not to represent. Measured across a 65-scenario
|
|
45
|
+
* corpus, four scenarios failed and a deliberate refusal (`never` has no runtime representation), a
|
|
46
|
+
* design limit and a genuine stack overflow were **indistinguishable**: all three arrived as the same
|
|
47
|
+
* "crashed" banner.
|
|
48
|
+
*
|
|
49
|
+
* A diagnostic points at the offending declaration, carries a code somebody can search for and
|
|
50
|
+
* suppress, and — because reporting does not unwind — lets the walk continue and report *every*
|
|
51
|
+
* problem in one compile rather than the first one it meets.
|
|
52
|
+
*
|
|
53
|
+
* **Internal invariant violations stay exceptions**, deliberately: `noteExternalImport` called
|
|
54
|
+
* outside its collector is a bug in this library, has no source span to point at, and must not be
|
|
55
|
+
* suppressible by the person writing the spec.
|
|
56
|
+
*/
|
|
57
|
+
const diagnostics = {
|
|
58
|
+
"unsupported-scalar": {
|
|
59
|
+
severity: "error",
|
|
60
|
+
messages: {
|
|
61
|
+
default: paramMessage `Cannot emit ${"artefact"} for scalar '${"name"}': this emitter has no rule for it. Give it a known base with 'extends', or encode it with '@encode'.`,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"unsupported-type": {
|
|
65
|
+
severity: "error",
|
|
66
|
+
messages: {
|
|
67
|
+
default: paramMessage `Cannot emit ${"artefact"} for ${"kind"}: ${"why"}.`,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"unsupported-default": {
|
|
71
|
+
severity: "error",
|
|
72
|
+
messages: {
|
|
73
|
+
default: paramMessage `Cannot emit a default value of ${"why"}. Only scalar defaults and empty collections are representable.`,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
/**
|
|
77
|
+
* ⚠️ **A discriminated union whose discriminator the document never publishes.**
|
|
78
|
+
*
|
|
79
|
+
* `@discriminated(#{envelope: "none"})` puts the discriminator inside the variant on the wire —
|
|
80
|
+
* `{kind: "cat", ...Cat}` — and `@typespec/openapi3` emits `oneOf: [Cat, Dog]` with a
|
|
81
|
+
* `discriminator` keyword while never adding that property to the variant schema. OpenAPI 3.1
|
|
82
|
+
* forbids exactly this: "the `discriminator` field MUST be a required field". So the published
|
|
83
|
+
* contract cannot require the property, and under `seal-object-schemas` it forbids it outright.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ **Confirmed upstream, not inferred here:** `microsoft/typespec#7141`, open since 2025-04-27,
|
|
86
|
+
* labelled `[Bug]` / `design:needed` / `emitter:openapi3` and filed by a maintainer against these
|
|
87
|
+
* exact shapes. Nothing here needs raising; the refusal stands until that lands.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ **Refused rather than compensated for, and that distinction is the whole point.** This
|
|
90
|
+
* emitter did briefly inject the discriminator itself — the validator was right, the document was
|
|
91
|
+
* wrong, and it was recorded as a "declared divergence" with a warning and a committed list. That
|
|
92
|
+
* is a custom track with a label on it: runtime behaviour derived from nothing the document
|
|
93
|
+
* states. A validator may only enforce what the contract says.
|
|
94
|
+
*
|
|
95
|
+
* **It is avoidable, and the message says how.** Declaring the discriminator on the variant —
|
|
96
|
+
* `model Cat { kind: "cat"; … }` — makes openapi3 publish it as required, and everything works
|
|
97
|
+
* with no divergence and no refusal. The refusal lands only on the spelling that cannot produce
|
|
98
|
+
* an honest contract.
|
|
99
|
+
*/
|
|
100
|
+
"undeclared-discriminator": {
|
|
101
|
+
severity: "error",
|
|
102
|
+
messages: {
|
|
103
|
+
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
|
+
},
|
|
106
|
+
"empty-union": {
|
|
107
|
+
severity: "error",
|
|
108
|
+
messages: {
|
|
109
|
+
default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against.`,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* ⚠️ **Most cycles are not this.** A recursive model is an ordinary construct — `@typespec/openapi3`
|
|
114
|
+
* publishes a self-`$ref` and the document is valid — so it is emitted as a reference, deferred
|
|
115
|
+
* behind a getter on the property that closes the loop. This diagnostic is for the remainder: a
|
|
116
|
+
* cycle whose path holds **no object property**, and so has nowhere to put that getter. A named
|
|
117
|
+
* union whose variant refers back to a model still being rendered is the reachable case.
|
|
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`.
|
|
122
|
+
*/
|
|
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
|
+
/**
|
|
130
|
+
* A `key-vocabularies` entry naming no model in the service.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ **Reported rather than ignored, because the failure mode is silence.** The option exists so a
|
|
133
|
+
* consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
|
|
134
|
+
* vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
|
|
135
|
+
*/
|
|
136
|
+
"unknown-key-vocabulary": {
|
|
137
|
+
severity: "error",
|
|
138
|
+
messages: {
|
|
139
|
+
default: paramMessage `'${"name"}' is listed in 'key-vocabularies' but service '${"service"}' declares no model of that name, so no key tuple would be emitted for it. Check the spelling, or remove it from the option.`,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
/**
|
|
143
|
+
* A status RANGE that OpenAPI cannot key.
|
|
144
|
+
*
|
|
145
|
+
* ⚠️ **This replaced `mixed-error-bodies`, which was a different refusal for the same scenario and
|
|
146
|
+
* the wrong diagnosis of it.** That one said "a service must answer every failure with one body
|
|
147
|
+
* shape" — an assumption this emitter no longer makes, since each declared response resolves its
|
|
148
|
+
* own body. What is genuinely unrepresentable is narrower and is not ours: OpenAPI keys a range as
|
|
149
|
+
* `1XX`…`5XX`, so `@minValue(494) @maxValue(499)` has nowhere to go.
|
|
150
|
+
*
|
|
151
|
+
* ⚠️ **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its
|
|
152
|
+
* `rangeToOpenAPI`. Bucketing `494-499` into `4XX` anyway would make a server claim a `400`
|
|
153
|
+
* carries that body — a rule no document states, because openapi3 declines to publish a document
|
|
154
|
+
* for this spec at all. The same source is then either representable by both emitters or neither,
|
|
155
|
+
* which is the only arrangement under which a differential between them means anything.
|
|
156
|
+
*
|
|
157
|
+
* A range that covers a bucket exactly — `@minValue(400) @maxValue(499)` — is supported and emits
|
|
158
|
+
* a `4XX` arm.
|
|
159
|
+
*/
|
|
160
|
+
"unsupported-status-code-range": {
|
|
161
|
+
severity: "error",
|
|
162
|
+
messages: {
|
|
163
|
+
default: paramMessage `Status code range '${"start"}' to '${"end"}' cannot be published: OpenAPI can only key a range as 1XX, 2XX, 3XX, 4XX or 5XX, so no document states what this response carries and no validator may be emitted for it. Cover a whole group (\`@minValue(400) @maxValue(499)\` for 4XX) or declare the codes individually.`,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
export const $lib = createTypeSpecLibrary({
|
|
168
|
+
name: "typespec-http-zod",
|
|
169
|
+
diagnostics,
|
|
170
|
+
emitter: { options: EmitterOptionsSchema },
|
|
171
|
+
});
|
|
172
|
+
export const { reportDiagnostic } = $lib;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ModelProperty, Program, Type } from "@typespec/compiler";
|
|
2
|
+
interface Declaration {
|
|
3
|
+
readonly identifier: string;
|
|
4
|
+
readonly typeName: string;
|
|
5
|
+
readonly source: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class SchemaRegistry {
|
|
8
|
+
#private;
|
|
9
|
+
constructor(program: Program);
|
|
10
|
+
/**
|
|
11
|
+
* The Zod expression for a type — a bare identifier once the type has a declaration, otherwise
|
|
12
|
+
* inline source. Declaring is depth-first so a model's dependencies are emitted above it, which is
|
|
13
|
+
* what lets the generated file be plain `const` declarations with no forward references.
|
|
14
|
+
*/
|
|
15
|
+
expressionFor(type: Type): string;
|
|
16
|
+
/**
|
|
17
|
+
* The Zod for a single property, constraints and optionality included.
|
|
18
|
+
*
|
|
19
|
+
* Used for path/query parameters, which are `ModelProperty`s that belong to no model — so they
|
|
20
|
+
* need the property-level walk rather than the type-level one, or `@minLength` on a path param
|
|
21
|
+
* would be documented and never enforced.
|
|
22
|
+
*/
|
|
23
|
+
expressionForProperty(property: ModelProperty): string;
|
|
24
|
+
/**
|
|
25
|
+
* A declaration for a shape the spec never wrote, under a name the document publishes.
|
|
26
|
+
*
|
|
27
|
+
* `@discriminated` with an envelope has no TypeSpec type for its wrapper — openapi3 synthesises
|
|
28
|
+
* one and publishes it as `PetWithEnvelopeCat`, so there is a component with no `Type` to key on.
|
|
29
|
+
* Keyed by name instead, and pushed in the same dependency-first order as everything else, so the
|
|
30
|
+
* envelope declarations land above the union that references them.
|
|
31
|
+
*
|
|
32
|
+
* `declareNamed` exposes the same seam to the route emitter, which needs it for a **multipart**
|
|
33
|
+
* body: the document publishes `MultiPartRequest` as a component, and there is no walkable type
|
|
34
|
+
* for it — the parts have to be assembled from `@typespec/http`'s own view of the payload.
|
|
35
|
+
*/
|
|
36
|
+
declareNamed(name: string, build: () => string): string;
|
|
37
|
+
/** Every declaration, dependencies first. */
|
|
38
|
+
declarations(): readonly Declaration[];
|
|
39
|
+
}
|
|
40
|
+
export interface TsDeclaration {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
/** `true` when the body is an object literal and can be emitted as an `interface`. */
|
|
43
|
+
readonly isObject: boolean;
|
|
44
|
+
/** `true` for an enum-derived alias — declared but not exported, see below. */
|
|
45
|
+
readonly isVocabulary: boolean;
|
|
46
|
+
readonly source: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
|
|
50
|
+
*
|
|
51
|
+
* Kept as a separate class rather than a mode on the schema registry because the two produce
|
|
52
|
+
* different *kinds* of declaration — an `interface` versus a `const` — and their reference resolvers
|
|
53
|
+
* hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
|
|
54
|
+
* one artefact is named the same way in the other, so the emitted assertion can pair them by name.
|
|
55
|
+
*/
|
|
56
|
+
export declare class TypeRegistry {
|
|
57
|
+
#private;
|
|
58
|
+
constructor(program: Program);
|
|
59
|
+
expressionFor(type: Type): string;
|
|
60
|
+
/**
|
|
61
|
+
* The Zod-free type for a single property — used for path and query parameters, which are
|
|
62
|
+
* `ModelProperty`s belonging to no model. Without routing them through the registry a named model
|
|
63
|
+
* or enum in a parameter position is inlined rather than referenced.
|
|
64
|
+
*/
|
|
65
|
+
expressionForProperty(property: ModelProperty): string;
|
|
66
|
+
/** Every declaration, dependencies first. */
|
|
67
|
+
declarations(): readonly TsDeclaration[];
|
|
68
|
+
}
|
|
69
|
+
export {};
|