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 +2 -0
- package/dist/src/api.js +52 -9
- package/dist/src/registry.d.ts +2 -1
- package/dist/src/registry.js +3 -2
- package/dist/src/types.d.ts +11 -2
- package/dist/src/types.js +12 -3
- package/package.json +1 -1
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
|
1089
|
-
* a bare
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
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}:
|
|
1142
|
+
? `{\n\t${rawBody}: ${rawBodyType};\n}`
|
|
1100
1143
|
: body === undefined
|
|
1101
1144
|
? undefined
|
|
1102
1145
|
: registry.expressionFor(body),
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/src/registry.js
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { type ModelProperty, type Program, type Type } from "@typespec/compiler";
|
|
2
|
-
/**
|
|
3
|
-
|
|
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
|
-
/**
|
|
203
|
-
|
|
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.
|
|
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",
|