typespec-http-zod 0.11.0 → 0.12.1
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 +3 -0
- package/dist/src/api.js +74 -8
- package/dist/src/lib.d.ts +10 -0
- package/dist/src/lib.js +2 -0
- package/package.json +1 -1
package/dist/src/api.d.ts
CHANGED
|
@@ -43,6 +43,7 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
|
|
|
43
43
|
* independently.
|
|
44
44
|
*/
|
|
45
45
|
export { objectKey } from "./zod.js";
|
|
46
|
+
export declare function beginBanner(hint: string | undefined): void;
|
|
46
47
|
/**
|
|
47
48
|
* The status a row registers: the first non-error status the operation declares.
|
|
48
49
|
*
|
|
@@ -328,6 +329,8 @@ export interface ResolvedServiceOptions {
|
|
|
328
329
|
readonly sealObjectSchemas: boolean;
|
|
329
330
|
readonly keyVocabularies: readonly string[];
|
|
330
331
|
readonly runtimeModule: string;
|
|
332
|
+
/** The command that regenerates, for the banner. `undefined` keeps the generic line. */
|
|
333
|
+
readonly regenerateHint: string | undefined;
|
|
331
334
|
}
|
|
332
335
|
/** What the generated files import their runtime contract from when nothing says otherwise. */
|
|
333
336
|
export declare const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
package/dist/src/api.js
CHANGED
|
@@ -60,9 +60,30 @@ const PACKAGE_NAME = "typespec-http-zod";
|
|
|
60
60
|
* The package name stays, because that IS the answer to "what wrote this". The instruction becomes
|
|
61
61
|
* the one thing true for every consumer: recompile the source, do not edit the output.
|
|
62
62
|
*/
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
/**
|
|
64
|
+
* How to regenerate, for the banner. Set per emission; `undefined` keeps the generic line.
|
|
65
|
+
*
|
|
66
|
+
* Module-scoped rather than threaded through four render functions, matching how the contracts
|
|
67
|
+
* package and the stream module are carried. The emission loop awaits each service to completion
|
|
68
|
+
* before the next begins, so nothing interleaves.
|
|
69
|
+
*/
|
|
70
|
+
let regenerateHint;
|
|
71
|
+
export function beginBanner(hint) {
|
|
72
|
+
regenerateHint = hint;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The header every emitted file carries.
|
|
76
|
+
*
|
|
77
|
+
* **`DO NOT EDIT` says what not to do and not what to do instead**, and only the project knows the
|
|
78
|
+
* command that regenerates. Where it states one, the banner carries it; otherwise the generic line
|
|
79
|
+
* stands.
|
|
80
|
+
*/
|
|
81
|
+
function generatedBanner() {
|
|
82
|
+
const second = regenerateHint === undefined
|
|
83
|
+
? "// Recompile the spec that produced it; edits here are overwritten on the next run."
|
|
84
|
+
: `// Regenerate with: ${regenerateHint}`;
|
|
85
|
+
return `// GENERATED by ${PACKAGE_NAME} from the TypeSpec service definition. DO NOT EDIT.\n${second}\n`;
|
|
86
|
+
}
|
|
66
87
|
/**
|
|
67
88
|
* The status a row registers: the first non-error status the operation declares.
|
|
68
89
|
*
|
|
@@ -1110,7 +1131,7 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1110
1131
|
* with no cycle emits byte-identical output to before.
|
|
1111
1132
|
*/
|
|
1112
1133
|
if (d.structural === undefined) {
|
|
1113
|
-
return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}
|
|
1134
|
+
return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = Exact<z.infer<typeof ${d.identifier}>>;\n`;
|
|
1114
1135
|
}
|
|
1115
1136
|
const declared = d.structural.startsWith("{")
|
|
1116
1137
|
? `export interface ${d.typeName} ${d.structural}`
|
|
@@ -1134,8 +1155,28 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1134
1155
|
* inside `zValidator` or missed beside a bracket. `renderExternalImports` below reads the same
|
|
1135
1156
|
* rendered content for the same reason.
|
|
1136
1157
|
*/
|
|
1158
|
+
/**
|
|
1159
|
+
* **`Exact` narrows an inferred optional to what the wire can carry.**
|
|
1160
|
+
*
|
|
1161
|
+
* `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
|
|
1162
|
+
* admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
|
|
1163
|
+
* same thing by leaving the property out of `required`, so the inferred type was wider than both
|
|
1164
|
+
* the contract and the wire and a consumer had to strip it at the boundary.
|
|
1165
|
+
*
|
|
1166
|
+
* Derived from the same schema rather than hand-written beside it, so there is still one source of
|
|
1167
|
+
* truth: a mapped type cannot drift from the thing it maps.
|
|
1168
|
+
*
|
|
1169
|
+
* Emitted only where a declaration names it, like the imports beside it.
|
|
1170
|
+
*/
|
|
1171
|
+
const EXACT_HELPER = `
|
|
1172
|
+
/** An inferred type narrowed to what JSON can carry: an optional property is absent or a value. */
|
|
1173
|
+
type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
|
|
1174
|
+
[K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
|
|
1175
|
+
};
|
|
1176
|
+
`;
|
|
1177
|
+
const usesExact = declarations.some((d) => d.includes("Exact<"));
|
|
1137
1178
|
const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
|
|
1138
|
-
const parts = [
|
|
1179
|
+
const parts = [generatedBanner()];
|
|
1139
1180
|
if (usesZod)
|
|
1140
1181
|
parts.push('\nimport { z } from "zod";\n');
|
|
1141
1182
|
/**
|
|
@@ -1150,6 +1191,8 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1150
1191
|
parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
|
|
1151
1192
|
}
|
|
1152
1193
|
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
1194
|
+
if (usesExact)
|
|
1195
|
+
parts.push(EXACT_HELPER);
|
|
1153
1196
|
parts.push(declarations.join(""));
|
|
1154
1197
|
if (routeDeclarations.length > 0)
|
|
1155
1198
|
parts.push(`\n${routeDeclarations.join("\n")}\n`);
|
|
@@ -1317,7 +1360,28 @@ function responseArmsOf(route, response, alternate) {
|
|
|
1317
1360
|
* declares. Each arm resolves its own body now, so an arm with no schema is a response the document
|
|
1318
1361
|
* says carries no body, and saying `undefined` is the truthful answer rather than a guess.
|
|
1319
1362
|
*/
|
|
1363
|
+
/**
|
|
1364
|
+
* **An error arm whose status the primary arm already answers is dropped, primary winning.**
|
|
1365
|
+
*
|
|
1366
|
+
* `errorArmsOf` excludes 2xx from the failure arms, so widening the primary to include 3xx made a
|
|
1367
|
+
* redirect BOTH: `[{ status: 302, schema: undefined, headers: [...] }, { status: 302, schema:
|
|
1368
|
+
* undefined }]`, two arms for one status where the second shadows the first's headers for anything
|
|
1369
|
+
* scanning past the first match.
|
|
1370
|
+
*
|
|
1371
|
+
* Widening `isSuccessKey` to match would be wrong in the other direction: an operation declaring a
|
|
1372
|
+
* 200 AND a 302 has 200 as its primary, so the 302 must stay a failure arm to be emitted at all.
|
|
1373
|
+
* Deduplicating here keeps both cases, because it asks what was actually emitted rather than what
|
|
1374
|
+
* might have been.
|
|
1375
|
+
*/
|
|
1376
|
+
const emitted = new Set();
|
|
1377
|
+
for (const arm of arms) {
|
|
1378
|
+
const status = /status: ([^,}]+)/.exec(arm)?.[1]?.trim();
|
|
1379
|
+
if (status !== undefined)
|
|
1380
|
+
emitted.add(status);
|
|
1381
|
+
}
|
|
1320
1382
|
for (const arm of route.errorArms) {
|
|
1383
|
+
if (emitted.has(String(arm.status)) || emitted.has(JSON.stringify(arm.status)))
|
|
1384
|
+
continue;
|
|
1321
1385
|
arms.push(`{ status: ${JSON.stringify(arm.status)}, schema: ${arm.schema ?? "undefined"} }`);
|
|
1322
1386
|
}
|
|
1323
1387
|
return `[${arms.join(", ")}]`;
|
|
@@ -1468,7 +1532,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1468
1532
|
const lookup = entries
|
|
1469
1533
|
.map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
|
|
1470
1534
|
.join("\n");
|
|
1471
|
-
return `${
|
|
1535
|
+
return `${generatedBanner()}
|
|
1472
1536
|
/**
|
|
1473
1537
|
* Flattens an intersection into a single object type.
|
|
1474
1538
|
*
|
|
@@ -1549,7 +1613,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
|
1549
1613
|
if (paired.length === 0)
|
|
1550
1614
|
return undefined;
|
|
1551
1615
|
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}>, Contracts.${pair.name}>>;`);
|
|
1552
|
-
return `${
|
|
1616
|
+
return `${generatedBanner()}
|
|
1553
1617
|
import type { z } from "zod";
|
|
1554
1618
|
import type * as Contracts from "${contractsPackage}";
|
|
1555
1619
|
import type {
|
|
@@ -1587,7 +1651,7 @@ function renderVocabularies(vocabularies) {
|
|
|
1587
1651
|
const entries = [...vocabularies.entries()]
|
|
1588
1652
|
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
1589
1653
|
.map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
|
|
1590
|
-
return `${
|
|
1654
|
+
return `${generatedBanner()}
|
|
1591
1655
|
/**
|
|
1592
1656
|
* The spec's vocabularies - every enum the surface reaches, and what each one contains.
|
|
1593
1657
|
*
|
|
@@ -1671,7 +1735,9 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1671
1735
|
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
1672
1736
|
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
1673
1737
|
runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
|
|
1738
|
+
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
1674
1739
|
};
|
|
1740
|
+
beginBanner(resolved.regenerateHint);
|
|
1675
1741
|
const registry = new SchemaRegistry(context.program);
|
|
1676
1742
|
const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
1677
1743
|
// Declarations first: a vocabulary is a fact about the contract, so it is collected from
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -78,6 +78,15 @@ export interface EmitterOptions {
|
|
|
78
78
|
* package's own tests, where the package name does not resolve.
|
|
79
79
|
*/
|
|
80
80
|
"runtime-module"?: string;
|
|
81
|
+
/**
|
|
82
|
+
* The command that regenerates these files, written into every banner.
|
|
83
|
+
*
|
|
84
|
+
* **`DO NOT EDIT` says what not to do and not what to do instead.** Only the project knows whether
|
|
85
|
+
* that is `pnpm generate`, `npm run api` or a `tsp compile` with three flags, so no emitter can
|
|
86
|
+
* supply it, and the generic line standing in its place lost the one thing a reader opening a
|
|
87
|
+
* generated file needs. Omitted, the generic line is kept.
|
|
88
|
+
*/
|
|
89
|
+
"regenerate-hint"?: string;
|
|
81
90
|
/**
|
|
82
91
|
* Per-`@service` overrides, keyed by the service namespace name.
|
|
83
92
|
*
|
|
@@ -93,6 +102,7 @@ export interface EmitterOptions {
|
|
|
93
102
|
"seal-object-schemas"?: boolean;
|
|
94
103
|
"key-vocabularies"?: string[];
|
|
95
104
|
"runtime-module"?: string;
|
|
105
|
+
"regenerate-hint"?: string;
|
|
96
106
|
}>;
|
|
97
107
|
}
|
|
98
108
|
/**
|
package/dist/src/lib.js
CHANGED
|
@@ -14,6 +14,7 @@ export const EmitterOptionsSchema = {
|
|
|
14
14
|
"seal-object-schemas": { type: "boolean", nullable: true },
|
|
15
15
|
"key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
|
|
16
16
|
"runtime-module": { type: "string", nullable: true },
|
|
17
|
+
"regenerate-hint": { type: "string", nullable: true },
|
|
17
18
|
services: {
|
|
18
19
|
type: "object",
|
|
19
20
|
nullable: true,
|
|
@@ -27,6 +28,7 @@ export const EmitterOptionsSchema = {
|
|
|
27
28
|
"seal-object-schemas": { type: "boolean", nullable: true },
|
|
28
29
|
"key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
|
|
29
30
|
"runtime-module": { type: "string", nullable: true },
|
|
31
|
+
"regenerate-hint": { type: "string", nullable: true },
|
|
30
32
|
},
|
|
31
33
|
required: [],
|
|
32
34
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|