typespec-http-zod 0.5.0 → 0.6.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 +28 -0
- package/dist/src/api.js +59 -10
- 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,34 @@ 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
|
+
/**
|
|
215
|
+
* Emit only the external names a given file actually references.
|
|
216
|
+
*
|
|
217
|
+
* Both generated files can need them - `schemas.gen.ts` for the vocabulary tuples and `@refine`
|
|
218
|
+
* predicates, and `routes.gen.ts` when an inline params schema carries a refined scalar.
|
|
219
|
+
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
220
|
+
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* Whether emitted source references `name` as an identifier.
|
|
224
|
+
*
|
|
225
|
+
* **The name is ESCAPED before it becomes a pattern, and skipping that is a silent build break.**
|
|
226
|
+
* This was `new RegExp(`\\b${name}\\b`)`, interpolating an identifier straight into a pattern.
|
|
227
|
+
* `$` is a valid identifier character in JavaScript and in TypeSpec, and an anchor in a regular
|
|
228
|
+
* expression, so a name containing one fails to match ITSELF: measured, `foo$bar` and `$select` both
|
|
229
|
+
* report absent from source that references them verbatim.
|
|
230
|
+
*
|
|
231
|
+
* The direction matters. Reporting a name present that is absent yields an unused import, which lint
|
|
232
|
+
* refuses loudly. Reporting a name ABSENT that is present drops the import and leaves the emitted
|
|
233
|
+
* module referencing an undeclared identifier - the failure this function exists to prevent, which is
|
|
234
|
+
* how `routes.gen.ts` once referenced `isParsableInstant` with no import at all.
|
|
235
|
+
*
|
|
236
|
+
* Only `SPEC_VOCABULARIES` reaches here today, so the hole was latent rather than live. It sits on a
|
|
237
|
+
* general seam that any future name flows through, and the reasoning that made it look safe - an
|
|
238
|
+
* identifier absent from the text is genuinely not needed - is true of the QUESTION and false of the
|
|
239
|
+
* way it was being asked.
|
|
240
|
+
*/
|
|
241
|
+
export declare function referencedIn(source: string, name: string): boolean;
|
|
214
242
|
/**
|
|
215
243
|
* The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
|
|
216
244
|
*
|
package/dist/src/api.js
CHANGED
|
@@ -5,7 +5,7 @@ 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.
|
|
@@ -365,6 +365,21 @@ function responseContentTypesOf(operation, status) {
|
|
|
365
365
|
}
|
|
366
366
|
return [...types];
|
|
367
367
|
}
|
|
368
|
+
/** The type a parameter actually crosses the wire as, after any `@encode` on the property. */
|
|
369
|
+
function encodedParameterType(program, property) {
|
|
370
|
+
return getEncode(program, property)?.type ?? property.type;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
|
|
374
|
+
*
|
|
375
|
+
* `?ids=1,2,3` splits into strings, so each element needs the same decoding a lone scalar would.
|
|
376
|
+
*/
|
|
377
|
+
function elementKindOf(program, type) {
|
|
378
|
+
if (!isArrayType(type) || type.kind !== "Model")
|
|
379
|
+
return undefined;
|
|
380
|
+
const element = type.indexer?.value;
|
|
381
|
+
return element === undefined ? undefined : wireKindOf(program, element);
|
|
382
|
+
}
|
|
368
383
|
/** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
369
384
|
function isArrayType(type) {
|
|
370
385
|
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
@@ -583,12 +598,12 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
583
598
|
* `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
|
|
584
599
|
* with it silently.
|
|
585
600
|
*/
|
|
586
|
-
function wireDecoded(declared) {
|
|
601
|
+
function wireDecoded(declared, kind, elementKind) {
|
|
587
602
|
const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
|
|
588
603
|
const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
|
|
589
|
-
if (
|
|
604
|
+
if (kind === "number")
|
|
590
605
|
return `z.preprocess(${NUMBER}, ${declared})`;
|
|
591
|
-
if (
|
|
606
|
+
if (kind === "boolean")
|
|
592
607
|
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
593
608
|
/**
|
|
594
609
|
* A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
|
|
@@ -596,10 +611,8 @@ function wireDecoded(declared) {
|
|
|
596
611
|
* the same decoder applies per element. A non-array value is left alone; the split that produces
|
|
597
612
|
* the array runs outside this and may not have happened.
|
|
598
613
|
*/
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const inner = element[1] ?? "";
|
|
602
|
-
const decoder = inner.startsWith("z.number()") ? NUMBER : BOOLEAN;
|
|
614
|
+
if (elementKind !== undefined) {
|
|
615
|
+
const decoder = elementKind === "number" ? NUMBER : BOOLEAN;
|
|
603
616
|
return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
|
|
604
617
|
}
|
|
605
618
|
return declared;
|
|
@@ -688,7 +701,14 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
688
701
|
*/
|
|
689
702
|
const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
|
|
690
703
|
? mediaTypeDecoded(declared)
|
|
691
|
-
: wireDecoded(declared
|
|
704
|
+
: wireDecoded(declared,
|
|
705
|
+
/**
|
|
706
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is
|
|
707
|
+
* the one to read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number
|
|
708
|
+
* on the wire and a date in the spec, and reading the declared type alone stopped
|
|
709
|
+
* eighteen such parameters being decoded.
|
|
710
|
+
*/
|
|
711
|
+
wireKindOf(program, encodedParameterType(program, parameter.param)), elementKindOf(program, encodedParameterType(program, parameter.param)));
|
|
692
712
|
/**
|
|
693
713
|
* The boxing half of the same principle: an exploded array's single occurrence arrives as a
|
|
694
714
|
* bare string, so it is boxed into the one-element array the document describes before the
|
|
@@ -875,11 +895,40 @@ export function collectRoutes(program, registry, service) {
|
|
|
875
895
|
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
876
896
|
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
877
897
|
*/
|
|
898
|
+
/**
|
|
899
|
+
* Whether emitted source references `name` as an identifier.
|
|
900
|
+
*
|
|
901
|
+
* **The name is ESCAPED before it becomes a pattern, and skipping that is a silent build break.**
|
|
902
|
+
* This was `new RegExp(`\\b${name}\\b`)`, interpolating an identifier straight into a pattern.
|
|
903
|
+
* `$` is a valid identifier character in JavaScript and in TypeSpec, and an anchor in a regular
|
|
904
|
+
* expression, so a name containing one fails to match ITSELF: measured, `foo$bar` and `$select` both
|
|
905
|
+
* report absent from source that references them verbatim.
|
|
906
|
+
*
|
|
907
|
+
* The direction matters. Reporting a name present that is absent yields an unused import, which lint
|
|
908
|
+
* refuses loudly. Reporting a name ABSENT that is present drops the import and leaves the emitted
|
|
909
|
+
* module referencing an undeclared identifier - the failure this function exists to prevent, which is
|
|
910
|
+
* how `routes.gen.ts` once referenced `isParsableInstant` with no import at all.
|
|
911
|
+
*
|
|
912
|
+
* Only `SPEC_VOCABULARIES` reaches here today, so the hole was latent rather than live. It sits on a
|
|
913
|
+
* general seam that any future name flows through, and the reasoning that made it look safe - an
|
|
914
|
+
* identifier absent from the text is genuinely not needed - is true of the QUESTION and false of the
|
|
915
|
+
* way it was being asked.
|
|
916
|
+
*/
|
|
917
|
+
export function referencedIn(source, name) {
|
|
918
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
919
|
+
/**
|
|
920
|
+
* Bounded by IDENTIFIER characters rather than `\b`, because `$` is one in JavaScript and not a
|
|
921
|
+
* word character in a regular expression - so `\b$select\b` never matches `$select`, there being
|
|
922
|
+
* no word boundary before a `$`. The two failure modes have to be closed together or the fix for
|
|
923
|
+
* one leaves the other.
|
|
924
|
+
*/
|
|
925
|
+
return new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source);
|
|
926
|
+
}
|
|
878
927
|
function renderExternalImports(externals, ...bodies) {
|
|
879
928
|
const haystack = bodies.join("\n");
|
|
880
929
|
const lines = [];
|
|
881
930
|
for (const [module, names] of externals) {
|
|
882
|
-
const used = [...names].filter((name) =>
|
|
931
|
+
const used = [...names].filter((name) => referencedIn(haystack, name));
|
|
883
932
|
if (used.length > 0) {
|
|
884
933
|
lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
|
|
885
934
|
}
|
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.6.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",
|