typespec-http-zod 0.25.0 → 0.26.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 +150 -116
- package/dist/src/api.js +353 -332
- package/dist/src/lib.d.ts +0 -19
- package/dist/src/lib.js +0 -2
- package/dist/src/registry.js +26 -3
- 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 +1 -0
- package/dist/src/zod.js +37 -1
- package/package.json +18 -18
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,77 @@ 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.
|
|
279
|
+
*
|
|
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.
|
|
305
290
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* name any other way would be a second spelling of a fact the compiler already resolved.
|
|
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
|
+
optional: property.optional,
|
|
320
|
+
});
|
|
328
321
|
}
|
|
329
322
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
}
|
|
353
|
-
return [...byStatus].map(([status, contentTypes]) => ({ status, contentTypes }));
|
|
354
|
-
}
|
|
355
|
-
function errorArmsOf(program, operation, registry) {
|
|
356
|
-
const arms = new Map();
|
|
357
|
-
for (const response of operation.responses) {
|
|
358
|
-
for (const status of statusKeysOf(program, response)) {
|
|
359
|
-
if (isSuccessKey(status) || arms.has(status))
|
|
360
|
-
continue;
|
|
361
|
-
arms.set(status, bodySchemaOf(response, registry));
|
|
323
|
+
if (streamedTypeOf(program, response.type) !== undefined)
|
|
324
|
+
entry.streamed = true;
|
|
325
|
+
byStatus.set(key, entry);
|
|
362
326
|
}
|
|
363
327
|
}
|
|
364
|
-
return [...
|
|
365
|
-
.map((
|
|
366
|
-
|
|
328
|
+
return [...byStatus.values()]
|
|
329
|
+
.map((entry) => {
|
|
330
|
+
const contentTypes = [...entry.contentTypes];
|
|
331
|
+
const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
|
|
332
|
+
return {
|
|
333
|
+
status: entry.status,
|
|
334
|
+
schema: entry.body === undefined
|
|
335
|
+
? undefined
|
|
336
|
+
: binary
|
|
337
|
+
? "z.unknown()"
|
|
338
|
+
: registry.expressionFor(entry.body),
|
|
339
|
+
contentTypes,
|
|
340
|
+
headers: entry.headers,
|
|
341
|
+
binary,
|
|
342
|
+
streamed: entry.streamed,
|
|
343
|
+
textual: entry.body !== undefined && isStringType(entry.body),
|
|
344
|
+
};
|
|
345
|
+
})
|
|
346
|
+
.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
|
|
347
|
+
statusOrderOf(a.status) - statusOrderOf(b.status));
|
|
367
348
|
}
|
|
368
349
|
/**
|
|
369
350
|
* Where an arm sits in OpenAPI's own precedence order.
|
|
@@ -380,21 +361,40 @@ function statusPrecedenceOf(status) {
|
|
|
380
361
|
return 2;
|
|
381
362
|
return typeof status === "number" ? 0 : 1;
|
|
382
363
|
}
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Whether a type's values are strings: `string` or a scalar built on it, a string literal or template,
|
|
366
|
+
* or a union of those.
|
|
367
|
+
*
|
|
368
|
+
* Read from the type rather than from the emitted schema, which would be a decision made by matching
|
|
369
|
+
* generated text - `z.iso.datetime()` is a string and does not begin with `z.string`.
|
|
370
|
+
*/
|
|
371
|
+
function isStringType(type) {
|
|
372
|
+
switch (type.kind) {
|
|
373
|
+
case "String":
|
|
374
|
+
case "StringTemplate":
|
|
375
|
+
return true;
|
|
376
|
+
case "Scalar":
|
|
377
|
+
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
378
|
+
if (scalar.name === "string")
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
case "Union":
|
|
383
|
+
return [...type.variants.values()].every((variant) => isStringType(variant.type));
|
|
384
|
+
default:
|
|
385
|
+
return false;
|
|
391
386
|
}
|
|
392
|
-
|
|
387
|
+
}
|
|
388
|
+
/** Ascending within one precedence class, so the order does not depend on declaration order. */
|
|
389
|
+
function statusOrderOf(status) {
|
|
390
|
+
if (typeof status === "number")
|
|
391
|
+
return status;
|
|
392
|
+
return status === "default" ? 0 : Number(status.charAt(0));
|
|
393
393
|
}
|
|
394
394
|
/**
|
|
395
395
|
* Everything the document says about authenticating an operation, read ONCE.
|
|
396
396
|
*
|
|
397
|
-
* **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
|
|
397
|
+
* **`scopes` and a boolean `noAuth` were two lossy projections of this, each with its own read of the same
|
|
398
398
|
* fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
|
|
399
399
|
* gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
|
|
400
400
|
* rebuild the requirements itself -- which is what both server emitters were doing, in two
|
|
@@ -406,7 +406,6 @@ function bodySchemaOf(response, registry) {
|
|
|
406
406
|
function authenticationFor(program, operation) {
|
|
407
407
|
const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
|
|
408
408
|
const security = [];
|
|
409
|
-
let noAuth = false;
|
|
410
409
|
for (const option of options) {
|
|
411
410
|
const requirement = {};
|
|
412
411
|
let anonymous = false;
|
|
@@ -418,7 +417,6 @@ function authenticationFor(program, operation) {
|
|
|
418
417
|
*/
|
|
419
418
|
if (scheme.type === "noAuth") {
|
|
420
419
|
anonymous = true;
|
|
421
|
-
noAuth = true;
|
|
422
420
|
continue;
|
|
423
421
|
}
|
|
424
422
|
/** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
|
|
@@ -427,13 +425,24 @@ function authenticationFor(program, operation) {
|
|
|
427
425
|
? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
|
|
428
426
|
: [];
|
|
429
427
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
428
|
+
/**
|
|
429
|
+
* **An option that needs nothing is published as `{}`, and kept.** `NoAuth | X` is
|
|
430
|
+
* `security: [{}, { X: [] }]` in the document `@typespec/openapi3` writes from the same program,
|
|
431
|
+
* and `{}` is the requirement every caller satisfies. Dropping it published `[{ X: [] }]`, a
|
|
432
|
+
* contract that refuses the anonymous caller the document accepts; typespec-hono had carried a
|
|
433
|
+
* corrected copy of this rule of its own because this one was wrong.
|
|
434
|
+
*/
|
|
435
|
+
if (anonymous || Object.keys(requirement).length > 0)
|
|
433
436
|
security.push(requirement);
|
|
434
437
|
}
|
|
435
438
|
const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
|
|
436
|
-
|
|
439
|
+
const anonymous = security.some((requirement) => Object.keys(requirement).length === 0);
|
|
440
|
+
const real = security.some((requirement) => Object.keys(requirement).length > 0);
|
|
441
|
+
return {
|
|
442
|
+
security,
|
|
443
|
+
scopes,
|
|
444
|
+
authentication: !real ? "none" : anonymous ? "optional" : "required",
|
|
445
|
+
};
|
|
437
446
|
}
|
|
438
447
|
/**
|
|
439
448
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
@@ -591,70 +600,6 @@ function elementKindOf(program, type) {
|
|
|
591
600
|
function isArrayType(type) {
|
|
592
601
|
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
593
602
|
}
|
|
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
603
|
/**
|
|
659
604
|
* The Zod for a **multipart** request body, built from its parts.
|
|
660
605
|
*
|
|
@@ -701,16 +646,26 @@ function multipartTsOf(body, registry) {
|
|
|
701
646
|
});
|
|
702
647
|
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
703
648
|
}
|
|
704
|
-
function multipartSchemaOf(body, registry) {
|
|
649
|
+
function multipartSchemaOf(program, body, registry) {
|
|
705
650
|
const entries = body.parts.map((part) => {
|
|
706
651
|
const type = part.body?.type;
|
|
652
|
+
/**
|
|
653
|
+
* **A form carries text, so a part that means a number or a boolean is decoded first**, by the
|
|
654
|
+
* same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
|
|
655
|
+
* `0.5`, and text that is not a well-formed number is passed on unchanged to fail against the
|
|
656
|
+
* schema the document publishes. `c.req.parseBody()` hands a text part over as a string, so
|
|
657
|
+
* `z.number()` alone refused every conformant request; measured on `@typespec/http-specs`
|
|
658
|
+
* `payload/multipart/non-string-float`, a 400 for the request the scenario documents.
|
|
659
|
+
*/
|
|
707
660
|
const inner = type === undefined
|
|
708
661
|
? "z.unknown()"
|
|
709
662
|
: isFilePart(type)
|
|
710
663
|
? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
|
|
711
664
|
: isBinaryPart(type)
|
|
712
665
|
? "z.unknown()"
|
|
713
|
-
:
|
|
666
|
+
: isJsonPart(part)
|
|
667
|
+
? jsonTextDecoded(registry.expressionFor(type))
|
|
668
|
+
: wireDecoded(registry.expressionFor(type), wireKindOf(program, type), elementKindOf(program, type));
|
|
714
669
|
// `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
|
|
715
670
|
const value = part.multi === true ? `z.array(${inner})` : inner;
|
|
716
671
|
// `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
|
|
@@ -782,6 +737,27 @@ function isFilePart(type) {
|
|
|
782
737
|
return false;
|
|
783
738
|
}
|
|
784
739
|
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
740
|
+
/**
|
|
741
|
+
* Whether a multipart part travels as JSON: every media type `@typespec/http` resolves for it is a
|
|
742
|
+
* JSON one. That is `application/json` for a model or array part, and whatever an explicit
|
|
743
|
+
* `@header contentType` names for a part that declares one.
|
|
744
|
+
*/
|
|
745
|
+
function isJsonPart(part) {
|
|
746
|
+
const contentTypes = part.body?.contentTypes ?? [];
|
|
747
|
+
return (contentTypes.length > 0 &&
|
|
748
|
+
contentTypes.every((contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType)));
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* **A JSON part is decoded from the JSON text a form carries** before the document's schema applies.
|
|
752
|
+
* A client writes the part as text with `Content-Type: application/json` and no filename, so
|
|
753
|
+
* `c.req.parseBody()` hands over a string; the schema alone refused every such request with
|
|
754
|
+
* `expected object, received string`. Text that is not JSON is an issue of its own rather than a
|
|
755
|
+
* string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
|
|
756
|
+
* is not text is left alone for the schema to judge.
|
|
757
|
+
*/
|
|
758
|
+
function jsonTextDecoded(declared) {
|
|
759
|
+
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})`;
|
|
760
|
+
}
|
|
785
761
|
function isBinaryPart(type) {
|
|
786
762
|
if (type.kind === "Scalar") {
|
|
787
763
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
@@ -996,9 +972,16 @@ function mediaTypeDecoded(declared) {
|
|
|
996
972
|
const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
|
|
997
973
|
return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
|
|
998
974
|
}
|
|
999
|
-
function parameterSchemasOf(program, operation, registry
|
|
975
|
+
function parameterSchemasOf(program, operation, registry, segments,
|
|
976
|
+
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
977
|
+
literalQueryNames) {
|
|
1000
978
|
const byTarget = {};
|
|
1001
979
|
let accept;
|
|
980
|
+
/** Form-exploded records and models, gathered back under their names around the query object. */
|
|
981
|
+
const gathered = [];
|
|
982
|
+
const queryNames = operation.parameters.parameters
|
|
983
|
+
.filter((parameter) => parameter.type === "query")
|
|
984
|
+
.map((parameter) => parameter.name);
|
|
1002
985
|
for (const parameter of operation.parameters.parameters) {
|
|
1003
986
|
if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
|
|
1004
987
|
continue;
|
|
@@ -1046,11 +1029,54 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
1046
1029
|
* occurrences arrive already an array and pass through untouched, exactly as the delimiter
|
|
1047
1030
|
* arm leaves a pre-split value alone.
|
|
1048
1031
|
*/
|
|
1049
|
-
const
|
|
1032
|
+
const boxedOrSplit = isExplodedCollection(parameter)
|
|
1050
1033
|
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1051
1034
|
: delimiter === undefined
|
|
1052
1035
|
? decoded
|
|
1053
1036
|
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
|
|
1037
|
+
/**
|
|
1038
|
+
* **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
|
|
1039
|
+
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
1040
|
+
* run on that. See {@link uriExpansionCall}.
|
|
1041
|
+
*/
|
|
1042
|
+
const shape = expansionShapeOf(parameter.param.type);
|
|
1043
|
+
const values = recordValuesOf(program, parameter.param.type);
|
|
1044
|
+
let expression = boxedOrSplit;
|
|
1045
|
+
if (parameter.type === "path") {
|
|
1046
|
+
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1047
|
+
if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
|
|
1048
|
+
expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
else if (parameter.type === "query" && shape === "record") {
|
|
1052
|
+
if (parameter.explode === true) {
|
|
1053
|
+
/**
|
|
1054
|
+
* `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
|
|
1055
|
+
* this entry to read; the gatherer around the query object puts one back.
|
|
1056
|
+
*/
|
|
1057
|
+
const model = parameter.param.type.kind === "Model" && parameter.param.type.indexer === undefined
|
|
1058
|
+
? parameter.param.type
|
|
1059
|
+
: undefined;
|
|
1060
|
+
const properties = model === undefined ? [] : modelPropertiesOf(model);
|
|
1061
|
+
gathered.push(explodedQueryCall({
|
|
1062
|
+
name: parameter.name,
|
|
1063
|
+
others: [...queryNames.filter((name) => name !== parameter.name), ...literalQueryNames],
|
|
1064
|
+
keys: model === undefined
|
|
1065
|
+
? undefined
|
|
1066
|
+
: properties.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1067
|
+
numbers: properties
|
|
1068
|
+
.filter((property) => wireKindOf(program, property.type) === "number")
|
|
1069
|
+
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1070
|
+
booleans: properties
|
|
1071
|
+
.filter((property) => wireKindOf(program, property.type) === "boolean")
|
|
1072
|
+
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1073
|
+
values,
|
|
1074
|
+
}));
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1054
1080
|
const entry = `\t${objectKey(parameter.name)}: ${expression},`;
|
|
1055
1081
|
(byTarget[parameter.type] ??= []).push(entry);
|
|
1056
1082
|
/**
|
|
@@ -1074,24 +1100,49 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
1074
1100
|
const entries = byTarget[target];
|
|
1075
1101
|
return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
|
|
1076
1102
|
};
|
|
1103
|
+
const query = shape("query");
|
|
1077
1104
|
return {
|
|
1078
1105
|
path: shape("path"),
|
|
1079
|
-
query:
|
|
1106
|
+
query: query === undefined
|
|
1107
|
+
? undefined
|
|
1108
|
+
: gathered.reduce((inner, gather) => `z.preprocess(${gather}, ${inner})`, query),
|
|
1109
|
+
queryFields: query,
|
|
1080
1110
|
header: shape("header"),
|
|
1081
1111
|
negotiatedHeader: shape("header-without-accept"),
|
|
1082
1112
|
accept,
|
|
1083
1113
|
};
|
|
1084
1114
|
}
|
|
1115
|
+
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1116
|
+
function expansionShapeOf(type) {
|
|
1117
|
+
if (isArrayType(type))
|
|
1118
|
+
return "list";
|
|
1119
|
+
return type.kind === "Model" ? "record" : "scalar";
|
|
1120
|
+
}
|
|
1121
|
+
/** How a record's values decode, read from its indexer the way a scalar's kind is read. */
|
|
1122
|
+
function recordValuesOf(program, type) {
|
|
1123
|
+
if (type.kind !== "Model" || isArrayType(type) || type.indexer === undefined)
|
|
1124
|
+
return "";
|
|
1125
|
+
return wireKindOf(program, type.indexer.value) ?? "";
|
|
1126
|
+
}
|
|
1127
|
+
/** A model's properties, its base model's first, as the document lists them. */
|
|
1128
|
+
function modelPropertiesOf(model) {
|
|
1129
|
+
const chain = [];
|
|
1130
|
+
for (let current = model; current !== undefined; current = current.baseModel) {
|
|
1131
|
+
chain.unshift(current);
|
|
1132
|
+
}
|
|
1133
|
+
return chain.flatMap((current) => [...current.properties.values()]);
|
|
1134
|
+
}
|
|
1085
1135
|
export function collectRoutes(program, registry, service) {
|
|
1086
1136
|
const routes = [];
|
|
1087
1137
|
{
|
|
1088
1138
|
for (const operation of service.operations) {
|
|
1139
|
+
/**
|
|
1140
|
+
* **Every operation is a route, whatever its responses are.** This used to require an exact
|
|
1141
|
+
* 2xx or 3xx, which dropped an operation whose only success was a `2XX` range without a word
|
|
1142
|
+
* - the same silent shape as the 282 bodyless operations an earlier rule dropped, because a
|
|
1143
|
+
* range is not a number. A bodyless success is ordinary HTTP, and so is a ranged one.
|
|
1144
|
+
*/
|
|
1089
1145
|
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
1146
|
const requestType = requestBodyOf(operation);
|
|
1096
1147
|
/**
|
|
1097
1148
|
* **A streamed operation's EVENT payloads are not reachable from its body.**
|
|
@@ -1110,9 +1161,6 @@ export function collectRoutes(program, registry, service) {
|
|
|
1110
1161
|
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
1111
1162
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
1112
1163
|
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
1164
|
/**
|
|
1117
1165
|
* A `bytes` body means the BYTES are the contract.
|
|
1118
1166
|
*
|
|
@@ -1158,11 +1206,36 @@ export function collectRoutes(program, registry, service) {
|
|
|
1158
1206
|
* `multipartTsOf`, both recorded above.
|
|
1159
1207
|
*/
|
|
1160
1208
|
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1209
|
+
/**
|
|
1210
|
+
* **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
|
|
1211
|
+
*
|
|
1212
|
+
* `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
|
|
1213
|
+
* `{}`: the server validated the body and then never handed it to the handler, silently, from
|
|
1214
|
+
* a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
|
|
1215
|
+
* `TS2698: Spread types may only be created from object types`, which is what
|
|
1216
|
+
* `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
|
|
1217
|
+
* had been pinned on in the conformance corpus.
|
|
1218
|
+
*
|
|
1219
|
+
* A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
|
|
1220
|
+
* indexed or optional body already gets and for the same reason: the merge cannot express it.
|
|
1221
|
+
*
|
|
1222
|
+
* **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
|
|
1223
|
+
* naming it twice would put the same value under two keys.
|
|
1224
|
+
*/
|
|
1225
|
+
const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
|
|
1161
1226
|
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1162
|
-
optionalMultipartBody
|
|
1227
|
+
optionalMultipartBody ||
|
|
1228
|
+
unmergeableBody
|
|
1163
1229
|
? (bodyParameter?.property?.name ?? "body")
|
|
1164
1230
|
: undefined;
|
|
1165
1231
|
const authentication = authenticationFor(program, operation);
|
|
1232
|
+
const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
|
|
1233
|
+
.filter((parameter) => parameter.type === "path")
|
|
1234
|
+
.map((parameter) => ({
|
|
1235
|
+
name: parameter.name,
|
|
1236
|
+
optional: parameter.param.optional,
|
|
1237
|
+
reserved: parameter.type === "path" && parameter.allowReserved,
|
|
1238
|
+
})));
|
|
1166
1239
|
routes.push({
|
|
1167
1240
|
operationId: operationIdOf(program, operation.operation),
|
|
1168
1241
|
verb: operation.verb.toUpperCase(),
|
|
@@ -1188,10 +1261,16 @@ export function collectRoutes(program, registry, service) {
|
|
|
1188
1261
|
reservedPathParameters: operation.parameters.parameters
|
|
1189
1262
|
.filter((parameter) => parameter.type === "path" && parameter.allowReserved)
|
|
1190
1263
|
.map((parameter) => parameter.name),
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
responseContentTypes: responseContentTypesOf(operation, statusCode),
|
|
1264
|
+
pathSegments: template.segments,
|
|
1265
|
+
literalQuery: template.literalQuery,
|
|
1266
|
+
responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
|
|
1194
1267
|
requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
|
|
1268
|
+
// `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
|
|
1269
|
+
// here: a string body under `application/json` IS json, and its reader is the JSON one.
|
|
1270
|
+
requestTextual: requestType !== undefined &&
|
|
1271
|
+
rawBodyProperty === undefined &&
|
|
1272
|
+
isStringType(requestType) &&
|
|
1273
|
+
isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])]),
|
|
1195
1274
|
/**
|
|
1196
1275
|
* `@summary` first, `@doc` as the fallback.
|
|
1197
1276
|
*
|
|
@@ -1213,56 +1292,34 @@ export function collectRoutes(program, registry, service) {
|
|
|
1213
1292
|
// such route mounted with nothing checking the request at all.
|
|
1214
1293
|
const parameterBody = operation.parameters.body;
|
|
1215
1294
|
if (parameterBody?.bodyKind === "multipart") {
|
|
1216
|
-
return multipartSchemaOf(parameterBody, registry);
|
|
1295
|
+
return multipartSchemaOf(program, parameterBody, registry);
|
|
1217
1296
|
}
|
|
1218
1297
|
return requestType === undefined ? undefined : registry.expressionFor(requestType);
|
|
1219
1298
|
}),
|
|
1220
1299
|
...(() => {
|
|
1221
|
-
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry));
|
|
1300
|
+
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
|
|
1222
1301
|
return {
|
|
1223
1302
|
pathSchema: split.path,
|
|
1224
1303
|
querySchema: split.query,
|
|
1304
|
+
queryFieldsSchema: split.queryFields,
|
|
1225
1305
|
headerSchema: split.header,
|
|
1226
1306
|
negotiatedHeaderSchema: split.negotiatedHeader,
|
|
1227
1307
|
accept: split.accept,
|
|
1228
1308
|
};
|
|
1229
1309
|
})(),
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
*/
|
|
1238
|
-
isBinaryPart(responseType) &&
|
|
1239
|
-
isRawBinaryMediaType(responseContentTypesOf(operation, statusCode))
|
|
1240
|
-
? "z.unknown()"
|
|
1241
|
-
: registry.expressionFor(responseType)),
|
|
1310
|
+
/**
|
|
1311
|
+
* Read at `Visibility.Read`, as openapi3 reads a response, and after the request walks so
|
|
1312
|
+
* declarations keep the order they had. Resolving responses under the ambient request
|
|
1313
|
+
* visibility declared six components under the wrong suffix - measured:
|
|
1314
|
+
* type/model/visibility gained six divergences.
|
|
1315
|
+
*/
|
|
1316
|
+
responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
|
|
1242
1317
|
rawBodyProperty,
|
|
1243
1318
|
bodyProperty,
|
|
1244
1319
|
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
1320
|
scopes: authentication.scopes,
|
|
1252
1321
|
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),
|
|
1322
|
+
authentication: authentication.authentication,
|
|
1266
1323
|
});
|
|
1267
1324
|
}
|
|
1268
1325
|
}
|
|
@@ -1344,7 +1401,7 @@ function renderExternalImports(externals, ...bodies) {
|
|
|
1344
1401
|
function compiled(expression, on) {
|
|
1345
1402
|
return on ? `z.compile(${expression})` : expression;
|
|
1346
1403
|
}
|
|
1347
|
-
function renderSchemas(registry, externals, routeDeclarations,
|
|
1404
|
+
function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
1348
1405
|
const declarations = registry.declarations().map((d) => {
|
|
1349
1406
|
/**
|
|
1350
1407
|
* **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
|
|
@@ -1440,20 +1497,45 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, co
|
|
|
1440
1497
|
*/
|
|
1441
1498
|
const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
|
|
1442
1499
|
const parts = [generatedBanner()];
|
|
1443
|
-
if (usesZod)
|
|
1500
|
+
if (usesZod || routeDeclarations.length > 0)
|
|
1444
1501
|
parts.push('\nimport { z } from "zod";\n');
|
|
1445
1502
|
/**
|
|
1446
|
-
*
|
|
1503
|
+
* **The arm shape is declared IN this file, not imported, and that is what lets two emitters share
|
|
1504
|
+
* it.** It used to be `import type { ResponseArm } from <runtime-module>`, the one line of this
|
|
1505
|
+
* file that depended on where a runtime lived. A server emitter and a tool emitter pointed at one
|
|
1506
|
+
* output directory both write `schemas.gen.ts`, and `emitFile` is a silent last-writer-wins, so the
|
|
1507
|
+
* two had to agree on that specifier or the order of `emit:` decided which import survived. With
|
|
1508
|
+
* the shape declared here the file depends on no runtime at all, and `runtime-module` had nothing
|
|
1509
|
+
* left to decide.
|
|
1447
1510
|
*
|
|
1448
|
-
* The annotation is what makes a misspelled or omitted `schema` a compile error rather than
|
|
1449
|
-
* that silently validates nothing
|
|
1450
|
-
*
|
|
1511
|
+
* The annotation is still what makes a misspelled or omitted `schema` a compile error rather than
|
|
1512
|
+
* an arm that silently validates nothing. Not exported: the type an application reads an arm
|
|
1513
|
+
* through is `typespec-http-zod/runtime`'s, which is structurally the same. Emitted only where
|
|
1514
|
+
* there are arms to annotate, because an unused declaration fails the lint a generated file has to
|
|
1451
1515
|
* pass like any other.
|
|
1452
1516
|
*/
|
|
1517
|
+
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
1518
|
+
/**
|
|
1519
|
+
* **The RFC 6570 decoders are declared here, once, and only where a validator calls them**, for the
|
|
1520
|
+
* reason `z` is imported only when used: an unused declaration fails the lint a generated file has
|
|
1521
|
+
* to pass. Declared rather than imported, so this file still depends on no runtime.
|
|
1522
|
+
*/
|
|
1523
|
+
const referenced = identifiersIn(routeDeclarations.join(""));
|
|
1524
|
+
if (referenced.has("uriExpansion"))
|
|
1525
|
+
parts.push(URI_EXPANSION_SOURCE);
|
|
1526
|
+
if (referenced.has("explodedQuery"))
|
|
1527
|
+
parts.push(EXPLODED_QUERY_SOURCE);
|
|
1453
1528
|
if (routeDeclarations.length > 0) {
|
|
1454
|
-
parts.push(`
|
|
1529
|
+
parts.push(`
|
|
1530
|
+
/** One declared response: its status key, its body's schema, its media types and its headers. */
|
|
1531
|
+
type ResponseArm = {
|
|
1532
|
+
readonly status: number | "default" | \`\${1 | 2 | 3 | 4 | 5}XX\`;
|
|
1533
|
+
readonly schema: z.ZodType | undefined;
|
|
1534
|
+
readonly contentTypes?: readonly string[];
|
|
1535
|
+
readonly headers?: readonly { readonly name: string; readonly optional: boolean }[];
|
|
1536
|
+
};
|
|
1537
|
+
`);
|
|
1455
1538
|
}
|
|
1456
|
-
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
1457
1539
|
parts.push(declarations.join(""));
|
|
1458
1540
|
if (routeDeclarations.length > 0)
|
|
1459
1541
|
parts.push(`\n${routeDeclarations.join("\n")}\n`);
|
|
@@ -1542,129 +1624,72 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1542
1624
|
};
|
|
1543
1625
|
// Order follows the request: what identifies the resource, then what filters it, then its body.
|
|
1544
1626
|
const path = declare("Path", route.pathSchema);
|
|
1545
|
-
|
|
1627
|
+
/**
|
|
1628
|
+
* Where a query group is gathered, its object is declared on its own and the wire validator wraps
|
|
1629
|
+
* that identifier, so both have a name and only the second pipes.
|
|
1630
|
+
*/
|
|
1631
|
+
const gathers = route.queryFieldsSchema !== undefined && route.querySchema !== route.queryFieldsSchema;
|
|
1632
|
+
const queryFields = gathers ? declare("QueryFields", route.queryFieldsSchema) : undefined;
|
|
1633
|
+
const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
|
|
1634
|
+
? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
|
|
1635
|
+
: route.querySchema);
|
|
1546
1636
|
const negotiating = shared.has(`${route.verb} ${route.path}`);
|
|
1547
1637
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
1548
1638
|
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
1549
1639
|
const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
|
|
1550
|
-
|
|
1551
|
-
|
|
1640
|
+
/**
|
|
1641
|
+
* One identifier per distinct body, suffixed by the status that first carries it. Two statuses
|
|
1642
|
+
* sharing one inline body share its declaration rather than emitting it twice.
|
|
1643
|
+
*/
|
|
1644
|
+
const declaredBodies = new Map();
|
|
1645
|
+
const arms = route.responses.map((response) => {
|
|
1646
|
+
if (response.schema === undefined)
|
|
1647
|
+
return { status: response.status, schema: undefined };
|
|
1648
|
+
if (!declaredBodies.has(response.schema)) {
|
|
1649
|
+
const suffix = response.status === "default" ? "Default" : String(response.status);
|
|
1650
|
+
declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
|
|
1651
|
+
}
|
|
1652
|
+
return { status: response.status, schema: declaredBodies.get(response.schema) };
|
|
1653
|
+
});
|
|
1552
1654
|
const responses = schemaConst(route.operationId, "Responses");
|
|
1553
|
-
declarations.push(`export const ${responses} = ${responseArmsOf(route,
|
|
1655
|
+
declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
|
|
1554
1656
|
names.set(route.operationId, {
|
|
1555
1657
|
operationId: route.operationId,
|
|
1556
1658
|
path,
|
|
1557
1659
|
query,
|
|
1660
|
+
queryFields: gathers ? queryFields : query,
|
|
1558
1661
|
header,
|
|
1559
1662
|
body,
|
|
1560
|
-
|
|
1561
|
-
alternateResponse,
|
|
1663
|
+
arms,
|
|
1562
1664
|
responses,
|
|
1563
1665
|
});
|
|
1564
1666
|
}
|
|
1565
1667
|
return { names, declarations };
|
|
1566
1668
|
}
|
|
1567
1669
|
/**
|
|
1568
|
-
* What an operation may answer with, as a literal - each declared status
|
|
1569
|
-
* document gives it, in OpenAPI's own precedence order.
|
|
1670
|
+
* What an operation may answer with, as a literal - each declared status with the body, media types
|
|
1671
|
+
* and headers the document gives it, in OpenAPI's own precedence order.
|
|
1672
|
+
*
|
|
1673
|
+
* **Rendered from one record per status, so no arm can shadow another.** The primary success arm and
|
|
1674
|
+
* the failure arms used to be rendered from separate projections and then deduplicated by matching
|
|
1675
|
+
* the emitted text for `status:`, which is how a redirect came to be declared twice and how a
|
|
1676
|
+
* failure arm came to carry no headers while a success arm with the same headers did.
|
|
1570
1677
|
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1678
|
+
* `contentTypes` and `headers` are written only where the document names some, so "none declared"
|
|
1679
|
+
* and "none carried" are the same state rather than two a runtime has to tell apart.
|
|
1573
1680
|
*/
|
|
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(", ")}]`;
|
|
1681
|
+
function responseArmsOf(route, arms) {
|
|
1682
|
+
const rendered = route.responses.map((response, index) => {
|
|
1683
|
+
const schema = arms[index]?.schema ?? "undefined";
|
|
1684
|
+
const contentTypes = response.contentTypes.length === 0
|
|
1685
|
+
? ""
|
|
1686
|
+
: `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
|
|
1687
|
+
const headers = response.headers.length === 0
|
|
1688
|
+
? ""
|
|
1689
|
+
: `, headers: [${response.headers.map((header) => `{ name: ${JSON.stringify(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
|
|
1690
|
+
return `{ status: ${JSON.stringify(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
|
|
1691
|
+
});
|
|
1692
|
+
return `[${rendered.join(", ")}]`;
|
|
1668
1693
|
}
|
|
1669
1694
|
const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
1670
1695
|
/**
|
|
@@ -2129,8 +2154,6 @@ ${entries.join("\n")}
|
|
|
2129
2154
|
} as const;
|
|
2130
2155
|
`;
|
|
2131
2156
|
}
|
|
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
2157
|
/**
|
|
2135
2158
|
* Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
|
|
2136
2159
|
*
|
|
@@ -2185,9 +2208,8 @@ function reportTruncatedDocComments(program) {
|
|
|
2185
2208
|
scalar: check,
|
|
2186
2209
|
});
|
|
2187
2210
|
}
|
|
2188
|
-
export async function emitHttpZod(context
|
|
2211
|
+
export async function emitHttpZod(context) {
|
|
2189
2212
|
const options = context.options;
|
|
2190
|
-
const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
|
|
2191
2213
|
/**
|
|
2192
2214
|
* Each service already projected to the version it currently serves - see `versioning.ts`.
|
|
2193
2215
|
*
|
|
@@ -2279,7 +2301,6 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2279
2301
|
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
2280
2302
|
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
2281
2303
|
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
2282
|
-
runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
|
|
2283
2304
|
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
2284
2305
|
};
|
|
2285
2306
|
beginBanner(resolved.regenerateHint);
|
|
@@ -2337,7 +2358,7 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2337
2358
|
const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
|
|
2338
2359
|
await emitFile(context.program, {
|
|
2339
2360
|
path: resolvePath(outputDir, "schemas.gen.ts"),
|
|
2340
|
-
content: renderSchemas(registry, externals, declarations, resolved.
|
|
2361
|
+
content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
|
|
2341
2362
|
});
|
|
2342
2363
|
if (resolved.contractsOutputDir !== undefined) {
|
|
2343
2364
|
const types = new TypeRegistry(context.program);
|