typespec-http-zod 0.25.1 → 0.27.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 +171 -116
- package/dist/src/api.js +456 -344
- package/dist/src/constraints.d.ts +46 -0
- package/dist/src/constraints.js +233 -10
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/lib.d.ts +32 -22
- package/dist/src/lib.js +53 -2
- package/dist/src/linter.d.ts +15 -0
- package/dist/src/linter.js +23 -0
- package/dist/src/registry.d.ts +1 -1
- package/dist/src/registry.js +158 -36
- package/dist/src/rules/redos-prone-pattern.rule.d.ts +27 -0
- package/dist/src/rules/redos-prone-pattern.rule.js +47 -0
- package/dist/src/runtime.d.ts +20 -34
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.js +13 -4
- package/dist/src/uri-template.d.ts +106 -0
- package/dist/src/uri-template.js +254 -0
- package/dist/src/zod.d.ts +11 -17
- package/dist/src/zod.js +87 -14
- package/package.json +6 -3
package/dist/src/api.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
2
|
-
import { getAuthenticationForOperation,
|
|
1
|
+
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, } from "@typespec/compiler";
|
|
2
|
+
import { getAuthenticationForOperation, 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
6
|
import { typeToTs } from "./types.js";
|
|
7
7
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
8
8
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
9
|
+
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
|
|
9
10
|
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
10
11
|
/**
|
|
11
12
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
@@ -273,97 +274,85 @@ function statusKeysOf(program, response) {
|
|
|
273
274
|
}
|
|
274
275
|
return keys;
|
|
275
276
|
}
|
|
276
|
-
/** Whether a document status key describes a SUCCESS, so the success arms already carry it. */
|
|
277
|
-
function isSuccessKey(status) {
|
|
278
|
-
return status === "2XX" || (typeof status === "number" && status >= 200 && status < 300);
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Every FAILURE arm the operation declares, each with the schema for ITS OWN body.
|
|
282
|
-
*
|
|
283
|
-
* **None of these reached the generated output at all, and a census is the proof.** Measured on one
|
|
284
|
-
* service, the document declared `200`x9, `201`x3, `202`x1, `401`x1 and `default`x2, and only the
|
|
285
|
-
* first three were emitted. So the schema for a failure never arrived, and no application could have
|
|
286
|
-
* checked what it sends when something goes wrong even if it wanted to.
|
|
287
|
-
*
|
|
288
|
-
* **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
|
|
289
|
-
* `@error model WebhookUnauthorized { ...ProblemDetails; @statusCode 401 }` and openapi3 publishes
|
|
290
|
-
* the 401 as `$ref: WebhookUnauthorized` while we answered with `problemDetailsSchema`. The shapes
|
|
291
|
-
* were identical there, so nothing was mis-validated - but the component the document names for that
|
|
292
|
-
* response had no declaration at all.
|
|
293
|
-
*
|
|
294
|
-
* **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
|
|
295
|
-
* service-wide schema. That was backwards: a catch-all response declares a body like any other
|
|
296
|
-
* response, and `response/status-code-range` gives two operations whose `default` is a DIFFERENT
|
|
297
|
-
* model from every other failure they declare. Reading each response's own body answers all three at
|
|
298
|
-
* once, and is why the emitter no longer needs to believe a service has one error shape.
|
|
299
|
-
*
|
|
300
|
-
* Arms come out in the order OpenAPI resolves them - exact codes, then ranges, then the catch-all -
|
|
301
|
-
* so a consumer scanning for the first match applies the precedence the specification states.
|
|
302
|
-
*/
|
|
303
277
|
/**
|
|
304
|
-
*
|
|
278
|
+
* Every response the operation declares, one per status key, in OpenAPI's precedence order.
|
|
305
279
|
*
|
|
306
|
-
* **
|
|
307
|
-
*
|
|
308
|
-
*
|
|
280
|
+
* **Each response's own body, headers and media types, and nothing borrowed from another.** The
|
|
281
|
+
* failure arms used to be collected here separately from the success, and before that every failure
|
|
282
|
+
* carried the service-wide error schema: `@error model WebhookUnauthorized { ...ProblemDetails;
|
|
283
|
+
* @statusCode 401 }` is published as `$ref: WebhookUnauthorized`, and `response/status-code-range`
|
|
284
|
+
* gives two operations whose `default` is a different model from every other failure they declare.
|
|
285
|
+
* Reading each status's own response answers both, and is why nothing here believes a service has
|
|
286
|
+
* one error shape or one success shape.
|
|
287
|
+
*
|
|
288
|
+
* Where two responses resolve to the same status the first body wins, which is what the document
|
|
289
|
+
* does, and media types and headers are the union across them.
|
|
290
|
+
*
|
|
291
|
+
* Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
|
|
292
|
+
* resolving it under a request visibility declares components under the wrong suffix.
|
|
309
293
|
*/
|
|
310
|
-
function
|
|
294
|
+
function responsesOf(program, operation, registry) {
|
|
311
295
|
const byStatus = new Map();
|
|
312
296
|
for (const response of operation.responses) {
|
|
313
|
-
const status
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
297
|
+
for (const status of statusKeysOf(program, response)) {
|
|
298
|
+
const key = String(status);
|
|
299
|
+
const entry = byStatus.get(key) ?? {
|
|
300
|
+
status,
|
|
301
|
+
body: undefined,
|
|
302
|
+
contentTypes: new Set(),
|
|
303
|
+
headers: [],
|
|
304
|
+
streamed: false,
|
|
305
|
+
};
|
|
306
|
+
for (const content of response.responses) {
|
|
307
|
+
if (entry.body === undefined && content.body?.bodyKind === "single") {
|
|
308
|
+
entry.body = bodyTypeOf(content.body.type);
|
|
309
|
+
}
|
|
310
|
+
for (const contentType of content.body?.contentTypes ?? []) {
|
|
311
|
+
entry.contentTypes.add(contentType);
|
|
312
|
+
}
|
|
313
|
+
for (const [name, property] of Object.entries(content.headers ?? {})) {
|
|
314
|
+
if (entry.headers.some((existing) => existing.name === name))
|
|
315
|
+
continue;
|
|
316
|
+
entry.headers.push({
|
|
317
|
+
name,
|
|
318
|
+
type: typeToTs(program, property.type),
|
|
319
|
+
/**
|
|
320
|
+
* **The Zod expression, which used to be discarded here.** Every REQUEST parameter
|
|
321
|
+
* goes through `expressionForProperty` a few hundred lines below; a response header
|
|
322
|
+
* went through `typeToTs` and nothing else, so the emitted arm carried a name and an
|
|
323
|
+
* optionality and no way to check a value. A response body that breaks its schema
|
|
324
|
+
* throws `ResponseContractError`; a header that breaks its own was served.
|
|
325
|
+
*/
|
|
326
|
+
schema: registry.expressionForProperty(property),
|
|
327
|
+
optional: property.optional,
|
|
328
|
+
});
|
|
328
329
|
}
|
|
329
330
|
}
|
|
331
|
+
if (streamedTypeOf(program, response.type) !== undefined)
|
|
332
|
+
entry.streamed = true;
|
|
333
|
+
byStatus.set(key, entry);
|
|
330
334
|
}
|
|
331
|
-
if (headers.length === 0)
|
|
332
|
-
continue;
|
|
333
|
-
const existing = byStatus.get(status) ?? [];
|
|
334
|
-
byStatus.set(status, [
|
|
335
|
-
...existing,
|
|
336
|
-
...headers.filter((h) => !existing.some((e) => e.name === h.name)),
|
|
337
|
-
]);
|
|
338
|
-
}
|
|
339
|
-
return [...byStatus].map(([status, headers]) => ({ status, headers }));
|
|
340
|
-
}
|
|
341
|
-
/** The media types each numeric response status offers, from the compiler's own resolution. */
|
|
342
|
-
function responseMediaTypesOf(operation) {
|
|
343
|
-
const byStatus = new Map();
|
|
344
|
-
for (const response of operation.responses) {
|
|
345
|
-
const status = response.statusCodes;
|
|
346
|
-
if (typeof status !== "number")
|
|
347
|
-
continue;
|
|
348
|
-
const types = responseContentTypesOf(operation, status);
|
|
349
|
-
if (types.length === 0)
|
|
350
|
-
continue;
|
|
351
|
-
byStatus.set(status, types);
|
|
352
335
|
}
|
|
353
|
-
return [...byStatus
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
336
|
+
return [...byStatus.values()]
|
|
337
|
+
.map((entry) => {
|
|
338
|
+
const contentTypes = [...entry.contentTypes];
|
|
339
|
+
const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
|
|
340
|
+
return {
|
|
341
|
+
status: entry.status,
|
|
342
|
+
schema: entry.body === undefined
|
|
343
|
+
? undefined
|
|
344
|
+
: binary
|
|
345
|
+
? "z.unknown()"
|
|
346
|
+
: registry.expressionFor(entry.body),
|
|
347
|
+
contentTypes,
|
|
348
|
+
headers: entry.headers,
|
|
349
|
+
binary,
|
|
350
|
+
streamed: entry.streamed,
|
|
351
|
+
textual: entry.body !== undefined && isStringType(entry.body),
|
|
352
|
+
};
|
|
353
|
+
})
|
|
354
|
+
.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
|
|
355
|
+
statusOrderOf(a.status) - statusOrderOf(b.status));
|
|
367
356
|
}
|
|
368
357
|
/**
|
|
369
358
|
* Where an arm sits in OpenAPI's own precedence order.
|
|
@@ -380,21 +369,40 @@ function statusPrecedenceOf(status) {
|
|
|
380
369
|
return 2;
|
|
381
370
|
return typeof status === "number" ? 0 : 1;
|
|
382
371
|
}
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Whether a type's values are strings: `string` or a scalar built on it, a string literal or template,
|
|
374
|
+
* or a union of those.
|
|
375
|
+
*
|
|
376
|
+
* Read from the type rather than from the emitted schema, which would be a decision made by matching
|
|
377
|
+
* generated text - `z.iso.datetime()` is a string and does not begin with `z.string`.
|
|
378
|
+
*/
|
|
379
|
+
function isStringType(type) {
|
|
380
|
+
switch (type.kind) {
|
|
381
|
+
case "String":
|
|
382
|
+
case "StringTemplate":
|
|
383
|
+
return true;
|
|
384
|
+
case "Scalar":
|
|
385
|
+
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
386
|
+
if (scalar.name === "string")
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
case "Union":
|
|
391
|
+
return [...type.variants.values()].every((variant) => isStringType(variant.type));
|
|
392
|
+
default:
|
|
393
|
+
return false;
|
|
391
394
|
}
|
|
392
|
-
|
|
395
|
+
}
|
|
396
|
+
/** Ascending within one precedence class, so the order does not depend on declaration order. */
|
|
397
|
+
function statusOrderOf(status) {
|
|
398
|
+
if (typeof status === "number")
|
|
399
|
+
return status;
|
|
400
|
+
return status === "default" ? 0 : Number(status.charAt(0));
|
|
393
401
|
}
|
|
394
402
|
/**
|
|
395
403
|
* Everything the document says about authenticating an operation, read ONCE.
|
|
396
404
|
*
|
|
397
|
-
* **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
|
|
405
|
+
* **`scopes` and a boolean `noAuth` were two lossy projections of this, each with its own read of the same
|
|
398
406
|
* fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
|
|
399
407
|
* gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
|
|
400
408
|
* rebuild the requirements itself -- which is what both server emitters were doing, in two
|
|
@@ -406,7 +414,6 @@ function bodySchemaOf(response, registry) {
|
|
|
406
414
|
function authenticationFor(program, operation) {
|
|
407
415
|
const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
|
|
408
416
|
const security = [];
|
|
409
|
-
let noAuth = false;
|
|
410
417
|
for (const option of options) {
|
|
411
418
|
const requirement = {};
|
|
412
419
|
let anonymous = false;
|
|
@@ -418,7 +425,6 @@ function authenticationFor(program, operation) {
|
|
|
418
425
|
*/
|
|
419
426
|
if (scheme.type === "noAuth") {
|
|
420
427
|
anonymous = true;
|
|
421
|
-
noAuth = true;
|
|
422
428
|
continue;
|
|
423
429
|
}
|
|
424
430
|
/** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
|
|
@@ -427,13 +433,24 @@ function authenticationFor(program, operation) {
|
|
|
427
433
|
? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
|
|
428
434
|
: [];
|
|
429
435
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
/**
|
|
437
|
+
* **An option that needs nothing is published as `{}`, and kept.** `NoAuth | X` is
|
|
438
|
+
* `security: [{}, { X: [] }]` in the document `@typespec/openapi3` writes from the same program,
|
|
439
|
+
* and `{}` is the requirement every caller satisfies. Dropping it published `[{ X: [] }]`, a
|
|
440
|
+
* contract that refuses the anonymous caller the document accepts; typespec-hono had carried a
|
|
441
|
+
* corrected copy of this rule of its own because this one was wrong.
|
|
442
|
+
*/
|
|
443
|
+
if (anonymous || Object.keys(requirement).length > 0)
|
|
433
444
|
security.push(requirement);
|
|
434
445
|
}
|
|
435
446
|
const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
|
|
436
|
-
|
|
447
|
+
const anonymous = security.some((requirement) => Object.keys(requirement).length === 0);
|
|
448
|
+
const real = security.some((requirement) => Object.keys(requirement).length > 0);
|
|
449
|
+
return {
|
|
450
|
+
security,
|
|
451
|
+
scopes,
|
|
452
|
+
authentication: !real ? "none" : anonymous ? "optional" : "required",
|
|
453
|
+
};
|
|
437
454
|
}
|
|
438
455
|
/**
|
|
439
456
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
@@ -591,70 +608,6 @@ function elementKindOf(program, type) {
|
|
|
591
608
|
function isArrayType(type) {
|
|
592
609
|
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
593
610
|
}
|
|
594
|
-
/**
|
|
595
|
-
* How to choose between an operation's two success statuses, read from the declared arms.
|
|
596
|
-
*
|
|
597
|
-
* **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
|
|
598
|
-
* banned fallback wearing a different hat: a genuine validation failure on the first arm would be
|
|
599
|
-
* reported as the second arm's shape. Each arm pins a **required literal** property instead -
|
|
600
|
-
* `awaiting: true` on the accepted one - and a consumer reads that one property.
|
|
601
|
-
*
|
|
602
|
-
* This replaces hand-written per-operation closures, which existed only because a spec declaring the
|
|
603
|
-
* SAME body model for both statuses could not say which was which.
|
|
604
|
-
*/
|
|
605
|
-
function statusDiscriminatorOf(operation, statuses) {
|
|
606
|
-
if (statuses.length < 2)
|
|
607
|
-
return undefined;
|
|
608
|
-
for (const status of statuses) {
|
|
609
|
-
const body = successBodyOf(operation, status);
|
|
610
|
-
if (body?.kind !== "Model")
|
|
611
|
-
continue;
|
|
612
|
-
for (const [name, property] of body.properties) {
|
|
613
|
-
// The OTHER arm carries the same property as optional; a required literal is the mark.
|
|
614
|
-
if (property.optional)
|
|
615
|
-
continue;
|
|
616
|
-
if (property.type.kind === "Boolean" || property.type.kind === "String") {
|
|
617
|
-
return { property: name, value: property.type.value, status };
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
return undefined;
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* How a handler says WHICH success status it means, when the spec declares more than one.
|
|
625
|
-
*
|
|
626
|
-
* **The `@statusCode` property is the selector, and it is the one the spec already wrote.**
|
|
627
|
-
* `model Created { @statusCode statusCode: 200 | 201; @body body: Item }` declares both statuses and
|
|
628
|
-
* names the property that chooses between them, so nothing has to be inferred from the body's shape.
|
|
629
|
-
*
|
|
630
|
-
* That inference is what {@link statusDiscriminatorOf} does, and it needs a required literal property
|
|
631
|
-
* on one of the bodies. Measured across the whole conformance corpus: it fires ZERO times, so before
|
|
632
|
-
* this the second arm had never been emitted by any spec at all - `armFor` could not select a status
|
|
633
|
-
* that was never written. Both remain: a discriminator still serves two DIFFERENT response models,
|
|
634
|
-
* this serves one model declaring a union of statuses.
|
|
635
|
-
*
|
|
636
|
-
* All statuses of such a model share one body by construction - it is one model - so every arm
|
|
637
|
-
* carries the same schema and only the status and the selector value differ.
|
|
638
|
-
*/
|
|
639
|
-
function statusSelectorOf(program, operation, statuses) {
|
|
640
|
-
if (statuses.length < 2)
|
|
641
|
-
return undefined;
|
|
642
|
-
for (const response of operation.responses) {
|
|
643
|
-
const type = response.type;
|
|
644
|
-
if (type === undefined || type.kind !== "Model")
|
|
645
|
-
continue;
|
|
646
|
-
for (const [name, property] of type.properties) {
|
|
647
|
-
if (isStatusCode(program, property))
|
|
648
|
-
return { property: name, statuses };
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
return undefined;
|
|
652
|
-
}
|
|
653
|
-
/** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
|
|
654
|
-
function alternateSchemaFor(operation, status, registry) {
|
|
655
|
-
const body = successBodyOf(operation, status);
|
|
656
|
-
return body === undefined ? undefined : registry.expressionFor(body);
|
|
657
|
-
}
|
|
658
611
|
/**
|
|
659
612
|
* The Zod for a **multipart** request body, built from its parts.
|
|
660
613
|
*
|
|
@@ -701,16 +654,26 @@ function multipartTsOf(body, registry) {
|
|
|
701
654
|
});
|
|
702
655
|
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
703
656
|
}
|
|
704
|
-
function multipartSchemaOf(body, registry) {
|
|
657
|
+
function multipartSchemaOf(program, body, registry) {
|
|
705
658
|
const entries = body.parts.map((part) => {
|
|
706
659
|
const type = part.body?.type;
|
|
660
|
+
/**
|
|
661
|
+
* **A form carries text, so a part that means a number or a boolean is decoded first**, by the
|
|
662
|
+
* same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
|
|
663
|
+
* `0.5`, and text that is not a well-formed number is passed on unchanged to fail against the
|
|
664
|
+
* schema the document publishes. `c.req.parseBody()` hands a text part over as a string, so
|
|
665
|
+
* `z.number()` alone refused every conformant request; measured on `@typespec/http-specs`
|
|
666
|
+
* `payload/multipart/non-string-float`, a 400 for the request the scenario documents.
|
|
667
|
+
*/
|
|
707
668
|
const inner = type === undefined
|
|
708
669
|
? "z.unknown()"
|
|
709
670
|
: isFilePart(type)
|
|
710
671
|
? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
|
|
711
672
|
: isBinaryPart(type)
|
|
712
673
|
? "z.unknown()"
|
|
713
|
-
:
|
|
674
|
+
: isJsonPart(part)
|
|
675
|
+
? jsonTextDecoded(registry.expressionFor(type))
|
|
676
|
+
: wireDecoded(registry.expressionFor(type), wireKindOf(program, type), elementKindOf(program, type));
|
|
714
677
|
// `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
|
|
715
678
|
const value = part.multi === true ? `z.array(${inner})` : inner;
|
|
716
679
|
// `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
|
|
@@ -782,6 +745,27 @@ function isFilePart(type) {
|
|
|
782
745
|
return false;
|
|
783
746
|
}
|
|
784
747
|
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
748
|
+
/**
|
|
749
|
+
* Whether a multipart part travels as JSON: every media type `@typespec/http` resolves for it is a
|
|
750
|
+
* JSON one. That is `application/json` for a model or array part, and whatever an explicit
|
|
751
|
+
* `@header contentType` names for a part that declares one.
|
|
752
|
+
*/
|
|
753
|
+
function isJsonPart(part) {
|
|
754
|
+
const contentTypes = part.body?.contentTypes ?? [];
|
|
755
|
+
return (contentTypes.length > 0 &&
|
|
756
|
+
contentTypes.every((contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType)));
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* **A JSON part is decoded from the JSON text a form carries** before the document's schema applies.
|
|
760
|
+
* A client writes the part as text with `Content-Type: application/json` and no filename, so
|
|
761
|
+
* `c.req.parseBody()` hands over a string; the schema alone refused every such request with
|
|
762
|
+
* `expected object, received string`. Text that is not JSON is an issue of its own rather than a
|
|
763
|
+
* string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
|
|
764
|
+
* is not text is left alone for the schema to judge.
|
|
765
|
+
*/
|
|
766
|
+
function jsonTextDecoded(declared) {
|
|
767
|
+
return `z.preprocess((raw, ctx) => { if (typeof raw !== "string") return raw; try { return JSON.parse(raw); } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON in a multipart part" }); return z.NEVER; } }, ${declared})`;
|
|
768
|
+
}
|
|
785
769
|
function isBinaryPart(type) {
|
|
786
770
|
if (type.kind === "Scalar") {
|
|
787
771
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
@@ -992,15 +976,67 @@ function mediaTypeDecoded(declared) {
|
|
|
992
976
|
.map((entry) => entry.trim().replace(/^"|"$/g, ""));
|
|
993
977
|
if (values === undefined || values.length === 0)
|
|
994
978
|
return declared;
|
|
979
|
+
/**
|
|
980
|
+
* **A declared media RANGE admits the types inside it, and a literal admits only itself.**
|
|
981
|
+
*
|
|
982
|
+
* \`@header contentType: "text/*"\` published \`z.literal("text/*")\`, so the only request that
|
|
983
|
+
* satisfied it was one sending the range itself as its \`Content-Type\`, which no client does.
|
|
984
|
+
* Measured before this existed: \`text/plain\` and \`text/html\` both answered 400 on a route whose
|
|
985
|
+
* document declares the whole \`text\` group.
|
|
986
|
+
*
|
|
987
|
+
* A template literal is the Zod counterpart of the TypeScript type the contract already uses for
|
|
988
|
+
* a range, so the validator and the handler's own type say the same thing.
|
|
989
|
+
*/
|
|
990
|
+
const admitted = values.map((value) => {
|
|
991
|
+
/**
|
|
992
|
+
* **The full wildcard admits every media type, so there is nothing left to match on.**
|
|
993
|
+
*
|
|
994
|
+
* Spelled as a template literal it becomes the prefix `*` + `/`, which matches only a
|
|
995
|
+
* Content-Type whose text begins with the range's own first two characters. Measured on the
|
|
996
|
+
* emitted schema: `application/json`, `text/plain` and `image/png` were all REFUSED and the
|
|
997
|
+
* literal `*` + `/*` was the one value accepted, which is the defect the range branch exists
|
|
998
|
+
* to remove rather than to respell. A consumer declares this on a file upload that takes
|
|
999
|
+
* whatever the caller sends.
|
|
1000
|
+
*/
|
|
1001
|
+
if (value === "*/*")
|
|
1002
|
+
return "z.string()";
|
|
1003
|
+
return value.endsWith("/*")
|
|
1004
|
+
? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
|
|
1005
|
+
: `z.literal(${JSON.stringify(value)})`;
|
|
1006
|
+
});
|
|
1007
|
+
const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
|
|
1008
|
+
const ranged = values.some((value) => value.endsWith("/*")) ? schema : declared;
|
|
995
1009
|
// Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
|
|
996
1010
|
const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
|
|
997
|
-
return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${
|
|
1011
|
+
return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${ranged})`;
|
|
998
1012
|
}
|
|
999
|
-
function parameterSchemasOf(program, operation, registry
|
|
1013
|
+
function parameterSchemasOf(program, operation, registry, segments,
|
|
1014
|
+
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
1015
|
+
literalQueryNames) {
|
|
1000
1016
|
const byTarget = {};
|
|
1001
1017
|
let accept;
|
|
1018
|
+
/** Form-exploded records and models, gathered back under their names around the query object. */
|
|
1019
|
+
const gathered = [];
|
|
1020
|
+
const queryNames = operation.parameters.parameters
|
|
1021
|
+
.filter((parameter) => parameter.type === "query")
|
|
1022
|
+
.map((parameter) => parameter.name);
|
|
1002
1023
|
for (const parameter of operation.parameters.parameters) {
|
|
1003
|
-
|
|
1024
|
+
/**
|
|
1025
|
+
* **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
|
|
1026
|
+
* `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
|
|
1027
|
+
* filter named three of the four, so a `@cookie` reached no validator, no handler input and no
|
|
1028
|
+
* diagnostic while `@typespec/openapi3` published it as `in: cookie, required: true`. A
|
|
1029
|
+
* requirement the document states and the server cannot see is the defect class this package
|
|
1030
|
+
* exists to remove, and a cookie is where a session and a CSRF token normally live.
|
|
1031
|
+
*
|
|
1032
|
+
* The exclusion was by omission rather than by a `never` check, which is why TypeScript never
|
|
1033
|
+
* objected; every other place that reads `parameter.type` handles an encoding question a cookie
|
|
1034
|
+
* does not have, since `CookieOptions` carries only a name.
|
|
1035
|
+
*/
|
|
1036
|
+
if (parameter.type !== "path" &&
|
|
1037
|
+
parameter.type !== "query" &&
|
|
1038
|
+
parameter.type !== "header" &&
|
|
1039
|
+
parameter.type !== "cookie") {
|
|
1004
1040
|
continue;
|
|
1005
1041
|
}
|
|
1006
1042
|
/**
|
|
@@ -1046,11 +1082,54 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
1046
1082
|
* occurrences arrive already an array and pass through untouched, exactly as the delimiter
|
|
1047
1083
|
* arm leaves a pre-split value alone.
|
|
1048
1084
|
*/
|
|
1049
|
-
const
|
|
1085
|
+
const boxedOrSplit = isExplodedCollection(parameter)
|
|
1050
1086
|
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1051
1087
|
: delimiter === undefined
|
|
1052
1088
|
? decoded
|
|
1053
1089
|
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
|
|
1090
|
+
/**
|
|
1091
|
+
* **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
|
|
1092
|
+
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
1093
|
+
* run on that. See {@link uriExpansionCall}.
|
|
1094
|
+
*/
|
|
1095
|
+
const shape = expansionShapeOf(parameter.param.type);
|
|
1096
|
+
const values = recordValuesOf(program, parameter.param.type);
|
|
1097
|
+
let expression = boxedOrSplit;
|
|
1098
|
+
if (parameter.type === "path") {
|
|
1099
|
+
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1100
|
+
if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
|
|
1101
|
+
expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
else if (parameter.type === "query" && shape === "record") {
|
|
1105
|
+
if (parameter.explode === true) {
|
|
1106
|
+
/**
|
|
1107
|
+
* `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
|
|
1108
|
+
* this entry to read; the gatherer around the query object puts one back.
|
|
1109
|
+
*/
|
|
1110
|
+
const model = parameter.param.type.kind === "Model" && parameter.param.type.indexer === undefined
|
|
1111
|
+
? parameter.param.type
|
|
1112
|
+
: undefined;
|
|
1113
|
+
const properties = model === undefined ? [] : modelPropertiesOf(model);
|
|
1114
|
+
gathered.push(explodedQueryCall({
|
|
1115
|
+
name: parameter.name,
|
|
1116
|
+
others: [...queryNames.filter((name) => name !== parameter.name), ...literalQueryNames],
|
|
1117
|
+
keys: model === undefined
|
|
1118
|
+
? undefined
|
|
1119
|
+
: properties.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1120
|
+
numbers: properties
|
|
1121
|
+
.filter((property) => wireKindOf(program, property.type) === "number")
|
|
1122
|
+
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1123
|
+
booleans: properties
|
|
1124
|
+
.filter((property) => wireKindOf(program, property.type) === "boolean")
|
|
1125
|
+
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1126
|
+
values,
|
|
1127
|
+
}));
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1054
1133
|
const entry = `\t${objectKey(parameter.name)}: ${expression},`;
|
|
1055
1134
|
(byTarget[parameter.type] ??= []).push(entry);
|
|
1056
1135
|
/**
|
|
@@ -1074,24 +1153,50 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
1074
1153
|
const entries = byTarget[target];
|
|
1075
1154
|
return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
|
|
1076
1155
|
};
|
|
1156
|
+
const query = shape("query");
|
|
1077
1157
|
return {
|
|
1158
|
+
cookie: shape("cookie"),
|
|
1078
1159
|
path: shape("path"),
|
|
1079
|
-
query:
|
|
1160
|
+
query: query === undefined
|
|
1161
|
+
? undefined
|
|
1162
|
+
: gathered.reduce((inner, gather) => `z.preprocess(${gather}, ${inner})`, query),
|
|
1163
|
+
queryFields: query,
|
|
1080
1164
|
header: shape("header"),
|
|
1081
1165
|
negotiatedHeader: shape("header-without-accept"),
|
|
1082
1166
|
accept,
|
|
1083
1167
|
};
|
|
1084
1168
|
}
|
|
1169
|
+
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1170
|
+
function expansionShapeOf(type) {
|
|
1171
|
+
if (isArrayType(type))
|
|
1172
|
+
return "list";
|
|
1173
|
+
return type.kind === "Model" ? "record" : "scalar";
|
|
1174
|
+
}
|
|
1175
|
+
/** How a record's values decode, read from its indexer the way a scalar's kind is read. */
|
|
1176
|
+
function recordValuesOf(program, type) {
|
|
1177
|
+
if (type.kind !== "Model" || isArrayType(type) || type.indexer === undefined)
|
|
1178
|
+
return "";
|
|
1179
|
+
return wireKindOf(program, type.indexer.value) ?? "";
|
|
1180
|
+
}
|
|
1181
|
+
/** A model's properties, its base model's first, as the document lists them. */
|
|
1182
|
+
function modelPropertiesOf(model) {
|
|
1183
|
+
const chain = [];
|
|
1184
|
+
for (let current = model; current !== undefined; current = current.baseModel) {
|
|
1185
|
+
chain.unshift(current);
|
|
1186
|
+
}
|
|
1187
|
+
return chain.flatMap((current) => [...current.properties.values()]);
|
|
1188
|
+
}
|
|
1085
1189
|
export function collectRoutes(program, registry, service) {
|
|
1086
1190
|
const routes = [];
|
|
1087
1191
|
{
|
|
1088
1192
|
for (const operation of service.operations) {
|
|
1193
|
+
/**
|
|
1194
|
+
* **Every operation is a route, whatever its responses are.** This used to require an exact
|
|
1195
|
+
* 2xx or 3xx, which dropped an operation whose only success was a `2XX` range without a word
|
|
1196
|
+
* - the same silent shape as the 282 bodyless operations an earlier rule dropped, because a
|
|
1197
|
+
* range is not a number. A bodyless success is ordinary HTTP, and so is a ranged one.
|
|
1198
|
+
*/
|
|
1089
1199
|
const statusCode = successStatusOf(operation);
|
|
1090
|
-
if (statusCode === undefined)
|
|
1091
|
-
continue;
|
|
1092
|
-
// No body is a legitimate success - see `EmittedRoute.responseSchema`. Skipping here is what
|
|
1093
|
-
// dropped 282 operations across the conformance corpus without a word.
|
|
1094
|
-
const responseType = successBodyOf(operation, statusCode);
|
|
1095
1200
|
const requestType = requestBodyOf(operation);
|
|
1096
1201
|
/**
|
|
1097
1202
|
* **A streamed operation's EVENT payloads are not reachable from its body.**
|
|
@@ -1110,9 +1215,6 @@ export function collectRoutes(program, registry, service) {
|
|
|
1110
1215
|
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
1111
1216
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
1112
1217
|
const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
|
|
1113
|
-
const statusCodes = successStatusesOf(operation);
|
|
1114
|
-
const discriminator = statusDiscriminatorOf(operation, statusCodes);
|
|
1115
|
-
const statusSelector = statusSelectorOf(program, operation, statusCodes);
|
|
1116
1218
|
/**
|
|
1117
1219
|
* A `bytes` body means the BYTES are the contract.
|
|
1118
1220
|
*
|
|
@@ -1158,11 +1260,36 @@ export function collectRoutes(program, registry, service) {
|
|
|
1158
1260
|
* `multipartTsOf`, both recorded above.
|
|
1159
1261
|
*/
|
|
1160
1262
|
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1263
|
+
/**
|
|
1264
|
+
* **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
|
|
1265
|
+
*
|
|
1266
|
+
* `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
|
|
1267
|
+
* `{}`: the server validated the body and then never handed it to the handler, silently, from
|
|
1268
|
+
* a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
|
|
1269
|
+
* `TS2698: Spread types may only be created from object types`, which is what
|
|
1270
|
+
* `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
|
|
1271
|
+
* had been pinned on in the conformance corpus.
|
|
1272
|
+
*
|
|
1273
|
+
* A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
|
|
1274
|
+
* indexed or optional body already gets and for the same reason: the merge cannot express it.
|
|
1275
|
+
*
|
|
1276
|
+
* **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
|
|
1277
|
+
* naming it twice would put the same value under two keys.
|
|
1278
|
+
*/
|
|
1279
|
+
const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
|
|
1161
1280
|
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1162
|
-
optionalMultipartBody
|
|
1281
|
+
optionalMultipartBody ||
|
|
1282
|
+
unmergeableBody
|
|
1163
1283
|
? (bodyParameter?.property?.name ?? "body")
|
|
1164
1284
|
: undefined;
|
|
1165
1285
|
const authentication = authenticationFor(program, operation);
|
|
1286
|
+
const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
|
|
1287
|
+
.filter((parameter) => parameter.type === "path")
|
|
1288
|
+
.map((parameter) => ({
|
|
1289
|
+
name: parameter.name,
|
|
1290
|
+
optional: parameter.param.optional,
|
|
1291
|
+
reserved: parameter.type === "path" && parameter.allowReserved,
|
|
1292
|
+
})));
|
|
1166
1293
|
routes.push({
|
|
1167
1294
|
operationId: operationIdOf(program, operation.operation),
|
|
1168
1295
|
verb: operation.verb.toUpperCase(),
|
|
@@ -1188,10 +1315,16 @@ export function collectRoutes(program, registry, service) {
|
|
|
1188
1315
|
reservedPathParameters: operation.parameters.parameters
|
|
1189
1316
|
.filter((parameter) => parameter.type === "path" && parameter.allowReserved)
|
|
1190
1317
|
.map((parameter) => parameter.name),
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
responseContentTypes: responseContentTypesOf(operation, statusCode),
|
|
1318
|
+
pathSegments: template.segments,
|
|
1319
|
+
literalQuery: template.literalQuery,
|
|
1320
|
+
responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
|
|
1194
1321
|
requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
|
|
1322
|
+
// `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
|
|
1323
|
+
// here: a string body under `application/json` IS json, and its reader is the JSON one.
|
|
1324
|
+
requestTextual: requestType !== undefined &&
|
|
1325
|
+
rawBodyProperty === undefined &&
|
|
1326
|
+
isStringType(requestType) &&
|
|
1327
|
+
isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])]),
|
|
1195
1328
|
/**
|
|
1196
1329
|
* `@summary` first, `@doc` as the fallback.
|
|
1197
1330
|
*
|
|
@@ -1213,56 +1346,35 @@ export function collectRoutes(program, registry, service) {
|
|
|
1213
1346
|
// such route mounted with nothing checking the request at all.
|
|
1214
1347
|
const parameterBody = operation.parameters.body;
|
|
1215
1348
|
if (parameterBody?.bodyKind === "multipart") {
|
|
1216
|
-
return multipartSchemaOf(parameterBody, registry);
|
|
1349
|
+
return multipartSchemaOf(program, parameterBody, registry);
|
|
1217
1350
|
}
|
|
1218
1351
|
return requestType === undefined ? undefined : registry.expressionFor(requestType);
|
|
1219
1352
|
}),
|
|
1220
1353
|
...(() => {
|
|
1221
|
-
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry));
|
|
1354
|
+
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
|
|
1222
1355
|
return {
|
|
1356
|
+
cookieSchema: split.cookie,
|
|
1223
1357
|
pathSchema: split.path,
|
|
1224
1358
|
querySchema: split.query,
|
|
1359
|
+
queryFieldsSchema: split.queryFields,
|
|
1225
1360
|
headerSchema: split.header,
|
|
1226
1361
|
negotiatedHeaderSchema: split.negotiatedHeader,
|
|
1227
1362
|
accept: split.accept,
|
|
1228
1363
|
};
|
|
1229
1364
|
})(),
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
*/
|
|
1238
|
-
isBinaryPart(responseType) &&
|
|
1239
|
-
isRawBinaryMediaType(responseContentTypesOf(operation, statusCode))
|
|
1240
|
-
? "z.unknown()"
|
|
1241
|
-
: registry.expressionFor(responseType)),
|
|
1365
|
+
/**
|
|
1366
|
+
* Read at `Visibility.Read`, as openapi3 reads a response, and after the request walks so
|
|
1367
|
+
* declarations keep the order they had. Resolving responses under the ambient request
|
|
1368
|
+
* visibility declared six components under the wrong suffix - measured:
|
|
1369
|
+
* type/model/visibility gained six divergences.
|
|
1370
|
+
*/
|
|
1371
|
+
responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
|
|
1242
1372
|
rawBodyProperty,
|
|
1243
1373
|
bodyProperty,
|
|
1244
1374
|
optionalBody,
|
|
1245
|
-
// Responses are read at Visibility.Read, exactly as the success body above is. Resolving
|
|
1246
|
-
// them under the ambient request visibility declared six components under the wrong
|
|
1247
|
-
// suffix - measured: type/model/visibility gained six divergences.
|
|
1248
|
-
errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
|
|
1249
|
-
responseHeaders: responseHeadersOf(program, operation),
|
|
1250
|
-
responseMediaTypes: responseMediaTypesOf(operation),
|
|
1251
1375
|
scopes: authentication.scopes,
|
|
1252
1376
|
security: authentication.security,
|
|
1253
|
-
|
|
1254
|
-
statusBy: discriminator,
|
|
1255
|
-
statusSelector,
|
|
1256
|
-
/**
|
|
1257
|
-
* **Each arm is validated against its OWN shape.**
|
|
1258
|
-
*
|
|
1259
|
-
* A consumer checks its value against the arm's schema, and with one schema
|
|
1260
|
-
* for two statuses the acknowledged response was checked against the resolved arm's
|
|
1261
|
-
* shape - a 502 on a perfectly good 202.
|
|
1262
|
-
*/
|
|
1263
|
-
alternateResponseSchema: discriminator === undefined
|
|
1264
|
-
? undefined
|
|
1265
|
-
: alternateSchemaFor(operation, discriminator.status, registry),
|
|
1377
|
+
authentication: authentication.authentication,
|
|
1266
1378
|
});
|
|
1267
1379
|
}
|
|
1268
1380
|
}
|
|
@@ -1344,7 +1456,7 @@ function renderExternalImports(externals, ...bodies) {
|
|
|
1344
1456
|
function compiled(expression, on) {
|
|
1345
1457
|
return on ? `z.compile(${expression})` : expression;
|
|
1346
1458
|
}
|
|
1347
|
-
function renderSchemas(registry, externals, routeDeclarations,
|
|
1459
|
+
function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
1348
1460
|
const declarations = registry.declarations().map((d) => {
|
|
1349
1461
|
/**
|
|
1350
1462
|
* **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
|
|
@@ -1395,13 +1507,30 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, co
|
|
|
1395
1507
|
* parse - the walk captures the reference while it is still in the temporal dead zone, so the
|
|
1396
1508
|
* failure surfaces at request time rather than at import.
|
|
1397
1509
|
*
|
|
1398
|
-
* **
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1510
|
+
* **CYCLE MEMBERSHIP decides this, not deferral, and that took a zod upgrade to learn.**
|
|
1511
|
+
*
|
|
1512
|
+
* It was keyed on `annotated` - set exactly when the declaration is deferred, which is exactly
|
|
1513
|
+
* when `z.lazy()` is written - on the measurement that a model whose back edge sits on a GETTER
|
|
1514
|
+
* compiled and parsed fine. That was true on zod 4.5.2 and is false on 4.6.5, which made the
|
|
1515
|
+
* recursive parse state weak. The failure moved from the deferred declaration to the one that
|
|
1516
|
+
* REFERENCES it, which was still being compiled:
|
|
1517
|
+
*
|
|
1518
|
+
* ```
|
|
1519
|
+
* const branchSchema = z.lazy(() => z.union([nodeSchema, z.string()]));
|
|
1520
|
+
* const nodeSchema = z.compile(z.strictObject({ ..., branch: branchSchema.exactOptional() }));
|
|
1521
|
+
* nodeSchema.safeParse({ label: "a", attributes: {} });
|
|
1522
|
+
* zod 4.5.2: true
|
|
1523
|
+
* zod 4.6.5: TypeError: Cannot read properties of undefined (reading '_zod')
|
|
1524
|
+
* ```
|
|
1525
|
+
*
|
|
1526
|
+
* Bisected: naming a compiled schema from inside a `z.lazy()` body is the whole trigger, and
|
|
1527
|
+
* the self-referencing getter is not needed for it. So the same rule the registry already
|
|
1528
|
+
* applies to structural types applies here - every member of the cycle, not only the deferred
|
|
1529
|
+
* one - and `structural` is the fact it already holds for exactly that set.
|
|
1530
|
+
*
|
|
1531
|
+
* The peer range admits 4.6, so this reached a running server on the first parse.
|
|
1403
1532
|
*/
|
|
1404
|
-
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.
|
|
1533
|
+
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.structural === undefined)};\n`;
|
|
1405
1534
|
});
|
|
1406
1535
|
/**
|
|
1407
1536
|
* **Written only when something in the file names it**, like the `ResponseArm` import below and for
|
|
@@ -1440,20 +1569,45 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, co
|
|
|
1440
1569
|
*/
|
|
1441
1570
|
const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
|
|
1442
1571
|
const parts = [generatedBanner()];
|
|
1443
|
-
if (usesZod)
|
|
1572
|
+
if (usesZod || routeDeclarations.length > 0)
|
|
1444
1573
|
parts.push('\nimport { z } from "zod";\n');
|
|
1445
1574
|
/**
|
|
1446
|
-
*
|
|
1575
|
+
* **The arm shape is declared IN this file, not imported, and that is what lets two emitters share
|
|
1576
|
+
* it.** It used to be `import type { ResponseArm } from <runtime-module>`, the one line of this
|
|
1577
|
+
* file that depended on where a runtime lived. A server emitter and a tool emitter pointed at one
|
|
1578
|
+
* output directory both write `schemas.gen.ts`, and `emitFile` is a silent last-writer-wins, so the
|
|
1579
|
+
* two had to agree on that specifier or the order of `emit:` decided which import survived. With
|
|
1580
|
+
* the shape declared here the file depends on no runtime at all, and `runtime-module` had nothing
|
|
1581
|
+
* left to decide.
|
|
1447
1582
|
*
|
|
1448
|
-
* The annotation is what makes a misspelled or omitted `schema` a compile error rather than
|
|
1449
|
-
* that silently validates nothing
|
|
1450
|
-
*
|
|
1583
|
+
* The annotation is still what makes a misspelled or omitted `schema` a compile error rather than
|
|
1584
|
+
* an arm that silently validates nothing. Not exported: the type an application reads an arm
|
|
1585
|
+
* through is `typespec-http-zod/runtime`'s, which is structurally the same. Emitted only where
|
|
1586
|
+
* there are arms to annotate, because an unused declaration fails the lint a generated file has to
|
|
1451
1587
|
* pass like any other.
|
|
1452
1588
|
*/
|
|
1589
|
+
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
1590
|
+
/**
|
|
1591
|
+
* **The RFC 6570 decoders are declared here, once, and only where a validator calls them**, for the
|
|
1592
|
+
* reason `z` is imported only when used: an unused declaration fails the lint a generated file has
|
|
1593
|
+
* to pass. Declared rather than imported, so this file still depends on no runtime.
|
|
1594
|
+
*/
|
|
1595
|
+
const referenced = identifiersIn(routeDeclarations.join(""));
|
|
1596
|
+
if (referenced.has("uriExpansion"))
|
|
1597
|
+
parts.push(URI_EXPANSION_SOURCE);
|
|
1598
|
+
if (referenced.has("explodedQuery"))
|
|
1599
|
+
parts.push(EXPLODED_QUERY_SOURCE);
|
|
1453
1600
|
if (routeDeclarations.length > 0) {
|
|
1454
|
-
parts.push(`
|
|
1601
|
+
parts.push(`
|
|
1602
|
+
/** One declared response: its status key, its body's schema, its media types and its headers. */
|
|
1603
|
+
type ResponseArm = {
|
|
1604
|
+
readonly status: number | "default" | \`\${1 | 2 | 3 | 4 | 5}XX\`;
|
|
1605
|
+
readonly schema: z.ZodType | undefined;
|
|
1606
|
+
readonly contentTypes?: readonly string[];
|
|
1607
|
+
readonly headers?: readonly { readonly name: string; readonly optional: boolean }[];
|
|
1608
|
+
};
|
|
1609
|
+
`);
|
|
1455
1610
|
}
|
|
1456
|
-
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
1457
1611
|
parts.push(declarations.join(""));
|
|
1458
1612
|
if (routeDeclarations.length > 0)
|
|
1459
1613
|
parts.push(`\n${routeDeclarations.join("\n")}\n`);
|
|
@@ -1542,129 +1696,89 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1542
1696
|
};
|
|
1543
1697
|
// Order follows the request: what identifies the resource, then what filters it, then its body.
|
|
1544
1698
|
const path = declare("Path", route.pathSchema);
|
|
1545
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* Where a query group is gathered, its object is declared on its own and the wire validator wraps
|
|
1701
|
+
* that identifier, so both have a name and only the second pipes.
|
|
1702
|
+
*/
|
|
1703
|
+
const gathers = route.queryFieldsSchema !== undefined && route.querySchema !== route.queryFieldsSchema;
|
|
1704
|
+
const queryFields = gathers ? declare("QueryFields", route.queryFieldsSchema) : undefined;
|
|
1705
|
+
const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
|
|
1706
|
+
? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
|
|
1707
|
+
: route.querySchema);
|
|
1708
|
+
const cookie = declare("Cookie", route.cookieSchema);
|
|
1546
1709
|
const negotiating = shared.has(`${route.verb} ${route.path}`);
|
|
1547
1710
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
1548
1711
|
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
1549
1712
|
const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
|
|
1550
|
-
|
|
1551
|
-
|
|
1713
|
+
/**
|
|
1714
|
+
* One identifier per distinct body, suffixed by the status that first carries it. Two statuses
|
|
1715
|
+
* sharing one inline body share its declaration rather than emitting it twice.
|
|
1716
|
+
*/
|
|
1717
|
+
const declaredBodies = new Map();
|
|
1718
|
+
const arms = route.responses.map((response) => {
|
|
1719
|
+
const suffix = response.status === "default" ? "Default" : String(response.status);
|
|
1720
|
+
/**
|
|
1721
|
+
* The headers this status declares, as one object schema, so a server can check what it is
|
|
1722
|
+
* about to send the way it already checks the body.
|
|
1723
|
+
*
|
|
1724
|
+
* **A plain `z.object`, which strips.** A response carries headers the document does not
|
|
1725
|
+
* declare - `Content-Type` among them, which the server sets itself - and those are not a
|
|
1726
|
+
* contract violation. What is checked is that every header the document DOES declare carries
|
|
1727
|
+
* a value the document permits.
|
|
1728
|
+
*/
|
|
1729
|
+
const headers = response.headers.length === 0
|
|
1730
|
+
? undefined
|
|
1731
|
+
: declare(`Response${suffix}Headers`, `z.object({\n${response.headers
|
|
1732
|
+
.map((header) => `\t${objectKey(header.name)}: ${header.schema},`)
|
|
1733
|
+
.join("\n")}\n})`);
|
|
1734
|
+
if (response.schema === undefined) {
|
|
1735
|
+
return { status: response.status, schema: undefined, headers };
|
|
1736
|
+
}
|
|
1737
|
+
if (!declaredBodies.has(response.schema)) {
|
|
1738
|
+
declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
|
|
1739
|
+
}
|
|
1740
|
+
return { status: response.status, schema: declaredBodies.get(response.schema), headers };
|
|
1741
|
+
});
|
|
1552
1742
|
const responses = schemaConst(route.operationId, "Responses");
|
|
1553
|
-
declarations.push(`export const ${responses} = ${responseArmsOf(route,
|
|
1743
|
+
declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
|
|
1554
1744
|
names.set(route.operationId, {
|
|
1555
1745
|
operationId: route.operationId,
|
|
1556
1746
|
path,
|
|
1557
1747
|
query,
|
|
1748
|
+
queryFields: gathers ? queryFields : query,
|
|
1558
1749
|
header,
|
|
1750
|
+
cookie,
|
|
1559
1751
|
body,
|
|
1560
|
-
|
|
1561
|
-
alternateResponse,
|
|
1752
|
+
arms,
|
|
1562
1753
|
responses,
|
|
1563
1754
|
});
|
|
1564
1755
|
}
|
|
1565
1756
|
return { names, declarations };
|
|
1566
1757
|
}
|
|
1567
1758
|
/**
|
|
1568
|
-
* What an operation may answer with, as a literal - each declared status
|
|
1569
|
-
* document gives it, in OpenAPI's own precedence order.
|
|
1759
|
+
* What an operation may answer with, as a literal - each declared status with the body, media types
|
|
1760
|
+
* and headers the document gives it, in OpenAPI's own precedence order.
|
|
1761
|
+
*
|
|
1762
|
+
* **Rendered from one record per status, so no arm can shadow another.** The primary success arm and
|
|
1763
|
+
* the failure arms used to be rendered from separate projections and then deduplicated by matching
|
|
1764
|
+
* the emitted text for `status:`, which is how a redirect came to be declared twice and how a
|
|
1765
|
+
* failure arm came to carry no headers while a success arm with the same headers did.
|
|
1570
1766
|
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1767
|
+
* `contentTypes` and `headers` are written only where the document names some, so "none declared"
|
|
1768
|
+
* and "none carried" are the same state rather than two a runtime has to tell apart.
|
|
1573
1769
|
*/
|
|
1574
|
-
function responseArmsOf(route,
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
* **Emitted whenever the document names any, including a single one.** It was emitted only for
|
|
1587
|
-
* several, on the reasoning that one type is what a `respond` already assumes - and that reasoning
|
|
1588
|
-
* was wrong, because it assumed the one type is JSON. A `text/plain` arm and a JSON arm were then
|
|
1589
|
-
* indistinguishable to a generic `respond`: the document knew and the runtime did not, so consumers
|
|
1590
|
-
* restated the media type in their own result envelope to get it back.
|
|
1591
|
-
*/
|
|
1592
|
-
const mediaTypesFor = (status) => {
|
|
1593
|
-
const declared = route.responseMediaTypes.find((entry) => entry.status === status)?.contentTypes ?? [];
|
|
1594
|
-
if (declared.length === 0)
|
|
1595
|
-
return "";
|
|
1596
|
-
return `, contentTypes: [${declared.map((t) => JSON.stringify(t)).join(", ")}]`;
|
|
1597
|
-
};
|
|
1598
|
-
const headersFor = (status) => {
|
|
1599
|
-
const declared = route.responseHeaders.find((entry) => entry.status === status)?.headers ?? [];
|
|
1600
|
-
if (declared.length === 0)
|
|
1601
|
-
return "";
|
|
1602
|
-
const rendered = declared
|
|
1603
|
-
.map((h) => `{ name: ${JSON.stringify(h.name)}, property: ${JSON.stringify(h.property)} }`)
|
|
1604
|
-
.join(", ");
|
|
1605
|
-
return `, headers: [${rendered}]`;
|
|
1606
|
-
};
|
|
1607
|
-
if (route.statusSelector !== undefined) {
|
|
1608
|
-
/**
|
|
1609
|
-
* **One arm per declared status, keyed on the property the spec already named.**
|
|
1610
|
-
*
|
|
1611
|
-
* The selector arms come first and the default last, the same order the discriminator below
|
|
1612
|
-
* uses: anything scanning for a match wants the specific ones before the fallback. Every arm
|
|
1613
|
-
* carries the same schema because a `@statusCode` union is ONE model - the statuses differ,
|
|
1614
|
-
* the body does not.
|
|
1615
|
-
*/
|
|
1616
|
-
const { property, statuses } = route.statusSelector;
|
|
1617
|
-
for (const status of statuses.filter((candidate) => candidate !== route.statusCode)) {
|
|
1618
|
-
arms.push(`{ status: ${status}, schema: ${primary}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${status} } }`);
|
|
1619
|
-
}
|
|
1620
|
-
arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
|
|
1621
|
-
}
|
|
1622
|
-
else if (route.statusBy === undefined) {
|
|
1623
|
-
arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
|
|
1624
|
-
}
|
|
1625
|
-
else {
|
|
1626
|
-
const alternateSchema = alternate ?? "undefined";
|
|
1627
|
-
const { property, value, status } = route.statusBy;
|
|
1628
|
-
arms.push(`{ status: ${status}, schema: ${alternateSchema}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${JSON.stringify(value)} } }`, `{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
|
|
1629
|
-
}
|
|
1630
|
-
/**
|
|
1631
|
-
* The failure arms, which used to be dropped entirely.
|
|
1632
|
-
*
|
|
1633
|
-
* **The document declares what an error body looks like and the server never passed it on**, so
|
|
1634
|
-
* `respond` could not check what it sends on failure even in principle.
|
|
1635
|
-
*
|
|
1636
|
-
* **This used to fall back to the service-wide error schema, and the fallback was the defect.**
|
|
1637
|
-
* `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for -
|
|
1638
|
-
* which, while `"default"` was deliberately given none, meant every catch-all in every service
|
|
1639
|
-
* answered with whichever shape `errorBodyOf` had settled on rather than the one its own response
|
|
1640
|
-
* declares. Each arm resolves its own body now, so an arm with no schema is a response the document
|
|
1641
|
-
* says carries no body, and saying `undefined` is the truthful answer rather than a guess.
|
|
1642
|
-
*/
|
|
1643
|
-
/**
|
|
1644
|
-
* **An error arm whose status the primary arm already answers is dropped, primary winning.**
|
|
1645
|
-
*
|
|
1646
|
-
* `errorArmsOf` excludes 2xx from the failure arms, so widening the primary to include 3xx made a
|
|
1647
|
-
* redirect BOTH: `[{ status: 302, schema: undefined, headers: [...] }, { status: 302, schema:
|
|
1648
|
-
* undefined }]`, two arms for one status where the second shadows the first's headers for anything
|
|
1649
|
-
* scanning past the first match.
|
|
1650
|
-
*
|
|
1651
|
-
* Widening `isSuccessKey` to match would be wrong in the other direction: an operation declaring a
|
|
1652
|
-
* 200 AND a 302 has 200 as its primary, so the 302 must stay a failure arm to be emitted at all.
|
|
1653
|
-
* Deduplicating here keeps both cases, because it asks what was actually emitted rather than what
|
|
1654
|
-
* might have been.
|
|
1655
|
-
*/
|
|
1656
|
-
const emitted = new Set();
|
|
1657
|
-
for (const arm of arms) {
|
|
1658
|
-
const status = /status: ([^,}]+)/.exec(arm)?.[1]?.trim();
|
|
1659
|
-
if (status !== undefined)
|
|
1660
|
-
emitted.add(status);
|
|
1661
|
-
}
|
|
1662
|
-
for (const arm of route.errorArms) {
|
|
1663
|
-
if (emitted.has(String(arm.status)) || emitted.has(JSON.stringify(arm.status)))
|
|
1664
|
-
continue;
|
|
1665
|
-
arms.push(`{ status: ${JSON.stringify(arm.status)}, schema: ${arm.schema ?? "undefined"} }`);
|
|
1666
|
-
}
|
|
1667
|
-
return `[${arms.join(", ")}]`;
|
|
1770
|
+
function responseArmsOf(route, arms) {
|
|
1771
|
+
const rendered = route.responses.map((response, index) => {
|
|
1772
|
+
const schema = arms[index]?.schema ?? "undefined";
|
|
1773
|
+
const contentTypes = response.contentTypes.length === 0
|
|
1774
|
+
? ""
|
|
1775
|
+
: `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
|
|
1776
|
+
const headers = response.headers.length === 0
|
|
1777
|
+
? ""
|
|
1778
|
+
: `, headers: [${response.headers.map((header) => `{ name: ${JSON.stringify(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
|
|
1779
|
+
return `{ status: ${JSON.stringify(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
|
|
1780
|
+
});
|
|
1781
|
+
return `[${rendered.join(", ")}]`;
|
|
1668
1782
|
}
|
|
1669
1783
|
const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
1670
1784
|
/**
|
|
@@ -1716,7 +1830,9 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1716
1830
|
const extraProperties = operation.parameters.parameters
|
|
1717
1831
|
.filter((parameter) => parameter.type === "path" ||
|
|
1718
1832
|
parameter.type === "query" ||
|
|
1719
|
-
parameter.type === "header"
|
|
1833
|
+
parameter.type === "header" ||
|
|
1834
|
+
// The second half of the same omission: a validator with no matching type.
|
|
1835
|
+
parameter.type === "cookie")
|
|
1720
1836
|
/**
|
|
1721
1837
|
* Named as the WIRE names it, which is what the emitted validator keys on.
|
|
1722
1838
|
* `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
|
|
@@ -2033,7 +2149,7 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
2033
2149
|
* that does not.
|
|
2034
2150
|
*/
|
|
2035
2151
|
/**
|
|
2036
|
-
*
|
|
2152
|
+
* **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
|
|
2037
2153
|
* deliberate on both sides.**
|
|
2038
2154
|
*
|
|
2039
2155
|
* The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
|
|
@@ -2096,8 +2212,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2096
2212
|
: T;
|
|
2097
2213
|
|
|
2098
2214
|
/**
|
|
2099
|
-
*
|
|
2100
|
-
* type when the two disagree, and a type alias to an odd type compiles fine
|
|
2215
|
+
* **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
2216
|
+
* type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
|
|
2101
2217
|
* assert nothing. Only a constraint violation is an error.
|
|
2102
2218
|
*/
|
|
2103
2219
|
type MustHold<T extends true> = T;
|
|
@@ -2129,8 +2245,6 @@ ${entries.join("\n")}
|
|
|
2129
2245
|
} as const;
|
|
2130
2246
|
`;
|
|
2131
2247
|
}
|
|
2132
|
-
/** What the generated files import their runtime contract from when nothing says otherwise. */
|
|
2133
|
-
export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
2134
2248
|
/**
|
|
2135
2249
|
* Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
|
|
2136
2250
|
*
|
|
@@ -2185,9 +2299,8 @@ function reportTruncatedDocComments(program) {
|
|
|
2185
2299
|
scalar: check,
|
|
2186
2300
|
});
|
|
2187
2301
|
}
|
|
2188
|
-
export async function emitHttpZod(context
|
|
2302
|
+
export async function emitHttpZod(context) {
|
|
2189
2303
|
const options = context.options;
|
|
2190
|
-
const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
|
|
2191
2304
|
/**
|
|
2192
2305
|
* Each service already projected to the version it currently serves - see `versioning.ts`.
|
|
2193
2306
|
*
|
|
@@ -2279,7 +2392,6 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2279
2392
|
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
2280
2393
|
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
2281
2394
|
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
2282
|
-
runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
|
|
2283
2395
|
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
2284
2396
|
};
|
|
2285
2397
|
beginBanner(resolved.regenerateHint);
|
|
@@ -2337,7 +2449,7 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2337
2449
|
const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
|
|
2338
2450
|
await emitFile(context.program, {
|
|
2339
2451
|
path: resolvePath(outputDir, "schemas.gen.ts"),
|
|
2340
|
-
content: renderSchemas(registry, externals, declarations, resolved.
|
|
2452
|
+
content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
|
|
2341
2453
|
});
|
|
2342
2454
|
if (resolved.contractsOutputDir !== undefined) {
|
|
2343
2455
|
const types = new TypeRegistry(context.program);
|