typespec-http-zod 0.5.0 → 0.7.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/dist/src/api.d.ts +2 -0
- package/dist/src/api.js +147 -14
- package/dist/src/lib.d.ts +19 -1
- package/dist/src/lib.js +41 -0
- package/dist/src/registry.d.ts +0 -8
- package/dist/src/registry.js +144 -7
- package/dist/src/zod.d.ts +18 -0
- package/dist/src/zod.js +53 -0
- package/package.json +1 -1
package/dist/src/api.d.ts
CHANGED
|
@@ -211,6 +211,8 @@ export interface EmittedRoute {
|
|
|
211
211
|
readonly alternateResponseSchema: string | undefined;
|
|
212
212
|
}
|
|
213
213
|
export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
|
|
214
|
+
/** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
|
|
215
|
+
export declare function referencedIn(source: string, name: string): boolean;
|
|
214
216
|
/**
|
|
215
217
|
* The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
|
|
216
218
|
*
|
package/dist/src/api.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
|
|
2
2
|
import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
|
-
import { resolveOperationId } from "@typespec/openapi";
|
|
3
|
+
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
4
|
import { reportDiagnostic } from "./lib.js";
|
|
5
5
|
import { SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
6
6
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
7
7
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
8
|
-
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
8
|
+
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
9
9
|
/**
|
|
10
10
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
11
11
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -90,6 +90,89 @@ export function successStatusesOf(operation) {
|
|
|
90
90
|
}
|
|
91
91
|
return [...new Set(codes)].toSorted((a, b) => a - b);
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* **Operation ids, deduplicated the way `@typespec/openapi3` deduplicates them.**
|
|
95
|
+
*
|
|
96
|
+
* `resolveOperationId` derives an id from the immediate parent container, so two interfaces of the
|
|
97
|
+
* same name in different namespaces both resolve to `Standard_primitive`. Every per-operation
|
|
98
|
+
* declaration here is keyed on that id, so colliding ids emitted colliding `const`s:
|
|
99
|
+
* `TS2451: Cannot redeclare block-scoped variable 'Standard_primitiveResponses'`, 72 of them on one
|
|
100
|
+
* conformance scenario, from a compile that reported success.
|
|
101
|
+
*
|
|
102
|
+
* **The document does NOT have this problem, so refusing the spec would be wrong.** Measured on
|
|
103
|
+
* 1.15.0, openapi3 publishes `Standard_primitive` and `Standard_primitive_2` - it deduplicates, and
|
|
104
|
+
* the document is valid. The same source is representable there, so it has to be representable here,
|
|
105
|
+
* and matching its ids is also what keeps the promise that identifiers are named from the operation
|
|
106
|
+
* id the document publishes.
|
|
107
|
+
*
|
|
108
|
+
* The rule is copied from `OperationIdResolver` rather than invented, including the parts that look
|
|
109
|
+
* incidental: an explicit `@operationId` wins and is NOT deduplicated or reserved, and the suffix
|
|
110
|
+
* counts from `_2`. It is not exported, so it is reproduced here and the differential compares the
|
|
111
|
+
* result against the document's own ids.
|
|
112
|
+
*/
|
|
113
|
+
let operationIds;
|
|
114
|
+
/**
|
|
115
|
+
* Establish a fresh id resolver for one service's emission.
|
|
116
|
+
*
|
|
117
|
+
* **Scoped per SERVICE rather than per walk, because the walks see different subsets.**
|
|
118
|
+
* `collectRoutes` visits every operation with a success status; `collectRequestTypes` skips any with
|
|
119
|
+
* no body and no parameters. Deduplicating independently would let the two assign a suffix to
|
|
120
|
+
* different operations, and the artefacts would name one operation two ways.
|
|
121
|
+
*
|
|
122
|
+
* A reset rather than a wrapper, because the emission loop is `for (const snapshot of snapshots)`
|
|
123
|
+
* and awaits each service to completion before the next begins. Nothing interleaves, so there is no
|
|
124
|
+
* scope to restore, and wrapping an async body would mean threading a callback through every
|
|
125
|
+
* `emitFile` between the three walks.
|
|
126
|
+
*
|
|
127
|
+
* Left `undefined` outside an emission on purpose: `collectRoutes` is exported and a caller reaching
|
|
128
|
+
* it directly gets `resolveOperationId`'s answer unchanged rather than a suffix that depends on
|
|
129
|
+
* whatever ran before.
|
|
130
|
+
*/
|
|
131
|
+
function beginOperationIds() {
|
|
132
|
+
operationIds = { used: new Set(), assigned: new Set(), cache: new Map() };
|
|
133
|
+
}
|
|
134
|
+
/** The id this operation is emitted under, unique within the emission. */
|
|
135
|
+
function operationIdOf(program, operation) {
|
|
136
|
+
const scope = operationIds;
|
|
137
|
+
const resolved = resolveOperationId(program, operation);
|
|
138
|
+
if (scope === undefined)
|
|
139
|
+
return resolved;
|
|
140
|
+
const cached = scope.cache.get(operation);
|
|
141
|
+
if (cached !== undefined)
|
|
142
|
+
return cached;
|
|
143
|
+
// An explicit `@operationId` is used as given: openapi3 neither deduplicates nor reserves it.
|
|
144
|
+
const explicit = getOperationId(program, operation) !== undefined;
|
|
145
|
+
let name = resolved;
|
|
146
|
+
if (!explicit && scope.used.has(name)) {
|
|
147
|
+
let count = 1;
|
|
148
|
+
for (;;) {
|
|
149
|
+
count += 1;
|
|
150
|
+
const candidate = `${resolved}_${count}`;
|
|
151
|
+
if (!scope.used.has(candidate)) {
|
|
152
|
+
name = candidate;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!explicit)
|
|
158
|
+
scope.used.add(name);
|
|
159
|
+
scope.cache.set(operation, name);
|
|
160
|
+
/**
|
|
161
|
+
* **Checked after the id is settled, and reachable only through an explicit `@operationId`.** A
|
|
162
|
+
* derived id is deduplicated against `used` and cannot arrive here twice; an explicit one is never
|
|
163
|
+
* reserved, so it can equal an id already handed out, in either direction. Reported once per
|
|
164
|
+
* operation, because the cache above returns before this on every later walk.
|
|
165
|
+
*/
|
|
166
|
+
if (scope.assigned.has(name)) {
|
|
167
|
+
reportDiagnostic(program, {
|
|
168
|
+
code: "duplicate-operation-id",
|
|
169
|
+
format: { operationId: name },
|
|
170
|
+
target: operation,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
scope.assigned.add(name);
|
|
174
|
+
return name;
|
|
175
|
+
}
|
|
93
176
|
/** The five buckets OpenAPI can express a status RANGE as, in the order it names them. */
|
|
94
177
|
const STATUS_RANGE_GROUPS = [1, 2, 3, 4, 5];
|
|
95
178
|
/**
|
|
@@ -365,6 +448,21 @@ function responseContentTypesOf(operation, status) {
|
|
|
365
448
|
}
|
|
366
449
|
return [...types];
|
|
367
450
|
}
|
|
451
|
+
/** The type a parameter actually crosses the wire as, after any `@encode` on the property. */
|
|
452
|
+
function encodedParameterType(program, property) {
|
|
453
|
+
return getEncode(program, property)?.type ?? property.type;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
|
|
457
|
+
*
|
|
458
|
+
* `?ids=1,2,3` splits into strings, so each element needs the same decoding a lone scalar would.
|
|
459
|
+
*/
|
|
460
|
+
function elementKindOf(program, type) {
|
|
461
|
+
if (!isArrayType(type) || type.kind !== "Model")
|
|
462
|
+
return undefined;
|
|
463
|
+
const element = type.indexer?.value;
|
|
464
|
+
return element === undefined ? undefined : wireKindOf(program, element);
|
|
465
|
+
}
|
|
368
466
|
/** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
369
467
|
function isArrayType(type) {
|
|
370
468
|
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
@@ -583,12 +681,12 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
583
681
|
* `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
|
|
584
682
|
* with it silently.
|
|
585
683
|
*/
|
|
586
|
-
function wireDecoded(declared) {
|
|
684
|
+
function wireDecoded(declared, kind, elementKind) {
|
|
587
685
|
const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
|
|
588
686
|
const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
|
|
589
|
-
if (
|
|
687
|
+
if (kind === "number")
|
|
590
688
|
return `z.preprocess(${NUMBER}, ${declared})`;
|
|
591
|
-
if (
|
|
689
|
+
if (kind === "boolean")
|
|
592
690
|
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
593
691
|
/**
|
|
594
692
|
* A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
|
|
@@ -596,10 +694,8 @@ function wireDecoded(declared) {
|
|
|
596
694
|
* the same decoder applies per element. A non-array value is left alone; the split that produces
|
|
597
695
|
* the array runs outside this and may not have happened.
|
|
598
696
|
*/
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const inner = element[1] ?? "";
|
|
602
|
-
const decoder = inner.startsWith("z.number()") ? NUMBER : BOOLEAN;
|
|
697
|
+
if (elementKind !== undefined) {
|
|
698
|
+
const decoder = elementKind === "number" ? NUMBER : BOOLEAN;
|
|
603
699
|
return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
|
|
604
700
|
}
|
|
605
701
|
return declared;
|
|
@@ -688,7 +784,14 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
688
784
|
*/
|
|
689
785
|
const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
|
|
690
786
|
? mediaTypeDecoded(declared)
|
|
691
|
-
: wireDecoded(declared
|
|
787
|
+
: wireDecoded(declared,
|
|
788
|
+
/**
|
|
789
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is
|
|
790
|
+
* the one to read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number
|
|
791
|
+
* on the wire and a date in the spec, and reading the declared type alone stopped
|
|
792
|
+
* eighteen such parameters being decoded.
|
|
793
|
+
*/
|
|
794
|
+
wireKindOf(program, encodedParameterType(program, parameter.param)), elementKindOf(program, encodedParameterType(program, parameter.param)));
|
|
692
795
|
/**
|
|
693
796
|
* The boxing half of the same principle: an exploded array's single occurrence arrives as a
|
|
694
797
|
* bare string, so it is boxed into the one-element array the document describes before the
|
|
@@ -773,7 +876,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
773
876
|
? (bodyParameter?.property?.name ?? "body")
|
|
774
877
|
: undefined;
|
|
775
878
|
routes.push({
|
|
776
|
-
operationId:
|
|
879
|
+
operationId: operationIdOf(program, operation.operation),
|
|
777
880
|
verb: operation.verb.toUpperCase(),
|
|
778
881
|
/**
|
|
779
882
|
* The path TEMPLATE, exactly as the document publishes it - `/widgets/{widget-id}`.
|
|
@@ -875,11 +978,40 @@ export function collectRoutes(program, registry, service) {
|
|
|
875
978
|
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
876
979
|
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
877
980
|
*/
|
|
981
|
+
/**
|
|
982
|
+
* Every identifier the emitted source mentions.
|
|
983
|
+
*
|
|
984
|
+
* **Tokenised once on the language's own identifier rule, then tested by equality.** The question is
|
|
985
|
+
* "does this file reference this name", and the two obvious ways to ask it are both wrong in ways
|
|
986
|
+
* that only appear for particular names.
|
|
987
|
+
*
|
|
988
|
+
* A pattern built from the name is wrong when the name carries a regular-expression metacharacter.
|
|
989
|
+
* `$` is a valid identifier character in JavaScript and in TypeSpec, and an anchor in a pattern, so
|
|
990
|
+
* `new RegExp(`\b${name}\b`)` reports `foo$bar` absent from source that references it verbatim -
|
|
991
|
+
* and the import is dropped, leaving the emitted module referencing an undeclared name. Escaping
|
|
992
|
+
* alone does not fix it either, because `\b` is a WORD boundary and `$` is not a word character, so
|
|
993
|
+
* there is no boundary before it to match.
|
|
994
|
+
*
|
|
995
|
+
* A substring test is wrong in the other direction: `Foo` is found inside `FooExtra`, emitting an
|
|
996
|
+
* import the file never uses. That one fails loudly at lint, which makes it the better mistake, but
|
|
997
|
+
* it is still a mistake.
|
|
998
|
+
*
|
|
999
|
+
* Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
|
|
1000
|
+
* per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
|
|
1001
|
+
* identifier carried a `$` emitted a file that imported nothing and did not compile.
|
|
1002
|
+
*/
|
|
1003
|
+
function identifiersIn(source) {
|
|
1004
|
+
return new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
|
|
1005
|
+
}
|
|
1006
|
+
/** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
|
|
1007
|
+
export function referencedIn(source, name) {
|
|
1008
|
+
return identifiersIn(source).has(name);
|
|
1009
|
+
}
|
|
878
1010
|
function renderExternalImports(externals, ...bodies) {
|
|
879
1011
|
const haystack = bodies.join("\n");
|
|
880
1012
|
const lines = [];
|
|
881
1013
|
for (const [module, names] of externals) {
|
|
882
|
-
const used = [...names].filter((name) =>
|
|
1014
|
+
const used = [...names].filter((name) => referencedIn(haystack, name));
|
|
883
1015
|
if (used.length > 0) {
|
|
884
1016
|
lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
|
|
885
1017
|
}
|
|
@@ -1137,7 +1269,7 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1137
1269
|
? "ArrayBuffer"
|
|
1138
1270
|
: "string";
|
|
1139
1271
|
entries.push({
|
|
1140
|
-
operationId:
|
|
1272
|
+
operationId: operationIdOf(program, operation.operation),
|
|
1141
1273
|
bodyRef: rawBody !== undefined
|
|
1142
1274
|
? `{\n\t${rawBody}: ${rawBodyType};\n}`
|
|
1143
1275
|
: body === undefined
|
|
@@ -1169,7 +1301,7 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1169
1301
|
if (body === undefined)
|
|
1170
1302
|
continue;
|
|
1171
1303
|
entries.push({
|
|
1172
|
-
operationId:
|
|
1304
|
+
operationId: operationIdOf(program, operation.operation),
|
|
1173
1305
|
ref: registry.expressionFor(body),
|
|
1174
1306
|
});
|
|
1175
1307
|
}
|
|
@@ -1408,6 +1540,7 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1408
1540
|
*/
|
|
1409
1541
|
const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
|
|
1410
1542
|
for (const snapshot of snapshots) {
|
|
1543
|
+
beginOperationIds();
|
|
1411
1544
|
const service = snapshot.service;
|
|
1412
1545
|
const serviceName = service.namespace.name;
|
|
1413
1546
|
const perService = options.services?.[serviceName];
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -121,8 +121,14 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
121
121
|
"unsupported-status-code-range": {
|
|
122
122
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
123
123
|
};
|
|
124
|
+
"duplicate-operation-id": {
|
|
125
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
126
|
+
};
|
|
127
|
+
"duplicate-declaration": {
|
|
128
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
129
|
+
};
|
|
124
130
|
}, EmitterOptions, never>;
|
|
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 {
|
|
131
|
+
export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
126
132
|
"unsupported-type": {
|
|
127
133
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
128
134
|
};
|
|
@@ -141,6 +147,12 @@ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-di
|
|
|
141
147
|
"unsupported-status-code-range": {
|
|
142
148
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
143
149
|
};
|
|
150
|
+
"duplicate-operation-id": {
|
|
151
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
152
|
+
};
|
|
153
|
+
"duplicate-declaration": {
|
|
154
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
155
|
+
};
|
|
144
156
|
}[C]>(program: import("@typespec/compiler").Program, diag: import("@typespec/compiler").DiagnosticReport<{
|
|
145
157
|
"unsupported-type": {
|
|
146
158
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
@@ -160,4 +172,10 @@ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-di
|
|
|
160
172
|
"unsupported-status-code-range": {
|
|
161
173
|
readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
|
|
162
174
|
};
|
|
175
|
+
"duplicate-operation-id": {
|
|
176
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
177
|
+
};
|
|
178
|
+
"duplicate-declaration": {
|
|
179
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
180
|
+
};
|
|
163
181
|
}, C, M>) => void;
|
package/dist/src/lib.js
CHANGED
|
@@ -155,6 +155,47 @@ const diagnostics = {
|
|
|
155
155
|
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.`,
|
|
156
156
|
},
|
|
157
157
|
},
|
|
158
|
+
/**
|
|
159
|
+
* An explicit `@operationId` another operation already answers to.
|
|
160
|
+
*
|
|
161
|
+
* **Derived ids are deduplicated and this one cannot be.** `resolveOperationId` names an operation
|
|
162
|
+
* from its immediate parent container, so two interfaces of the same name collide, and openapi3
|
|
163
|
+
* resolves that by appending `_2`. It does NOT do so for an explicit `@operationId`: that id is
|
|
164
|
+
* returned exactly as written and is never reserved, because the author asked for that name and
|
|
165
|
+
* renaming it silently would break every client generated against the document.
|
|
166
|
+
*
|
|
167
|
+
* So a colliding explicit id is a document OpenAPI forbids, `operationId` must be unique, and
|
|
168
|
+
* openapi3 raises nothing and publishes the duplicate. Every declaration here is keyed on the id,
|
|
169
|
+
* so the emitted file declares each name twice and fails with `TS2451: Cannot redeclare
|
|
170
|
+
* block-scoped variable` from a compile that reported success.
|
|
171
|
+
*
|
|
172
|
+
* **This is the one collision worth refusing.** The derived case is representable and is emitted.
|
|
173
|
+
* This one cannot be resolved without contradicting what the author explicitly wrote.
|
|
174
|
+
*/
|
|
175
|
+
"duplicate-operation-id": {
|
|
176
|
+
severity: "error",
|
|
177
|
+
messages: {
|
|
178
|
+
default: paramMessage `'${"operationId"}' is already the operation id of another operation, and OpenAPI requires it to be unique. An explicit '@operationId' is never renamed to make room, so this cannot be resolved for you. Give this operation an id no other operation uses, or remove the '@operationId' and let it be derived.`,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
/**
|
|
182
|
+
* Two declarations claiming one TypeScript name with different bodies.
|
|
183
|
+
*
|
|
184
|
+
* **A repeat of the SAME declaration is not this and is dropped silently.** Visibility projection
|
|
185
|
+
* hands the registry a different `Type` object for one declared model, so a model reached under two
|
|
186
|
+
* visibilities is registered twice and renders identically; emitting both produced two
|
|
187
|
+
* byte-identical `export interface BodyModel` and a file that does not compile.
|
|
188
|
+
*
|
|
189
|
+
* This is the other case: one name, two different shapes. The document distinguishes them by
|
|
190
|
+
* namespace and a TypeScript module cannot, so keeping the first would publish a contract for a
|
|
191
|
+
* type nothing checks, and picking either is a guess about which the author meant.
|
|
192
|
+
*/
|
|
193
|
+
"duplicate-declaration": {
|
|
194
|
+
severity: "error",
|
|
195
|
+
messages: {
|
|
196
|
+
default: paramMessage `Two different types are both named '${"name"}', and a TypeScript module cannot declare one name twice. The document tells them apart by namespace; this file cannot. Give one of them a different name, or set an explicit one with '@friendlyName'.`,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
158
199
|
};
|
|
159
200
|
export const $lib = createTypeSpecLibrary({
|
|
160
201
|
name: "typespec-http-zod",
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -95,14 +95,6 @@ export interface TsDeclaration {
|
|
|
95
95
|
readonly isVocabulary: boolean;
|
|
96
96
|
readonly source: string;
|
|
97
97
|
}
|
|
98
|
-
/**
|
|
99
|
-
* The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
|
|
100
|
-
*
|
|
101
|
-
* Kept as a separate class rather than a mode on the schema registry because the two produce
|
|
102
|
-
* different *kinds* of declaration - an `interface` versus a `const` - and their reference resolvers
|
|
103
|
-
* hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
|
|
104
|
-
* one artefact is named the same way in the other, so the emitted assertion can pair them by name.
|
|
105
|
-
*/
|
|
106
98
|
export declare class TypeRegistry {
|
|
107
99
|
#private;
|
|
108
100
|
constructor(program: Program);
|
package/dist/src/registry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getVisibilitySuffix, Visibility } from "@typespec/http";
|
|
2
|
+
import { reportDiagnostic } from "./lib.js";
|
|
2
3
|
import { propertyToTs, typeToTsBody, withTsRefResolver } from "./types.js";
|
|
3
4
|
import { captureBackEdges, currentVisibility, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
4
5
|
/**
|
|
@@ -63,10 +64,78 @@ required = [...type.properties.keys()]) {
|
|
|
63
64
|
return model.name === "" ? spreadSourceOf(model, new Set([...seen, type]), required) : model;
|
|
64
65
|
}
|
|
65
66
|
const spreadSourceName = (type) => spreadSourceOf(type)?.name;
|
|
67
|
+
/**
|
|
68
|
+
* ECMAScript's reserved words, which cannot name a TypeScript declaration.
|
|
69
|
+
*
|
|
70
|
+
* **A model may be named after one, and the corpus declares exactly that on purpose.** `special-words`
|
|
71
|
+
* carries `await`, `break` and the rest, and this emitter wrote `export interface await { ... }` and
|
|
72
|
+
* `export type break = z.infer<...>`, neither of which parses - `TS2427`, then the parser gives up
|
|
73
|
+
* and the whole file is lost.
|
|
74
|
+
*
|
|
75
|
+
* **This is a LIST, which this package otherwise refuses.** It is defensible here because it is
|
|
76
|
+
* not a list about our own code that drifts as the code changes: it is a closed set the language
|
|
77
|
+
* specification fixes. The guard is that `tsc` compiles a fixture declaring models named after these,
|
|
78
|
+
* so a word missing from the list fails a test rather than reaching a consumer - the list is the
|
|
79
|
+
* implementation and the compiler is the oracle.
|
|
80
|
+
*
|
|
81
|
+
* Contextual keywords are deliberately absent, because they are legal: `interface`, `string`,
|
|
82
|
+
* `number`, `any`, `never`, `public` and `yield` all name a type without complaint. Measured, rather
|
|
83
|
+
* than assumed from a language reference.
|
|
84
|
+
*/
|
|
85
|
+
const RESERVED_DECLARATION_NAMES = new Set([
|
|
86
|
+
"await",
|
|
87
|
+
"break",
|
|
88
|
+
"case",
|
|
89
|
+
"catch",
|
|
90
|
+
"class",
|
|
91
|
+
"const",
|
|
92
|
+
"continue",
|
|
93
|
+
"debugger",
|
|
94
|
+
"default",
|
|
95
|
+
"delete",
|
|
96
|
+
"do",
|
|
97
|
+
"else",
|
|
98
|
+
"enum",
|
|
99
|
+
"export",
|
|
100
|
+
"extends",
|
|
101
|
+
"false",
|
|
102
|
+
"finally",
|
|
103
|
+
"for",
|
|
104
|
+
"function",
|
|
105
|
+
"if",
|
|
106
|
+
"import",
|
|
107
|
+
"in",
|
|
108
|
+
"instanceof",
|
|
109
|
+
"new",
|
|
110
|
+
"null",
|
|
111
|
+
"return",
|
|
112
|
+
"super",
|
|
113
|
+
"switch",
|
|
114
|
+
"this",
|
|
115
|
+
"throw",
|
|
116
|
+
"true",
|
|
117
|
+
"try",
|
|
118
|
+
"typeof",
|
|
119
|
+
"var",
|
|
120
|
+
"void",
|
|
121
|
+
"while",
|
|
122
|
+
"with",
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* The name a declaration is emitted under.
|
|
126
|
+
*
|
|
127
|
+
* A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration. It is
|
|
128
|
+
* applied in ONE place so both walks agree: `wire-contract.gen.ts` pairs a validator against a
|
|
129
|
+
* contract type by name, and a mangling applied to one side only would break that assertion rather
|
|
130
|
+
* than the file.
|
|
131
|
+
*/
|
|
132
|
+
function safeDeclarationName(name) {
|
|
133
|
+
return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
|
|
134
|
+
}
|
|
66
135
|
/** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
|
|
67
136
|
function declaredNameOf(type) {
|
|
68
137
|
if (type.kind === "Enum")
|
|
69
|
-
return type.name;
|
|
138
|
+
return safeDeclarationName(type.name);
|
|
70
139
|
/**
|
|
71
140
|
* A named union earns a declaration too.
|
|
72
141
|
*
|
|
@@ -76,14 +145,16 @@ function declaredNameOf(type) {
|
|
|
76
145
|
* would silently stop being checked at the boundary.
|
|
77
146
|
*/
|
|
78
147
|
if (type.kind === "Union")
|
|
79
|
-
return type.name === undefined ? undefined : type.name;
|
|
148
|
+
return type.name === undefined ? undefined : safeDeclarationName(type.name);
|
|
80
149
|
if (type.kind !== "Model")
|
|
81
150
|
return undefined;
|
|
82
151
|
// `Array`/`Record` are TypeSpec's built-in generic containers, not names worth exporting.
|
|
83
152
|
if (type.name === "Array" || type.name === "Record")
|
|
84
153
|
return undefined;
|
|
85
|
-
if (type.name === "")
|
|
86
|
-
|
|
154
|
+
if (type.name === "") {
|
|
155
|
+
const spread = spreadSourceName(type);
|
|
156
|
+
return spread === undefined ? undefined : safeDeclarationName(spread);
|
|
157
|
+
}
|
|
87
158
|
/**
|
|
88
159
|
* **A template INSTANTIATION carries the template's name, and it is not a declaration.**
|
|
89
160
|
*
|
|
@@ -97,7 +168,7 @@ function declaredNameOf(type) {
|
|
|
97
168
|
*/
|
|
98
169
|
if (type.templateMapper !== undefined)
|
|
99
170
|
return undefined;
|
|
100
|
-
return type.name;
|
|
171
|
+
return safeDeclarationName(type.name);
|
|
101
172
|
}
|
|
102
173
|
const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
103
174
|
/**
|
|
@@ -344,6 +415,48 @@ export class SchemaRegistry {
|
|
|
344
415
|
* hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
|
|
345
416
|
* one artefact is named the same way in the other, so the emitted assertion can pair them by name.
|
|
346
417
|
*/
|
|
418
|
+
/**
|
|
419
|
+
* Whether a rendered body is ONE object literal, and so may be emitted as an `interface`.
|
|
420
|
+
*
|
|
421
|
+
* **This was `source.startsWith("{") && !source.includes("} & ")`, which named the two shapes it had
|
|
422
|
+
* met rather than the property it needed.** A discriminated union with an envelope renders as
|
|
423
|
+
* `{ kind: "cat"; value: Cat; } | { kind: "dog"; value: Dog; }`: it starts with a brace and holds no
|
|
424
|
+
* intersection, so it was emitted as `export interface PetWithEnvelope { ... } | { ... }`. An
|
|
425
|
+
* interface cannot be a union, so the file did not parse at all - and that is the DEFAULT envelope,
|
|
426
|
+
* the shape a spec gets from writing `@discriminated` and nothing else.
|
|
427
|
+
*
|
|
428
|
+
* Balanced braces answer the question actually being asked: an interface is valid exactly when the
|
|
429
|
+
* whole body is a single `{...}`, whatever it contains. A composition operator cannot be missed,
|
|
430
|
+
* because anything following the matching close brace fails the test whatever it happens to be.
|
|
431
|
+
*/
|
|
432
|
+
function isSingleObjectLiteral(source) {
|
|
433
|
+
if (!source.startsWith("{"))
|
|
434
|
+
return false;
|
|
435
|
+
let depth = 0;
|
|
436
|
+
let quoted = false;
|
|
437
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
438
|
+
const character = source[index];
|
|
439
|
+
// A property key may be quoted and may itself contain a brace, which is not a nesting one.
|
|
440
|
+
if (quoted) {
|
|
441
|
+
if (character === "\\")
|
|
442
|
+
index += 1;
|
|
443
|
+
else if (character === '"')
|
|
444
|
+
quoted = false;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (character === '"')
|
|
448
|
+
quoted = true;
|
|
449
|
+
else if (character === "{")
|
|
450
|
+
depth += 1;
|
|
451
|
+
else if (character === "}") {
|
|
452
|
+
depth -= 1;
|
|
453
|
+
// Closing the outermost brace anywhere but at the very end means something follows it.
|
|
454
|
+
if (depth === 0)
|
|
455
|
+
return index === source.length - 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
347
460
|
export class TypeRegistry {
|
|
348
461
|
#program;
|
|
349
462
|
#declarations = new Map();
|
|
@@ -377,7 +490,7 @@ export class TypeRegistry {
|
|
|
377
490
|
* `{ ...pinned } & Record<string, unknown>`, and `export interface X { ... } & ...` does not parse -
|
|
378
491
|
* so an intersection is emitted as a type alias instead.
|
|
379
492
|
*/
|
|
380
|
-
isObject:
|
|
493
|
+
isObject: isSingleObjectLiteral(source),
|
|
381
494
|
/**
|
|
382
495
|
* **A vocabulary alias is NOT exported.**
|
|
383
496
|
*
|
|
@@ -396,7 +509,31 @@ export class TypeRegistry {
|
|
|
396
509
|
source,
|
|
397
510
|
};
|
|
398
511
|
this.#declarations.set(type, declaration);
|
|
399
|
-
|
|
512
|
+
/**
|
|
513
|
+
* **Declared once per NAME, not once per `Type`, and the two are not the same thing.**
|
|
514
|
+
*
|
|
515
|
+
* `#declarations` is keyed on the `Type` object so a repeated reference resolves without
|
|
516
|
+
* re-walking. Visibility projection produces a DIFFERENT `Type` for the same declaration, so one
|
|
517
|
+
* model reached under two visibilities was emitted twice and the file did not compile:
|
|
518
|
+
* `parameters/body-optionality` declares one `model BodyModel` and `requests.gen.ts` carried two
|
|
519
|
+
* byte-identical `export interface BodyModel`, from a compile that reported success.
|
|
520
|
+
*
|
|
521
|
+
* **Identical is dropped; DIFFERING is a collision and must not be.** Two declarations sharing a
|
|
522
|
+
* name with different bodies are two different types that TypeScript can only express as one, so
|
|
523
|
+
* silently keeping the first would publish a contract for a type nothing checks. The document
|
|
524
|
+
* distinguishes them and this file cannot, which is a refusal rather than a choice.
|
|
525
|
+
*/
|
|
526
|
+
const sameName = this.#order.find((candidate) => candidate.name === declaration.name);
|
|
527
|
+
if (sameName === undefined) {
|
|
528
|
+
this.#order.push(declaration);
|
|
529
|
+
}
|
|
530
|
+
else if (sameName.source !== declaration.source) {
|
|
531
|
+
reportDiagnostic(this.#program, {
|
|
532
|
+
code: "duplicate-declaration",
|
|
533
|
+
format: { name: declaration.name },
|
|
534
|
+
target: type,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
400
537
|
return declaration.name;
|
|
401
538
|
}
|
|
402
539
|
/**
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -93,6 +93,24 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
|
|
|
93
93
|
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
94
94
|
*/
|
|
95
95
|
export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
|
|
96
|
+
/**
|
|
97
|
+
* **What JSON kind a value of this type arrives as, for a parameter carried in text.**
|
|
98
|
+
*
|
|
99
|
+
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
100
|
+
* to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
|
|
101
|
+
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
102
|
+
* and fails silently the moment the expression is spelled any other way.
|
|
103
|
+
*
|
|
104
|
+
* It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
|
|
105
|
+
* neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
|
|
106
|
+
* that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
|
|
107
|
+
* caller got a 400, and no document comparison could see it, because both sides agree the parameter
|
|
108
|
+
* is a number - the disagreement is with the wire.
|
|
109
|
+
*
|
|
110
|
+
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
111
|
+
* single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
|
|
112
|
+
*/
|
|
113
|
+
export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
|
|
96
114
|
/**
|
|
97
115
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
|
98
116
|
*
|
package/dist/src/zod.js
CHANGED
|
@@ -787,6 +787,59 @@ function modelToZod(program, model) {
|
|
|
787
787
|
: `z.object(${shape})${suffix}${strict}`;
|
|
788
788
|
return applyConstraints(program, body, model);
|
|
789
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* **What JSON kind a value of this type arrives as, for a parameter carried in text.**
|
|
792
|
+
*
|
|
793
|
+
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
794
|
+
* to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
|
|
795
|
+
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
796
|
+
* and fails silently the moment the expression is spelled any other way.
|
|
797
|
+
*
|
|
798
|
+
* It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
|
|
799
|
+
* neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
|
|
800
|
+
* that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
|
|
801
|
+
* caller got a 400, and no document comparison could see it, because both sides agree the parameter
|
|
802
|
+
* is a number - the disagreement is with the wire.
|
|
803
|
+
*
|
|
804
|
+
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
805
|
+
* single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
|
|
806
|
+
*/
|
|
807
|
+
export function wireKindOf(program, type) {
|
|
808
|
+
if (type.kind === "Scalar") {
|
|
809
|
+
const encoded = encodedTypeOf(program, type);
|
|
810
|
+
if (encoded !== undefined && encoded !== type)
|
|
811
|
+
return wireKindOf(program, encoded);
|
|
812
|
+
for (let current = type; current !== undefined; current = current.baseScalar) {
|
|
813
|
+
const mapped = SCALARS[current.name];
|
|
814
|
+
if (mapped === undefined)
|
|
815
|
+
continue;
|
|
816
|
+
if (mapped.startsWith("z.number()"))
|
|
817
|
+
return "number";
|
|
818
|
+
if (mapped === "z.boolean()")
|
|
819
|
+
return "boolean";
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
// A literal is its own type, and a union of them is the enum form a parameter commonly takes.
|
|
825
|
+
if (type.kind === "Number")
|
|
826
|
+
return "number";
|
|
827
|
+
if (type.kind === "Boolean")
|
|
828
|
+
return "boolean";
|
|
829
|
+
if (type.kind === "Union") {
|
|
830
|
+
const kinds = new Set();
|
|
831
|
+
for (const variant of type.variants.values()) {
|
|
832
|
+
// `T | null` is still a T on the wire; the null arm needs no decoding.
|
|
833
|
+
if (variant.type.kind === "Intrinsic" && variant.type.name === "null")
|
|
834
|
+
continue;
|
|
835
|
+
kinds.add(wireKindOf(program, variant.type));
|
|
836
|
+
}
|
|
837
|
+
if (kinds.size !== 1)
|
|
838
|
+
return undefined;
|
|
839
|
+
return [...kinds][0];
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
790
843
|
/**
|
|
791
844
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
|
792
845
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codegen",
|