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 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 (declared.startsWith("z.number()"))
604
+ if (kind === "number")
590
605
  return `z.preprocess(${NUMBER}, ${declared})`;
591
- if (declared.startsWith("z.boolean()"))
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
- const element = /^z\.array\((z\.(?:number|boolean)\(\).*)\)$/s.exec(declared);
600
- if (element !== null) {
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) => new RegExp(`\\b${name}\\b`).test(haystack));
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.5.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",