typespec-http-zod 0.21.0 → 0.23.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 +14 -0
- package/dist/src/api.js +110 -10
- package/dist/src/lib.d.ts +10 -1
- package/dist/src/lib.js +36 -0
- package/dist/src/runtime.d.ts +9 -1
- package/dist/src/types.js +28 -10
- package/dist/src/zod.js +35 -9
- package/package.json +1 -1
package/dist/src/api.d.ts
CHANGED
|
@@ -263,6 +263,8 @@ export interface EmittedRoute {
|
|
|
263
263
|
readonly headers: readonly {
|
|
264
264
|
readonly name: string;
|
|
265
265
|
readonly property: string;
|
|
266
|
+
/** The TypeScript type of the value, so a server emitter can put it in a signature. */
|
|
267
|
+
readonly type: string;
|
|
266
268
|
}[];
|
|
267
269
|
}[];
|
|
268
270
|
/**
|
|
@@ -302,6 +304,18 @@ export interface EmittedRoute {
|
|
|
302
304
|
value: boolean | string;
|
|
303
305
|
status: number;
|
|
304
306
|
} | undefined;
|
|
307
|
+
/**
|
|
308
|
+
* The property a handler sets to choose between several declared success statuses, and the
|
|
309
|
+
* statuses it may carry - read from a `@statusCode` typed as a union of literals.
|
|
310
|
+
*
|
|
311
|
+
* **Published because the handler has to be able to SAY it.** The property is HTTP metadata, so it
|
|
312
|
+
* is stripped from the body schema (correctly - it is not body), which means a server emitter has
|
|
313
|
+
* to add it back to the type a handler returns or the arms name something unsatisfiable.
|
|
314
|
+
*/
|
|
315
|
+
readonly statusSelector: {
|
|
316
|
+
property: string;
|
|
317
|
+
statuses: readonly number[];
|
|
318
|
+
} | undefined;
|
|
305
319
|
/** The schema for the SECOND success status, when the operation declares two distinct arms. */
|
|
306
320
|
readonly alternateResponseSchema: string | undefined;
|
|
307
321
|
}
|
package/dist/src/api.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
2
|
-
import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
2
|
+
import { getAuthenticationForOperation, isStatusCode, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
3
|
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
4
|
import { reportDiagnostic } from "./lib.js";
|
|
5
5
|
import { SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
6
|
+
import { typeToTs } from "./types.js";
|
|
6
7
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
7
8
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
8
9
|
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
@@ -306,7 +307,7 @@ function isSuccessKey(status) {
|
|
|
306
307
|
* body and gives each one under its WIRE name with the declaring property behind it. Deriving either
|
|
307
308
|
* name any other way would be a second spelling of a fact the compiler already resolved.
|
|
308
309
|
*/
|
|
309
|
-
function responseHeadersOf(operation) {
|
|
310
|
+
function responseHeadersOf(program, operation) {
|
|
310
311
|
const byStatus = new Map();
|
|
311
312
|
for (const response of operation.responses) {
|
|
312
313
|
const status = response.statusCodes;
|
|
@@ -316,7 +317,14 @@ function responseHeadersOf(operation) {
|
|
|
316
317
|
for (const content of response.responses) {
|
|
317
318
|
for (const [name, property] of Object.entries(content.headers ?? {})) {
|
|
318
319
|
if (!headers.some((existing) => existing.name === name)) {
|
|
319
|
-
|
|
320
|
+
/**
|
|
321
|
+
* **The TYPE too, because a handler has to be able to SET this.** A `@header`
|
|
322
|
+
* property is stripped from the body schema - correctly, it is not body - so a server
|
|
323
|
+
* emitter has to put it back into the type a handler returns, and it cannot do that
|
|
324
|
+
* from a name alone. Measured before this existed: an arm naming `contentType` and
|
|
325
|
+
* `metadata` against a handler declared `Awaitable<Result<void>>`.
|
|
326
|
+
*/
|
|
327
|
+
headers.push({ name, property: property.name, type: typeToTs(program, property.type) });
|
|
320
328
|
}
|
|
321
329
|
}
|
|
322
330
|
}
|
|
@@ -579,6 +587,36 @@ function statusDiscriminatorOf(operation, statuses) {
|
|
|
579
587
|
}
|
|
580
588
|
return undefined;
|
|
581
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* How a handler says WHICH success status it means, when the spec declares more than one.
|
|
592
|
+
*
|
|
593
|
+
* **The `@statusCode` property is the selector, and it is the one the spec already wrote.**
|
|
594
|
+
* `model Created { @statusCode statusCode: 200 | 201; @body body: Item }` declares both statuses and
|
|
595
|
+
* names the property that chooses between them, so nothing has to be inferred from the body's shape.
|
|
596
|
+
*
|
|
597
|
+
* That inference is what {@link statusDiscriminatorOf} does, and it needs a required literal property
|
|
598
|
+
* on one of the bodies. Measured across the whole conformance corpus: it fires ZERO times, so before
|
|
599
|
+
* this the second arm had never been emitted by any spec at all - `armFor` could not select a status
|
|
600
|
+
* that was never written. Both remain: a discriminator still serves two DIFFERENT response models,
|
|
601
|
+
* this serves one model declaring a union of statuses.
|
|
602
|
+
*
|
|
603
|
+
* All statuses of such a model share one body by construction - it is one model - so every arm
|
|
604
|
+
* carries the same schema and only the status and the selector value differ.
|
|
605
|
+
*/
|
|
606
|
+
function statusSelectorOf(program, operation, statuses) {
|
|
607
|
+
if (statuses.length < 2)
|
|
608
|
+
return undefined;
|
|
609
|
+
for (const response of operation.responses) {
|
|
610
|
+
const type = response.type;
|
|
611
|
+
if (type === undefined || type.kind !== "Model")
|
|
612
|
+
continue;
|
|
613
|
+
for (const [name, property] of type.properties) {
|
|
614
|
+
if (isStatusCode(program, property))
|
|
615
|
+
return { property: name, statuses };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
582
620
|
/** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
|
|
583
621
|
function alternateSchemaFor(operation, status, registry) {
|
|
584
622
|
const body = successBodyOf(operation, status);
|
|
@@ -1039,6 +1077,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1039
1077
|
const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
|
|
1040
1078
|
const statusCodes = successStatusesOf(operation);
|
|
1041
1079
|
const discriminator = statusDiscriminatorOf(operation, statusCodes);
|
|
1080
|
+
const statusSelector = statusSelectorOf(program, operation, statusCodes);
|
|
1042
1081
|
/**
|
|
1043
1082
|
* A `bytes` body means the BYTES are the contract.
|
|
1044
1083
|
*
|
|
@@ -1145,7 +1184,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1145
1184
|
// them under the ambient request visibility declared six components under the wrong
|
|
1146
1185
|
// suffix - measured: type/model/visibility gained six divergences.
|
|
1147
1186
|
errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
|
|
1148
|
-
responseHeaders: responseHeadersOf(operation),
|
|
1187
|
+
responseHeaders: responseHeadersOf(program, operation),
|
|
1149
1188
|
responseMediaTypes: responseMediaTypesOf(operation),
|
|
1150
1189
|
scopes: [
|
|
1151
1190
|
...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
|
|
@@ -1154,6 +1193,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1154
1193
|
],
|
|
1155
1194
|
noAuth: noAuthFor(program, operation),
|
|
1156
1195
|
statusBy: discriminator,
|
|
1196
|
+
statusSelector,
|
|
1157
1197
|
/**
|
|
1158
1198
|
* **Each arm is validated against its OWN shape.**
|
|
1159
1199
|
*
|
|
@@ -1242,7 +1282,32 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1242
1282
|
const declared = d.structural.startsWith("{")
|
|
1243
1283
|
? `export interface ${d.typeName} ${d.structural}`
|
|
1244
1284
|
: `export type ${d.typeName} = ${d.structural};`;
|
|
1245
|
-
|
|
1285
|
+
/**
|
|
1286
|
+
* **BOTH of Zod's type parameters, and naming only the first left the second as `unknown`.**
|
|
1287
|
+
*
|
|
1288
|
+
* `z.ZodType<T>` is `ZodType<Output = T, Input = unknown>`, so a schema annotated with one
|
|
1289
|
+
* parameter reports `z.input<typeof x>` as `unknown` - and every schema that REFERENCES it
|
|
1290
|
+
* inherits that. Measured: `Identical<Declared<z.input<typeof nodeSchema>>, Contracts.Node>`
|
|
1291
|
+
* failed on `branch?: unknown | undefined` against `branch?: Branch | undefined`, in a fixture
|
|
1292
|
+
* whose two artefacts agree about every property. The wire assertion pairs `z.input` because
|
|
1293
|
+
* that is what a caller supplies, so an unannotated input parameter takes the whole cyclic
|
|
1294
|
+
* corner of the emitter out of that comparison.
|
|
1295
|
+
*
|
|
1296
|
+
* **One structural type serves both positions, and for a defaulted property on a cycle it is
|
|
1297
|
+
* the INPUT shape that both get.** A default is the only construct where a schema's input and
|
|
1298
|
+
* output types differ, and a cyclic declaration has exactly one type written out to name - the
|
|
1299
|
+
* loop is what stops `z.infer` being usable at all. Claiming the input shape in the output
|
|
1300
|
+
* position is the PERMISSIVE direction: every value that arrives still satisfies it, a reader
|
|
1301
|
+
* simply learns less than it could about a defaulted property. Claiming the output shape in
|
|
1302
|
+
* the input position is the restrictive one, and it is the defect this release exists to
|
|
1303
|
+
* remove - a caller told to supply what the document says they may omit.
|
|
1304
|
+
*
|
|
1305
|
+
* So the limitation is confined to this: a model on a cycle carrying a defaulted optional
|
|
1306
|
+
* property exports a type from `schemas.gen.ts` that is weaker than what arrives, by exactly
|
|
1307
|
+
* that property. `typespec-hono` derives its handler input from `z.infer` of the schema rather
|
|
1308
|
+
* than from this type, so no handler signature is affected.
|
|
1309
|
+
*/
|
|
1310
|
+
const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}, ${d.typeName}>` : "";
|
|
1246
1311
|
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
|
|
1247
1312
|
});
|
|
1248
1313
|
/**
|
|
@@ -1279,7 +1344,9 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1279
1344
|
* So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
|
|
1280
1345
|
* one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
|
|
1281
1346
|
* may SEND. A reader comparing them will find them unequal and should - the emitted assertion
|
|
1282
|
-
* pairs the contract against raw `z.
|
|
1347
|
+
* pairs the contract against raw `z.input`, not against this. They differ on a second axis for
|
|
1348
|
+
* the same reason: a defaulted property is present here, because a default has fired by the time
|
|
1349
|
+
* a value arrives, and optional in the contract type, because a caller may omit it.
|
|
1283
1350
|
*
|
|
1284
1351
|
* Derived from the same schema rather than hand-written beside it, so there is still one source of
|
|
1285
1352
|
* truth: a mapped type cannot drift from the thing it maps.
|
|
@@ -1461,7 +1528,22 @@ function responseArmsOf(route, response, alternate) {
|
|
|
1461
1528
|
.join(", ");
|
|
1462
1529
|
return `, headers: [${rendered}]`;
|
|
1463
1530
|
};
|
|
1464
|
-
if (route.
|
|
1531
|
+
if (route.statusSelector !== undefined) {
|
|
1532
|
+
/**
|
|
1533
|
+
* **One arm per declared status, keyed on the property the spec already named.**
|
|
1534
|
+
*
|
|
1535
|
+
* The selector arms come first and the default last, the same order the discriminator below
|
|
1536
|
+
* uses: anything scanning for a match wants the specific ones before the fallback. Every arm
|
|
1537
|
+
* carries the same schema because a `@statusCode` union is ONE model - the statuses differ,
|
|
1538
|
+
* the body does not.
|
|
1539
|
+
*/
|
|
1540
|
+
const { property, statuses } = route.statusSelector;
|
|
1541
|
+
for (const status of statuses.filter((candidate) => candidate !== route.statusCode)) {
|
|
1542
|
+
arms.push(`{ status: ${status}, schema: ${primary}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${status} } }`);
|
|
1543
|
+
}
|
|
1544
|
+
arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
|
|
1545
|
+
}
|
|
1546
|
+
else if (route.statusBy === undefined) {
|
|
1465
1547
|
arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
|
|
1466
1548
|
}
|
|
1467
1549
|
else {
|
|
@@ -1740,8 +1822,26 @@ ${responseLookup}
|
|
|
1740
1822
|
* both look right in isolation. That happened during this emitter's first run: a vocabulary in a
|
|
1741
1823
|
* query-parameter position was inlined by one walk and referenced by the other.
|
|
1742
1824
|
*
|
|
1743
|
-
*
|
|
1744
|
-
*
|
|
1825
|
+
* **`z.input`, not `z.infer`, and the difference is the whole caller-side question.** `z.infer` is
|
|
1826
|
+
* the OUTPUT type - what a handler receives, after `.default(...)` has fired. These contract types
|
|
1827
|
+
* are the floor a PRODUCER supplies, and for a request the producer is the caller, who has not run
|
|
1828
|
+
* the validator. `z.input` is what the validator ACCEPTS, which is what a request contract states
|
|
1829
|
+
* and what the document's `required` list says. Measured on zod 4.4.3: `.default()` is the only
|
|
1830
|
+
* construct this emitter emits where the two differ - `.optional()`, `.nullable()` and every
|
|
1831
|
+
* constraint infer identically on both sides.
|
|
1832
|
+
*
|
|
1833
|
+
* Once `default-on-required-property` has removed the incoherent spelling, `z.input` and the
|
|
1834
|
+
* document's `required` list agree property for property, so this assertion cross-checks that rule
|
|
1835
|
+
* rather than merely restating one side of it.
|
|
1836
|
+
*
|
|
1837
|
+
* **One measured exception, which does not reach here.** `.refine()` with a type predicate narrows
|
|
1838
|
+
* the OUTPUT only - zod types it `this & ZodType<R, core.input<this>>` - so `z.input` of a
|
|
1839
|
+
* `HttpPart<File>` part is `unknown` where `z.infer` is the narrowed file shape. A multipart body's
|
|
1840
|
+
* contract type is an inline object literal rather than a named declaration, so it is never in the
|
|
1841
|
+
* paired set below; if that ever changes, this is the pairing that breaks first.
|
|
1842
|
+
*
|
|
1843
|
+
* `Equals` here, not assignability: the two describe one shape, and a property present on one side
|
|
1844
|
+
* and absent from the other is exactly the drift this exists to catch.
|
|
1745
1845
|
*/
|
|
1746
1846
|
function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
1747
1847
|
// The assertions pair the emitted Zod against the request TYPES, which live in the contracts
|
|
@@ -1763,7 +1863,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
|
1763
1863
|
}
|
|
1764
1864
|
if (paired.length === 0)
|
|
1765
1865
|
return undefined;
|
|
1766
|
-
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.
|
|
1866
|
+
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
|
|
1767
1867
|
return `${generatedBanner()}
|
|
1768
1868
|
import type { z } from "zod";
|
|
1769
1869
|
import type * as Contracts from "${contractsPackage}";
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -119,6 +119,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
119
119
|
"unsupported-default": {
|
|
120
120
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
121
121
|
};
|
|
122
|
+
"default-on-required-property": {
|
|
123
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
124
|
+
};
|
|
122
125
|
"undeclared-discriminator": {
|
|
123
126
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
124
127
|
};
|
|
@@ -141,13 +144,16 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
141
144
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
142
145
|
};
|
|
143
146
|
}, EmitterOptions, never>;
|
|
144
|
-
export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
147
|
+
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
145
148
|
"unsupported-type": {
|
|
146
149
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
147
150
|
};
|
|
148
151
|
"unsupported-default": {
|
|
149
152
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
150
153
|
};
|
|
154
|
+
"default-on-required-property": {
|
|
155
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
156
|
+
};
|
|
151
157
|
"undeclared-discriminator": {
|
|
152
158
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
153
159
|
};
|
|
@@ -176,6 +182,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
|
|
|
176
182
|
"unsupported-default": {
|
|
177
183
|
readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
|
|
178
184
|
};
|
|
185
|
+
"default-on-required-property": {
|
|
186
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
187
|
+
};
|
|
179
188
|
"undeclared-discriminator": {
|
|
180
189
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
181
190
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -76,6 +76,42 @@ const diagnostics = {
|
|
|
76
76
|
default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. Replace it with a literal value, or drop the default and let the property be optional. The property keeps its declared shape and loses only the fallback.`,
|
|
77
77
|
},
|
|
78
78
|
},
|
|
79
|
+
/**
|
|
80
|
+
* **A default on a REQUIRED property, where the default can never apply.**
|
|
81
|
+
*
|
|
82
|
+
* **`default` is an annotation and `required` is the assertion**, and this emitter used to
|
|
83
|
+
* implement the opposite. JSON Schema 2020-12 section 9.2 makes `default` an annotation keyword
|
|
84
|
+
* with no effect on validation, and `@typespec/openapi3` implements exactly that:
|
|
85
|
+
* `#requiredModelProperties` builds the `required` list from `metadataInfo.isOptional` alone and
|
|
86
|
+
* never consults a default, then attaches the default beside it as a plain `schema.default`. The
|
|
87
|
+
* parameter side is the same rule spelled differently, `required: !param.optional`.
|
|
88
|
+
*
|
|
89
|
+
* So `basis: string = "s1159"` publishes as REQUIRED with an annotation nothing can reach. This
|
|
90
|
+
* emitter emitted `.default("s1159")`, which makes the field optional on the way IN - a validator
|
|
91
|
+
* accepting a request the document generated beside it forbids. OAI/OpenAPI-Specification#1543 is
|
|
92
|
+
* closed COMPLETED on this exact question, with the JSON Schema editor's answer that writing a
|
|
93
|
+
* default in before validating "is **NOT** part of validation", and the OpenAPI maintainer's that
|
|
94
|
+
* a default beside `required` "risks making the `required` notion invalid and encourages clients
|
|
95
|
+
* to unnecessarily send `default` values over the wire". Swagger's own documentation lists the
|
|
96
|
+
* combination as one of the two common mistakes with the keyword.
|
|
97
|
+
*
|
|
98
|
+
* **A WARNING, and this package's first, because the spec is representable.** `unsupported-default`
|
|
99
|
+
* used to be an error on composite literals and was retired for precisely this reason: refusing a
|
|
100
|
+
* construct `@typespec/openapi3` emits makes the same spec representable by one emitter and not the
|
|
101
|
+
* other, which is the one thing a differential between the two cannot tolerate. openapi3 emits this
|
|
102
|
+
* one, so this emitter serves it - saying what the document says, and saying out loud that the
|
|
103
|
+
* annotation is dead. The fix is one character and the message names it.
|
|
104
|
+
*
|
|
105
|
+
* The predicate is the DOCUMENT's, `metadataInfo.isOptional`, so this fires exactly where openapi3
|
|
106
|
+
* writes the name into `required` - including the PATCH position, where an update body's properties
|
|
107
|
+
* are optional and a default on one of them is live rather than dead.
|
|
108
|
+
*/
|
|
109
|
+
"default-on-required-property": {
|
|
110
|
+
severity: "warning",
|
|
111
|
+
messages: {
|
|
112
|
+
default: paramMessage `'${"name"}' is required, so its default can never apply: a required property is never absent. The OpenAPI document publishes it in 'required' and carries the default only as an annotation, and this validator does the same - a request omitting '${"name"}' is refused. Declare it optional, '${"name"}?: ... = ...', if callers may omit it.`,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
79
115
|
/**
|
|
80
116
|
* **A discriminated union whose discriminator the document never publishes.**
|
|
81
117
|
*
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -78,9 +78,17 @@ export interface ResponseArm {
|
|
|
78
78
|
*/
|
|
79
79
|
readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
80
80
|
readonly schema: ZodType | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* How a handler said WHICH arm it meant, where an operation declares more than one success.
|
|
83
|
+
*
|
|
84
|
+
* **`number` too, because a `@statusCode` union selects by the status itself.**
|
|
85
|
+
* `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses and its
|
|
86
|
+
* values are the statuses; the discriminator case carries a boolean or string literal off the body
|
|
87
|
+
* instead. Both answer the same question, so both use this one field.
|
|
88
|
+
*/
|
|
81
89
|
readonly when?: {
|
|
82
90
|
readonly property: string;
|
|
83
|
-
readonly value: boolean | string;
|
|
91
|
+
readonly value: boolean | number | string;
|
|
84
92
|
};
|
|
85
93
|
}
|
|
86
94
|
/**
|
package/dist/src/types.js
CHANGED
|
@@ -13,9 +13,12 @@ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, obj
|
|
|
13
13
|
*
|
|
14
14
|
* **It must agree with `zod.ts` exactly**, because the emitted assertion compares them. Every rule
|
|
15
15
|
* here has a counterpart there: `utcDateTime` is a string on both sides, `?` becomes
|
|
16
|
-
* `?: T | undefined` because `exactOptionalPropertyTypes` is on, and a
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* `?: T | undefined` because `exactOptionalPropertyTypes` is on, and a property the document leaves
|
|
17
|
+
* out of `required` is optional here whether its absence is spelled `?` or covered by a default.
|
|
18
|
+
*
|
|
19
|
+
* **The assertion pairs this walk against `z.input`, the type the validator ACCEPTS**, and not
|
|
20
|
+
* against `z.infer`, which is what it produces. These types are the floor a producer supplies, and
|
|
21
|
+
* for a request the producer is a caller who has not run the validator.
|
|
19
22
|
*/
|
|
20
23
|
/** Scalars we accept, and the TypeScript they become. Mirrors `SCALARS` in `zod.ts`. */
|
|
21
24
|
const SCALARS = {
|
|
@@ -247,15 +250,30 @@ export function propertyToTs(program, property, wireName) {
|
|
|
247
250
|
const encoded = getEncode(program, property)?.type;
|
|
248
251
|
const value = typeToTs(program, encoded ?? property.type);
|
|
249
252
|
/**
|
|
250
|
-
* **A defaulted property
|
|
253
|
+
* **A defaulted property used to be REQUIRED here, and that was the OUTPUT type's answer to a
|
|
254
|
+
* question this file does not ask.**
|
|
255
|
+
*
|
|
256
|
+
* The reasoning was: `zod.ts` emits `.default(v)`, the assertion compares against `z.infer` which
|
|
257
|
+
* is the output type, and by then the default has fired. Every step of that is true and the
|
|
258
|
+
* conclusion served the wrong reader. **These types are the floor a PRODUCER supplies**, and for
|
|
259
|
+
* a request the producer is the CALLER - who has not run the validator, and for whom the document
|
|
260
|
+
* is the only contract. The document leaves an optional defaulted property out of `required`, so
|
|
261
|
+
* a caller may omit it, and this file said they could not.
|
|
262
|
+
*
|
|
263
|
+
* Reported by a consumer building requests from a zero-dependency contracts package, carrying a
|
|
264
|
+
* named helper to relax six such properties by hand. The helper could only reach the top level; a
|
|
265
|
+
* default one level down stayed required and no caller could build the value at all. Emitting the
|
|
266
|
+
* optionality per property fixes every depth at once, because there is no depth to reach.
|
|
267
|
+
*
|
|
268
|
+
* **The two directions of the emitted set differ here on purpose**, exactly as they do on
|
|
269
|
+
* openness: `schemas.gen.ts` exports `Exact<z.infer<...>>`, the narrow view of what ARRIVES, in
|
|
270
|
+
* which a default has fired and the property is present. `wire-contract.gen.ts` pairs this walk
|
|
271
|
+
* against `z.input` for the same reason.
|
|
251
272
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* Marking it optional would make a correct schema fail its own assertion.
|
|
273
|
+
* A defaulted property that reaches here is one the document publishes as optional, because
|
|
274
|
+
* `propertyToZod` reports `default-on-required-property` for the other spelling and emits no
|
|
275
|
+
* `.default(...)` for it - so the two walks agree without either one restating the rule.
|
|
256
276
|
*/
|
|
257
|
-
if (property.defaultValue !== undefined)
|
|
258
|
-
return `${key}: ${value};`;
|
|
259
277
|
/**
|
|
260
278
|
* **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
|
|
261
279
|
*
|
package/dist/src/zod.js
CHANGED
|
@@ -978,14 +978,6 @@ export function propertyToZod(program, property) {
|
|
|
978
978
|
const base = nullable === undefined || encoded !== undefined
|
|
979
979
|
? applyConstraints(program, typeToZod(program, declared), property)
|
|
980
980
|
: `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
|
|
981
|
-
/**
|
|
982
|
-
* A TypeSpec default (`basis: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
|
|
983
|
-
* the field optional on the way in. Dropping it turned an optional field with a fallback into a
|
|
984
|
-
* required one - callers omitting it would start getting a 400 for a request that used to work.
|
|
985
|
-
*/
|
|
986
|
-
const defaulted = defaultOf(program, property);
|
|
987
|
-
if (defaulted !== undefined)
|
|
988
|
-
return `${base}.default(${defaulted})`;
|
|
989
981
|
/**
|
|
990
982
|
* **Optionality is a function of the position too, not only of the `?` in the spec.**
|
|
991
983
|
*
|
|
@@ -995,7 +987,41 @@ export function propertyToZod(program, property) {
|
|
|
995
987
|
* would reject every partial update the contract invites. `metadataInfo.isOptional` is the same
|
|
996
988
|
* predicate `@typespec/openapi3` uses to decide the `required` list.
|
|
997
989
|
*/
|
|
998
|
-
|
|
990
|
+
const optional = property.optional || isOptionalAt(program, property);
|
|
991
|
+
/**
|
|
992
|
+
* **A default only applies where the document says the property may be absent.**
|
|
993
|
+
*
|
|
994
|
+
* `.default(v)` makes a Zod field optional on the way IN, so emitting one for a property the
|
|
995
|
+
* document publishes as `required` made this validator accept a request the document beside it
|
|
996
|
+
* refuses. `default` is an ANNOTATION under JSON Schema 2020-12 and `required` is the assertion;
|
|
997
|
+
* `@typespec/openapi3` reads the two exactly that way, building `required` from
|
|
998
|
+
* `metadataInfo.isOptional` and never consulting a default. See `default-on-required-property`
|
|
999
|
+
* in `lib.ts` for the specification chain and for why this warns rather than refuses.
|
|
1000
|
+
*
|
|
1001
|
+
* Reported and then rendered as an ordinary required property - the declared shape is untouched
|
|
1002
|
+
* and only the dead fallback is dropped, which is the same shape `unsupported-default` already
|
|
1003
|
+
* takes. `defaultOf` is deliberately NOT called on this path: it would report a second diagnostic
|
|
1004
|
+
* about a value that cannot be reached either way, naming the less useful of the two problems.
|
|
1005
|
+
*/
|
|
1006
|
+
if (!optional && property.defaultValue !== undefined) {
|
|
1007
|
+
reportDiagnostic(program, {
|
|
1008
|
+
code: "default-on-required-property",
|
|
1009
|
+
target: property,
|
|
1010
|
+
format: { name: property.name },
|
|
1011
|
+
});
|
|
1012
|
+
return base;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* A TypeSpec default (`basis?: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
|
|
1016
|
+
* the field optional on the way in - matching a document that leaves the property out of
|
|
1017
|
+
* `required` and annotates it with the value. `.optional()` is not added beside it: `.default()`
|
|
1018
|
+
* already accepts an absent value, and the two together would make the OUTPUT optional as well,
|
|
1019
|
+
* so the fallback could be skipped.
|
|
1020
|
+
*/
|
|
1021
|
+
const defaulted = defaultOf(program, property);
|
|
1022
|
+
if (defaulted !== undefined)
|
|
1023
|
+
return `${base}.default(${defaulted})`;
|
|
1024
|
+
return optional ? `${base}.optional()` : base;
|
|
999
1025
|
}
|
|
1000
1026
|
/**
|
|
1001
1027
|
* A default value as a JS literal, or `undefined` where this emitter cannot render one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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",
|