typespec-http-zod 0.23.0 → 0.25.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 +61 -7
- package/dist/src/api.d.ts +34 -12
- package/dist/src/api.js +315 -114
- package/dist/src/constraints.d.ts +7 -6
- package/dist/src/constraints.js +7 -6
- package/dist/src/lib.d.ts +43 -1
- package/dist/src/lib.js +46 -0
- package/dist/src/registry.d.ts +41 -0
- package/dist/src/registry.js +80 -19
- package/dist/src/types.js +29 -8
- package/dist/src/zod.d.ts +27 -0
- package/dist/src/zod.js +64 -3
- package/package.json +4 -3
package/dist/src/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
1
|
+
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
2
2
|
import { getAuthenticationForOperation, isStatusCode, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
3
|
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
4
|
import { reportDiagnostic } from "./lib.js";
|
|
@@ -48,7 +48,7 @@ export { EmitterOptionsSchema } from "./lib.js";
|
|
|
48
48
|
* emitted module stopped being loadable. This is not a style choice a second emitter may make
|
|
49
49
|
* independently.
|
|
50
50
|
*/
|
|
51
|
-
export { objectKey } from "./zod.js";
|
|
51
|
+
export { jsDocComment, objectKey } from "./zod.js";
|
|
52
52
|
/** Named once. The emitted banner and the internal-invariant messages both quote it. */
|
|
53
53
|
const PACKAGE_NAME = "typespec-http-zod";
|
|
54
54
|
/**
|
|
@@ -392,15 +392,48 @@ function bodySchemaOf(response, registry) {
|
|
|
392
392
|
return undefined;
|
|
393
393
|
}
|
|
394
394
|
/**
|
|
395
|
-
*
|
|
395
|
+
* Everything the document says about authenticating an operation, read ONCE.
|
|
396
396
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
397
|
+
* **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
|
|
398
|
+
* fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
|
|
399
|
+
* gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
|
|
400
|
+
* rebuild the requirements itself -- which is what both server emitters were doing, in two
|
|
401
|
+
* hand-written copies of one rule.
|
|
402
|
+
*
|
|
403
|
+
* `scopes` is unchanged in meaning and is now derived from `security` rather than separately read:
|
|
404
|
+
* the union of every scope any requirement demands, which is what it always was.
|
|
401
405
|
*/
|
|
402
|
-
function
|
|
403
|
-
|
|
406
|
+
function authenticationFor(program, operation) {
|
|
407
|
+
const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
|
|
408
|
+
const security = [];
|
|
409
|
+
let noAuth = false;
|
|
410
|
+
for (const option of options) {
|
|
411
|
+
const requirement = {};
|
|
412
|
+
let anonymous = false;
|
|
413
|
+
for (const scheme of option.schemes) {
|
|
414
|
+
/**
|
|
415
|
+
* **`NoAuth` inside an option means that option needs nothing**, which is how a spec says
|
|
416
|
+
* authentication is optional here. It is not a scheme to demand, and publishing it as one
|
|
417
|
+
* would make an emitter refuse the anonymous callers the document permits.
|
|
418
|
+
*/
|
|
419
|
+
if (scheme.type === "noAuth") {
|
|
420
|
+
anonymous = true;
|
|
421
|
+
noAuth = true;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
/** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
|
|
425
|
+
requirement[scheme.id] =
|
|
426
|
+
scheme.type === "oauth2"
|
|
427
|
+
? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
|
|
428
|
+
: [];
|
|
429
|
+
}
|
|
430
|
+
if (anonymous && Object.keys(requirement).length === 0)
|
|
431
|
+
continue;
|
|
432
|
+
if (Object.keys(requirement).length > 0)
|
|
433
|
+
security.push(requirement);
|
|
434
|
+
}
|
|
435
|
+
const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
|
|
436
|
+
return { security, scopes, noAuth };
|
|
404
437
|
}
|
|
405
438
|
/**
|
|
406
439
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
@@ -680,7 +713,9 @@ function multipartSchemaOf(body, registry) {
|
|
|
680
713
|
: registry.expressionFor(type);
|
|
681
714
|
// `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
|
|
682
715
|
const value = part.multi === true ? `z.array(${inner})` : inner;
|
|
683
|
-
|
|
716
|
+
// `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
|
|
717
|
+
// only, so an absent part is an absent key rather than an explicit `undefined`.
|
|
718
|
+
return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
|
|
684
719
|
});
|
|
685
720
|
const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
|
|
686
721
|
/**
|
|
@@ -1098,9 +1133,36 @@ export function collectRoutes(program, registry, service) {
|
|
|
1098
1133
|
* express "absent". See {@link EmittedRoute.optionalBody}.
|
|
1099
1134
|
*/
|
|
1100
1135
|
const optionalBody = bodyParameter?.property?.optional === true;
|
|
1101
|
-
|
|
1136
|
+
/**
|
|
1137
|
+
* **An optional MULTIPART body reaches the same rule by a second route, because
|
|
1138
|
+
* `requestType` cannot see it.** `requestBodyOf` resolves a type only for
|
|
1139
|
+
* `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
|
|
1140
|
+
* and the `optionalBody` disjunct above was unreachable however the document declared it.
|
|
1141
|
+
*
|
|
1142
|
+
* The emitted server for such an operation did not compile. Measured, and the mechanism is
|
|
1143
|
+
* worth stating because it is not a union: spreading a value typed `Parts | undefined`
|
|
1144
|
+
* makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
|
|
1145
|
+
* `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
|
|
1146
|
+
*
|
|
1147
|
+
* That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
|
|
1148
|
+
* way to say "these properties are here only sometimes" without making every one of them
|
|
1149
|
+
* optional, which is a weaker and different claim about the body that IS sent. So this is
|
|
1150
|
+
* the stated rule reaching the one body kind it had never been applied to, not a new rule.
|
|
1151
|
+
*
|
|
1152
|
+
* **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
|
|
1153
|
+
* parts model, so a required multipart body merges as safely as a required single one, and
|
|
1154
|
+
* `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
|
|
1155
|
+
* Widening this to "multipart is always named" would rewrite that directory for no defect.
|
|
1156
|
+
*
|
|
1157
|
+
* This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
|
|
1158
|
+
* `multipartTsOf`, both recorded above.
|
|
1159
|
+
*/
|
|
1160
|
+
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1161
|
+
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1162
|
+
optionalMultipartBody
|
|
1102
1163
|
? (bodyParameter?.property?.name ?? "body")
|
|
1103
1164
|
: undefined;
|
|
1165
|
+
const authentication = authenticationFor(program, operation);
|
|
1104
1166
|
routes.push({
|
|
1105
1167
|
operationId: operationIdOf(program, operation.operation),
|
|
1106
1168
|
verb: operation.verb.toUpperCase(),
|
|
@@ -1186,12 +1248,9 @@ export function collectRoutes(program, registry, service) {
|
|
|
1186
1248
|
errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
|
|
1187
1249
|
responseHeaders: responseHeadersOf(program, operation),
|
|
1188
1250
|
responseMediaTypes: responseMediaTypesOf(operation),
|
|
1189
|
-
scopes:
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
: []))),
|
|
1193
|
-
],
|
|
1194
|
-
noAuth: noAuthFor(program, operation),
|
|
1251
|
+
scopes: authentication.scopes,
|
|
1252
|
+
security: authentication.security,
|
|
1253
|
+
noAuth: authentication.noAuth,
|
|
1195
1254
|
statusBy: discriminator,
|
|
1196
1255
|
statusSelector,
|
|
1197
1256
|
/**
|
|
@@ -1266,7 +1325,26 @@ function renderExternalImports(externals, ...bodies) {
|
|
|
1266
1325
|
* validate a request or a response against, and every oracle that graded those validators had to read
|
|
1267
1326
|
* a server to find them. See {@link nameRouteSchemas}.
|
|
1268
1327
|
*/
|
|
1269
|
-
|
|
1328
|
+
/**
|
|
1329
|
+
* **`z.compile(...)` around a validator, when `compile-schemas` asks for it.**
|
|
1330
|
+
*
|
|
1331
|
+
* Zod walks the schema once and emits a flat, loop-free function through `new Function`, falling back
|
|
1332
|
+
* to the ordinary parser for anything it cannot handle. Measured on zod 4.5.2 before this was written:
|
|
1333
|
+
* `z.toJSONSchema` output is byte-identical, `_zod.def` is unchanged and still readable, and verdicts
|
|
1334
|
+
* AND parsed output match for every construct this emitter writes - including `z.preprocess`,
|
|
1335
|
+
* `.exactOptional()` and a `z.lazy()` cycle. So the two oracles that read a schema rather than run it
|
|
1336
|
+
* are unaffected, and the wrapper is transparent.
|
|
1337
|
+
*
|
|
1338
|
+
* **A cyclic declaration is wrapped too, and that was worth checking rather than assuming.** The
|
|
1339
|
+
* emitted form names the const being defined from inside its own `z.lazy()` body, and this repository
|
|
1340
|
+
* has already shipped a break of exactly that shape - `.strict()` reads `shape` eagerly and throws
|
|
1341
|
+
* `Cannot access 'X' before initialization` during module initialisation. Measured on the emitted
|
|
1342
|
+
* shape: `z.compile()` does not force the lazy body, and the declaration parses.
|
|
1343
|
+
*/
|
|
1344
|
+
function compiled(expression, on) {
|
|
1345
|
+
return on ? `z.compile(${expression})` : expression;
|
|
1346
|
+
}
|
|
1347
|
+
function renderSchemas(registry, externals, routeDeclarations, runtimeModule, compileSchemas) {
|
|
1270
1348
|
const declarations = registry.declarations().map((d) => {
|
|
1271
1349
|
/**
|
|
1272
1350
|
* **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
|
|
@@ -1277,7 +1355,7 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1277
1355
|
* with no cycle emits byte-identical output to before.
|
|
1278
1356
|
*/
|
|
1279
1357
|
if (d.structural === undefined) {
|
|
1280
|
-
return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} =
|
|
1358
|
+
return `\nexport const ${d.identifier} = ${compiled(d.source, compileSchemas)};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
|
|
1281
1359
|
}
|
|
1282
1360
|
const declared = d.structural.startsWith("{")
|
|
1283
1361
|
? `export interface ${d.typeName} ${d.structural}`
|
|
@@ -1308,7 +1386,22 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1308
1386
|
* than from this type, so no handler signature is affected.
|
|
1309
1387
|
*/
|
|
1310
1388
|
const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}, ${d.typeName}>` : "";
|
|
1311
|
-
|
|
1389
|
+
/**
|
|
1390
|
+
* **A deferred declaration is NEVER compiled, and this was measured rather than reasoned.**
|
|
1391
|
+
*
|
|
1392
|
+
* `z.lazy()` is on Zod's own list of constructs the compiler cannot handle, and here the body
|
|
1393
|
+
* forward-references a `const` declared further down the module. Wrapping it emitted output that
|
|
1394
|
+
* loaded fine and then threw `Cannot read properties of undefined (reading '_zod')` on the first
|
|
1395
|
+
* parse - the walk captures the reference while it is still in the temporal dead zone, so the
|
|
1396
|
+
* failure surfaces at request time rather than at import.
|
|
1397
|
+
*
|
|
1398
|
+
* **Keyed on `annotated`, the fact the registry already holds** - it is set exactly when the
|
|
1399
|
+
* declaration is deferred, which is exactly when `z.lazy()` is written - rather than on the
|
|
1400
|
+
* shape of the emitted text. A model on a cycle whose back edge sits on a GETTER is a different
|
|
1401
|
+
* case and is compiled like any other: measured, it parses, because a getter is not resolved
|
|
1402
|
+
* until something reads it.
|
|
1403
|
+
*/
|
|
1404
|
+
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.annotated !== true)};\n`;
|
|
1312
1405
|
});
|
|
1313
1406
|
/**
|
|
1314
1407
|
* **Written only when something in the file names it**, like the `ResponseArm` import below and for
|
|
@@ -1327,39 +1420,24 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
1327
1420
|
* rendered content for the same reason.
|
|
1328
1421
|
*/
|
|
1329
1422
|
/**
|
|
1330
|
-
* **`Exact
|
|
1331
|
-
*
|
|
1332
|
-
* `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
|
|
1333
|
-
* admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
|
|
1334
|
-
* same thing by leaving the property out of `required`, so the inferred type is wider than the
|
|
1335
|
-
* wire and a consumer reading a payload had to strip it at the boundary.
|
|
1423
|
+
* **`Exact<>` lived here until `0.24.0`, and `z.exactOptional()` replaced it.**
|
|
1336
1424
|
*
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
* was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
|
|
1342
|
-
* compiling.
|
|
1425
|
+
* It was a mapped type over `z.infer` that stripped `| undefined` off every optional property,
|
|
1426
|
+
* because `.optional()` infers `p?: T | undefined` and **JSON has no `undefined`** - the document
|
|
1427
|
+
* says "absent or a value" by leaving the property out of `required`, so the inferred type was
|
|
1428
|
+
* wider than both the document and the wire.
|
|
1343
1429
|
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
* a value arrives, and optional in the contract type, because a caller may omit it.
|
|
1430
|
+
* The reasoning was right and it was applied to only one of the two artefacts. The VALIDATOR went
|
|
1431
|
+
* on accepting `{ p: undefined }`, so one program published a type refusing a value beside a
|
|
1432
|
+
* validator admitting it, compared by nothing. Emitting `.exactOptional()` makes `z.infer` produce
|
|
1433
|
+
* `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
|
|
1434
|
+
* happens once, in the schema, and this helper has nothing left to do.
|
|
1350
1435
|
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1436
|
+
* The contract types in `requests.gen.ts` still keep `?: T | undefined` on purpose: they are the
|
|
1437
|
+
* floor a PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so
|
|
1438
|
+
* refusing it there would cost every producer a conditional spread and buy the wire nothing.
|
|
1439
|
+
* Narrowing THAT surface was tried in `0.19.0` and reverted - it broke `(ctx, input) => ok(input)`.
|
|
1355
1440
|
*/
|
|
1356
|
-
const EXACT_HELPER = `
|
|
1357
|
-
/** An inferred type narrowed to what JSON can carry: an optional property is absent or a value. */
|
|
1358
|
-
type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
|
|
1359
|
-
[K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
|
|
1360
|
-
};
|
|
1361
|
-
`;
|
|
1362
|
-
const usesExact = declarations.some((d) => d.includes("Exact<"));
|
|
1363
1441
|
const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
|
|
1364
1442
|
const parts = [generatedBanner()];
|
|
1365
1443
|
if (usesZod)
|
|
@@ -1376,8 +1454,6 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
|
|
|
1376
1454
|
parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
|
|
1377
1455
|
}
|
|
1378
1456
|
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
1379
|
-
if (usesExact)
|
|
1380
|
-
parts.push(EXACT_HELPER);
|
|
1381
1457
|
parts.push(declarations.join(""));
|
|
1382
1458
|
if (routeDeclarations.length > 0)
|
|
1383
1459
|
parts.push(`\n${routeDeclarations.join("\n")}\n`);
|
|
@@ -1394,7 +1470,7 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
|
|
|
1394
1470
|
const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
|
|
1395
1471
|
const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
|
|
1396
1472
|
/** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
|
|
1397
|
-
function namedSchema(operationId, suffix, expression) {
|
|
1473
|
+
function namedSchema(operationId, suffix, expression, compileSchemas) {
|
|
1398
1474
|
if (expression === undefined)
|
|
1399
1475
|
return { name: undefined, declaration: undefined };
|
|
1400
1476
|
if (isIdentifier(expression))
|
|
@@ -1411,7 +1487,7 @@ function namedSchema(operationId, suffix, expression) {
|
|
|
1411
1487
|
* Exporting also gives an application the request shape by name, which is what the per-operation
|
|
1412
1488
|
* type aliases will be built from.
|
|
1413
1489
|
*/
|
|
1414
|
-
return { name, declaration: `export const ${name} = ${expression};` };
|
|
1490
|
+
return { name, declaration: `export const ${name} = ${compiled(expression, compileSchemas)};` };
|
|
1415
1491
|
}
|
|
1416
1492
|
/**
|
|
1417
1493
|
* Which `verb path` slots carry more than one operation.
|
|
@@ -1453,13 +1529,13 @@ function sharedSlots(routes) {
|
|
|
1453
1529
|
* Declaring them here dissolves all three: the library mints the names AND emits them, and a server
|
|
1454
1530
|
* emitter imports what it is told rather than agreeing about it.
|
|
1455
1531
|
*/
|
|
1456
|
-
function nameRouteSchemas(routes) {
|
|
1532
|
+
function nameRouteSchemas(routes, compileSchemas) {
|
|
1457
1533
|
const shared = sharedSlots(routes);
|
|
1458
1534
|
const declarations = [];
|
|
1459
1535
|
const names = new Map();
|
|
1460
1536
|
for (const route of routes) {
|
|
1461
1537
|
const declare = (suffix, expression) => {
|
|
1462
|
-
const resolved = namedSchema(route.operationId, suffix, expression);
|
|
1538
|
+
const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
|
|
1463
1539
|
if (resolved.declaration !== undefined)
|
|
1464
1540
|
declarations.push(resolved.declaration);
|
|
1465
1541
|
return resolved.name;
|
|
@@ -1611,61 +1687,83 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1611
1687
|
for (const operation of service.operations) {
|
|
1612
1688
|
if (successStatusOf(operation) === undefined)
|
|
1613
1689
|
continue;
|
|
1614
|
-
const body = requestBodyOf(operation);
|
|
1615
|
-
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
1616
|
-
const multipartBody = operation.parameters.body;
|
|
1617
|
-
const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
|
|
1618
1690
|
/**
|
|
1619
|
-
*
|
|
1691
|
+
* **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
|
|
1692
|
+
*
|
|
1693
|
+
* `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
|
|
1694
|
+
* Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
|
|
1695
|
+
* level. Without it this walk ran at the default `Read`, so a POST body's contract type
|
|
1696
|
+
* carried the canonical property set while the validator beside it accepted the create
|
|
1697
|
+
* projection. A caller satisfying the published type sent read-only fields and a strict
|
|
1698
|
+
* validator answered 400.
|
|
1620
1699
|
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1700
|
+
* It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
|
|
1701
|
+
* behind `withVisibility`, and calling them outside one leaves a module-level singleton
|
|
1702
|
+
* built for the wrong program.
|
|
1624
1703
|
*/
|
|
1625
|
-
|
|
1626
|
-
|
|
1704
|
+
withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
|
|
1705
|
+
const body = requestBodyOf(operation);
|
|
1706
|
+
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
1707
|
+
const multipartBody = operation.parameters.body;
|
|
1708
|
+
const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
|
|
1627
1709
|
/**
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1710
|
+
* Headers are part of the input too.
|
|
1711
|
+
*
|
|
1712
|
+
* The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
|
|
1713
|
+
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
1714
|
+
* type would describe an input the application cannot act on.
|
|
1632
1715
|
*/
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1716
|
+
const extraProperties = operation.parameters.parameters
|
|
1717
|
+
.filter((parameter) => parameter.type === "path" ||
|
|
1718
|
+
parameter.type === "query" ||
|
|
1719
|
+
parameter.type === "header")
|
|
1720
|
+
/**
|
|
1721
|
+
* Named as the WIRE names it, which is what the emitted validator keys on.
|
|
1722
|
+
* `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
|
|
1723
|
+
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
1724
|
+
* fails to typecheck against a server that is correct.
|
|
1725
|
+
*/
|
|
1726
|
+
.map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
|
|
1727
|
+
// `return` rather than `continue`: this body is a callback now, and returning from it
|
|
1728
|
+
// skips the operation exactly as the `continue` did.
|
|
1729
|
+
if (body === undefined && extraProperties.length === 0)
|
|
1730
|
+
return;
|
|
1731
|
+
/**
|
|
1732
|
+
* A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
|
|
1733
|
+
* a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
|
|
1734
|
+
* builds. Referencing the scalar directly would type the whole input as that value and
|
|
1735
|
+
* quietly drop the headers beside it.
|
|
1736
|
+
*/
|
|
1737
|
+
const rawBody = body?.kind === "Scalar" && body.name === "bytes"
|
|
1738
|
+
? (operation.parameters.body?.property?.name ?? "body")
|
|
1739
|
+
: undefined;
|
|
1740
|
+
/**
|
|
1741
|
+
* **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
|
|
1742
|
+
* `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
|
|
1743
|
+
* applied here for the first time.
|
|
1744
|
+
*
|
|
1745
|
+
* This type said `string` for every `bytes` body whatever it was served as. A server reading
|
|
1746
|
+
* an `application/octet-stream` body hands the handler bytes, so a consumer checking its
|
|
1747
|
+
* handlers against these types could not typecheck against a correct server. It is the
|
|
1748
|
+
* request half of the corruption already fixed on the reader: decoding raw bytes as text
|
|
1749
|
+
* replaces every byte outside ASCII, and typing them as text is what invites it.
|
|
1750
|
+
*/
|
|
1751
|
+
const rawBodyType = isRawBinaryMediaType([
|
|
1752
|
+
...(operation.parameters.body?.contentTypes ?? []),
|
|
1753
|
+
])
|
|
1754
|
+
? "ArrayBuffer"
|
|
1755
|
+
: "string";
|
|
1756
|
+
entries.push({
|
|
1757
|
+
operationId: operationIdOf(program, operation.operation),
|
|
1758
|
+
bodyRef: rawBody !== undefined
|
|
1759
|
+
? `{\n\t${rawBody}: ${rawBodyType};\n}`
|
|
1760
|
+
: multipart !== undefined
|
|
1761
|
+
? multipartTsOf(multipart, registry)
|
|
1762
|
+
: body === undefined
|
|
1763
|
+
? undefined
|
|
1764
|
+
: registry.expressionFor(body),
|
|
1765
|
+
extraProperties,
|
|
1766
|
+
});
|
|
1669
1767
|
});
|
|
1670
1768
|
}
|
|
1671
1769
|
}
|
|
@@ -1682,7 +1780,15 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1682
1780
|
*/
|
|
1683
1781
|
function collectResponseTypes(program, registry, service) {
|
|
1684
1782
|
const entries = [];
|
|
1685
|
-
|
|
1783
|
+
/**
|
|
1784
|
+
* **Pinned to `Read` explicitly, rather than inheriting whatever the caller last set.**
|
|
1785
|
+
*
|
|
1786
|
+
* A response is the canonical projection whatever verb produced it, and the same rule is stated
|
|
1787
|
+
* for the route walk: resolving responses under the ambient REQUEST visibility declared six
|
|
1788
|
+
* components under the wrong suffix. Saying it here rather than relying on the default is what
|
|
1789
|
+
* makes that impossible from either direction.
|
|
1790
|
+
*/
|
|
1791
|
+
withVisibility(program, Visibility.Read, () => {
|
|
1686
1792
|
for (const operation of service.operations) {
|
|
1687
1793
|
const status = successStatusOf(operation);
|
|
1688
1794
|
if (status === undefined)
|
|
@@ -1695,7 +1801,7 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1695
1801
|
ref: registry.expressionFor(body),
|
|
1696
1802
|
});
|
|
1697
1803
|
}
|
|
1698
|
-
}
|
|
1804
|
+
});
|
|
1699
1805
|
return entries;
|
|
1700
1806
|
}
|
|
1701
1807
|
/**
|
|
@@ -1709,10 +1815,23 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1709
1815
|
*/
|
|
1710
1816
|
function renderRequestTypes(entries, responses, registry) {
|
|
1711
1817
|
const declarations = registry.declarations().map((declaration) => {
|
|
1712
|
-
|
|
1713
|
-
|
|
1818
|
+
/**
|
|
1819
|
+
* **A vocabulary alias is exported like any other declaration.**
|
|
1820
|
+
*
|
|
1821
|
+
* It used to stay module-local, on the stated ground that "the vocabularies artefact already
|
|
1822
|
+
* exports these names as runtime tuples, and exporting both collides on every one". That was
|
|
1823
|
+
* true of a shape this emitter no longer produces: `vocabularies.gen.ts` has exactly one
|
|
1824
|
+
* top-level export, `SPEC_VOCABULARIES`, and the enum names are KEYS inside it - see the
|
|
1825
|
+
* docblock on `renderVocabularies`, which records why the keyed form replaced individually
|
|
1826
|
+
* named tuples. There is nothing left to collide with.
|
|
1827
|
+
*
|
|
1828
|
+
* The stale rationale was load-bearing: `wire-contract.gen.ts` names `Contracts.<EnumName>`
|
|
1829
|
+
* for an enum used as a request body, and a barrel cannot re-export a declaration that is not
|
|
1830
|
+
* exported. So that file did not compile for any such spec. `TS2694`, found the first time
|
|
1831
|
+
* anything compiled the conformance corpus rather than reading it.
|
|
1832
|
+
*/
|
|
1714
1833
|
if (declaration.isVocabulary)
|
|
1715
|
-
return `\
|
|
1834
|
+
return `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
1716
1835
|
return declaration.isObject
|
|
1717
1836
|
? `\nexport interface ${declaration.name} ${declaration.source}\n`
|
|
1718
1837
|
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
@@ -1913,6 +2032,26 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
1913
2032
|
* dropped from a shape that has other keys, and kept - with its value type still mapped - from one
|
|
1914
2033
|
* that does not.
|
|
1915
2034
|
*/
|
|
2035
|
+
/**
|
|
2036
|
+
* \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
|
|
2037
|
+
* deliberate on both sides.**
|
|
2038
|
+
*
|
|
2039
|
+
* The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
|
|
2040
|
+
* \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
|
|
2041
|
+
* contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
|
|
2042
|
+
* omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
|
|
2043
|
+
* nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
|
|
2044
|
+
* \`(ctx, input) => ok(input)\`.
|
|
2045
|
+
*
|
|
2046
|
+
* So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
|
|
2047
|
+
* producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
|
|
2048
|
+
* rather than a second deep one. Every other kind of drift still fails: a property that changes type,
|
|
2049
|
+
* appears, disappears or changes optionality is untouched by this.
|
|
2050
|
+
*/
|
|
2051
|
+
type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
|
|
2052
|
+
? Declared<T[K]> | undefined
|
|
2053
|
+
: Declared<T[K]>;
|
|
2054
|
+
|
|
1916
2055
|
type DeclaredKeyOf<T> = keyof {
|
|
1917
2056
|
[K in keyof T as string extends K
|
|
1918
2057
|
? never
|
|
@@ -1931,7 +2070,20 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
1931
2070
|
: readonly Declared<Element>[]
|
|
1932
2071
|
: T extends object
|
|
1933
2072
|
? [DeclaredKeyOf<T>] extends [never]
|
|
1934
|
-
?
|
|
2073
|
+
? [T[keyof T]] extends [never]
|
|
2074
|
+
? // **An indexer whose VALUE type is \`never\` admits no property, so it is an EMPTY
|
|
2075
|
+
// OBJECT rather than a dictionary.** Zod spells an empty object that way:
|
|
2076
|
+
// \`z.strictObject({})\` infers \`Record<string, never>\` under Zod 4, while this
|
|
2077
|
+
// walk emits \`{}\` for the same model. The two describe one shape and compared
|
|
2078
|
+
// unequal, so \`wire-contract.gen.ts\` did not compile for any spec declaring an
|
|
2079
|
+
// empty model. Normalising both to \`{}\` is what makes them agree, and it cannot
|
|
2080
|
+
// equate a real dictionary with an empty object: a dictionary whose values are
|
|
2081
|
+
// \`never\` accepts nothing either.
|
|
2082
|
+
{}
|
|
2083
|
+
: // A shape with no declared keys is a dictionary: it has no optional property to
|
|
2084
|
+
// widen, and an index signature satisfies \`{} extends Pick<T, K>\` for every
|
|
2085
|
+
// value type.
|
|
2086
|
+
{ [K in keyof T]: Declared<T[K]> }
|
|
1935
2087
|
: {
|
|
1936
2088
|
[K in keyof T as string extends K
|
|
1937
2089
|
? never
|
|
@@ -1939,7 +2091,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
1939
2091
|
? never
|
|
1940
2092
|
: symbol extends K
|
|
1941
2093
|
? never
|
|
1942
|
-
: K]:
|
|
2094
|
+
: K]: Supplied<T, K>;
|
|
1943
2095
|
}
|
|
1944
2096
|
: T;
|
|
1945
2097
|
|
|
@@ -1990,6 +2142,49 @@ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
|
1990
2142
|
* copies that will not, and the symptom would be a subtly different set of declarations in a file
|
|
1991
2143
|
* whose name is the contract.
|
|
1992
2144
|
*/
|
|
2145
|
+
/**
|
|
2146
|
+
* The doc tags TypeSpec understands. Anything else parses as an unknown tag, and the description
|
|
2147
|
+
* ends where it began. Written out rather than imported: `SyntaxKind` lives behind
|
|
2148
|
+
* `@typespec/compiler/ast`, whose exports map declares no `types` condition, and comparing the tag
|
|
2149
|
+
* NAME needs no AST import at all.
|
|
2150
|
+
*/
|
|
2151
|
+
const KNOWN_DOC_TAGS = new Set(["param", "template", "prop", "returns", "return", "errors"]);
|
|
2152
|
+
/** See the `truncated-doc-comment` docblock in `lib.ts` for why this reads the node, not the string. */
|
|
2153
|
+
function reportTruncatedDocComments(program) {
|
|
2154
|
+
const check = (type) => {
|
|
2155
|
+
if (getLocationContext(program, type).type !== "project")
|
|
2156
|
+
return;
|
|
2157
|
+
// An `@doc(...)` decorator wins, and `@` inside a string literal truncates nothing.
|
|
2158
|
+
if (getDocData(program, type)?.source !== "comment")
|
|
2159
|
+
return;
|
|
2160
|
+
const docs = type.node?.docs;
|
|
2161
|
+
for (const doc of docs ?? []) {
|
|
2162
|
+
for (const tag of doc.tags) {
|
|
2163
|
+
if (KNOWN_DOC_TAGS.has(tag.tagName.sv))
|
|
2164
|
+
continue;
|
|
2165
|
+
reportDiagnostic(program, {
|
|
2166
|
+
code: "truncated-doc-comment",
|
|
2167
|
+
target: type,
|
|
2168
|
+
format: {
|
|
2169
|
+
name: typeof type.name === "string" && type.name !== "" ? type.name : "(anonymous)",
|
|
2170
|
+
tag: tag.tagName.sv,
|
|
2171
|
+
},
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
navigateProgram(program, {
|
|
2177
|
+
model: check,
|
|
2178
|
+
modelProperty: check,
|
|
2179
|
+
operation: check,
|
|
2180
|
+
enum: check,
|
|
2181
|
+
enumMember: check,
|
|
2182
|
+
union: check,
|
|
2183
|
+
unionVariant: check,
|
|
2184
|
+
interface: check,
|
|
2185
|
+
scalar: check,
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
1993
2188
|
export async function emitHttpZod(context, wrapper = {}) {
|
|
1994
2189
|
const options = context.options;
|
|
1995
2190
|
const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
|
|
@@ -2064,6 +2259,11 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2064
2259
|
format: { sealed: sealed.join(", "), open: open.join(", ") },
|
|
2065
2260
|
});
|
|
2066
2261
|
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Once per program, before the per-service loop: a doc comment belongs to a type, not to a
|
|
2264
|
+
* service, and a type reachable from two services would otherwise be named twice.
|
|
2265
|
+
*/
|
|
2266
|
+
reportTruncatedDocComments(context.program);
|
|
2067
2267
|
for (const snapshot of snapshots) {
|
|
2068
2268
|
beginOperationIds();
|
|
2069
2269
|
const service = snapshot.service;
|
|
@@ -2077,6 +2277,7 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2077
2277
|
contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
|
|
2078
2278
|
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
2079
2279
|
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
2280
|
+
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
2080
2281
|
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
2081
2282
|
runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
|
|
2082
2283
|
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
@@ -2133,10 +2334,10 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2133
2334
|
* anything is written - and it has to happen HERE rather than in a server emitter, or the
|
|
2134
2335
|
* validators it produces never reach this package's own output.
|
|
2135
2336
|
*/
|
|
2136
|
-
const { names, declarations } = nameRouteSchemas(routes);
|
|
2337
|
+
const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
|
|
2137
2338
|
await emitFile(context.program, {
|
|
2138
2339
|
path: resolvePath(outputDir, "schemas.gen.ts"),
|
|
2139
|
-
content: renderSchemas(registry, externals, declarations, resolved.runtimeModule),
|
|
2340
|
+
content: renderSchemas(registry, externals, declarations, resolved.runtimeModule, resolved.compileSchemas),
|
|
2140
2341
|
});
|
|
2141
2342
|
if (resolved.contractsOutputDir !== undefined) {
|
|
2142
2343
|
const types = new TypeRegistry(context.program);
|