typespec-http-zod 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,6 +146,8 @@ carries the whole id, so `op readWidget` in `namespace Widgets` becomes `Widgets
146
146
  - [Guides](docs/guides.md): installing, validating a request, answering with the right body, content
147
147
  types, and building an emitter on the API.
148
148
  - [Reference](docs/reference.md): every option, every diagnostic, and the known limits.
149
+ - [Oracles](docs/oracles.md): every artefact this emitter produces, and what compares it to the
150
+ thing it has to agree with.
149
151
 
150
152
  ## Licence
151
153
 
package/dist/src/api.js CHANGED
@@ -517,6 +517,20 @@ const COLLECTION_DELIMITERS = {
517
517
  function isCollectionParameter(property) {
518
518
  return isArrayType(property.type);
519
519
  }
520
+ /**
521
+ * Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
522
+ *
523
+ * The distinction matters because of what `zValidator` hands the schema: for a repeated key it
524
+ * builds an array, but for a single occurrence it hands the bare string - and one `key=value` pair
525
+ * is exactly what a one-member exploded array looks like on the wire. A bare `z.array()` therefore
526
+ * refused a conformant request while accepting the same list sent twice, which no document
527
+ * describes. Found by the first consumer's `?topics=ip` (#1).
528
+ */
529
+ function isExplodedCollection(parameter) {
530
+ return (isCollectionParameter(parameter.param) &&
531
+ (parameter.type === "query" || parameter.type === "header") &&
532
+ parameter.explode === true);
533
+ }
520
534
  /**
521
535
  * The delimiter a parameter's values were joined with, or `undefined` when they were not joined.
522
536
  *
@@ -675,9 +689,18 @@ function parameterSchemasOf(program, operation, registry) {
675
689
  const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
676
690
  ? mediaTypeDecoded(declared)
677
691
  : wireDecoded(declared);
678
- const expression = delimiter === undefined
679
- ? decoded
680
- : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
692
+ /**
693
+ * The boxing half of the same principle: an exploded array's single occurrence arrives as a
694
+ * bare string, so it is boxed into the one-element array the document describes before the
695
+ * schema runs - every constraint on the list still applies to the result. Several
696
+ * occurrences arrive already an array and pass through untouched, exactly as the delimiter
697
+ * arm leaves a pre-split value alone.
698
+ */
699
+ const expression = isExplodedCollection(parameter)
700
+ ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
701
+ : delimiter === undefined
702
+ ? decoded
703
+ : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
681
704
  const entry = `\t${objectKey(parameter.name)}: ${expression},`;
682
705
  (byTarget[parameter.type] ??= []).push(entry);
683
706
  /**
@@ -1081,22 +1104,42 @@ function collectRequestTypes(program, registry, service) {
1081
1104
  */
1082
1105
  const extraProperties = operation.parameters.parameters
1083
1106
  .filter((parameter) => parameter.type === "path" || parameter.type === "query" || parameter.type === "header")
1084
- .map((parameter) => registry.expressionForProperty(parameter.param));
1107
+ /**
1108
+ * Named as the WIRE names it, which is what the emitted validator keys on.
1109
+ * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1110
+ * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1111
+ * fails to typecheck against a server that is correct.
1112
+ */
1113
+ .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1085
1114
  if (body === undefined && extraProperties.length === 0)
1086
1115
  continue;
1087
1116
  /**
1088
- * A `bytes` body reaches the use-case as a NAMED property holding the unparsed text, not as
1089
- * a bare string - so the wire type is `{ rawBody: string }`, matching what a server
1090
- * actually builds. Referencing the scalar directly would type the whole input as `string`
1091
- * and quietly drop the headers beside it.
1117
+ * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1118
+ * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1119
+ * builds. Referencing the scalar directly would type the whole input as that value and
1120
+ * quietly drop the headers beside it.
1092
1121
  */
1093
1122
  const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1094
1123
  ? (operation.parameters.body?.property?.name ?? "body")
1095
1124
  : undefined;
1125
+ /**
1126
+ * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1127
+ * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1128
+ * applied here for the first time.
1129
+ *
1130
+ * This type said `string` for every `bytes` body whatever it was served as. A server reading
1131
+ * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1132
+ * handlers against these types could not typecheck against a correct server. It is the
1133
+ * request half of the corruption already fixed on the reader: decoding raw bytes as text
1134
+ * replaces every byte outside ASCII, and typing them as text is what invites it.
1135
+ */
1136
+ const rawBodyType = isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])])
1137
+ ? "ArrayBuffer"
1138
+ : "string";
1096
1139
  entries.push({
1097
1140
  operationId: resolveOperationId(program, operation.operation),
1098
1141
  bodyRef: rawBody !== undefined
1099
- ? `{\n\t${rawBody}: string;\n}`
1142
+ ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1100
1143
  : body === undefined
1101
1144
  ? undefined
1102
1145
  : registry.expressionFor(body),
@@ -112,7 +112,8 @@ export declare class TypeRegistry {
112
112
  * `ModelProperty`s belonging to no model. Without routing them through the registry a named model
113
113
  * or enum in a parameter position is inlined rather than referenced.
114
114
  */
115
- expressionForProperty(property: ModelProperty): string;
115
+ /** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
116
+ expressionForProperty(property: ModelProperty, wireName?: string): string;
116
117
  /** Every declaration, dependencies first. */
117
118
  declarations(): readonly TsDeclaration[];
118
119
  }
@@ -404,12 +404,13 @@ export class TypeRegistry {
404
404
  * `ModelProperty`s belonging to no model. Without routing them through the registry a named model
405
405
  * or enum in a parameter position is inlined rather than referenced.
406
406
  */
407
- expressionForProperty(property) {
407
+ /** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
408
+ expressionForProperty(property, wireName) {
408
409
  return withTsRefResolver((candidate) => {
409
410
  if (declaredNameOf(candidate) === undefined)
410
411
  return undefined;
411
412
  return this.expressionFor(candidate);
412
- }, () => propertyToTs(this.#program, property));
413
+ }, () => propertyToTs(this.#program, property, wireName));
413
414
  }
414
415
  #inline(type) {
415
416
  return withTsRefResolver((candidate) => {
@@ -1,6 +1,15 @@
1
1
  import { type ModelProperty, type Program, type Type } from "@typespec/compiler";
2
- /** A property line, including its optionality marker. */
3
- export declare function propertyToTs(program: Program, property: ModelProperty): string;
2
+ /**
3
+ * A property line, including its optionality marker.
4
+ *
5
+ * `wireName` overrides the key for an HTTP PARAMETER, whose wire name comes from `@header("x-thing")`
6
+ * or `@query("$select")` rather than from any JSON encoding. `resolveEncodedName` cannot see those,
7
+ * so a header renamed on the wire was keyed here by its TypeScript property name while the emitted
8
+ * validator keyed it by the wire name. A consumer checking its handlers against the generated input
9
+ * types then failed to typecheck against a server that was correct: `z.object({ "x-thing": ... })`
10
+ * on one side and `{ thing: string }` on the other.
11
+ */
12
+ export declare function propertyToTs(program: Program, property: ModelProperty, wireName?: string): string;
4
13
  export declare function withTsRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
5
14
  export declare function typeToTs(program: Program, type: Type): string;
6
15
  /**
package/dist/src/types.js CHANGED
@@ -199,11 +199,20 @@ function modelToTs(program, model) {
199
199
  */
200
200
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
201
201
  }
202
- /** A property line, including its optionality marker. */
203
- export function propertyToTs(program, property) {
202
+ /**
203
+ * A property line, including its optionality marker.
204
+ *
205
+ * `wireName` overrides the key for an HTTP PARAMETER, whose wire name comes from `@header("x-thing")`
206
+ * or `@query("$select")` rather than from any JSON encoding. `resolveEncodedName` cannot see those,
207
+ * so a header renamed on the wire was keyed here by its TypeScript property name while the emitted
208
+ * validator keyed it by the wire name. A consumer checking its handlers against the generated input
209
+ * types then failed to typecheck against a server that was correct: `z.object({ "x-thing": ... })`
210
+ * on one side and `{ thing: string }` on the other.
211
+ */
212
+ export function propertyToTs(program, property, wireName) {
204
213
  // The WIRE name, resolved exactly as `zod.ts` resolves it - `wire-contract.gen.ts` asserts the
205
214
  // emitted Zod infers this type, so the two must name every property identically or it fails.
206
- const key = objectKey(resolveEncodedName(program, property, "application/json"));
215
+ const key = objectKey(wireName ?? resolveEncodedName(program, property, "application/json"));
207
216
  // `@encode` changes the wire TYPE, resolved exactly as `zod.ts` resolves it - a property encoded as
208
217
  // a string on one side and an array on the other fails the emitted assertion, and deserves to.
209
218
  const encoded = getEncode(program, property)?.type;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",