Skip to content

Commit fb39add

Browse files
authored
Replace extractObjectSchema with JSON Schema flattening (#2595)
In most cases we are only interested in property names of the flattened IOSchema. I came to realization that it might be possible to use `toJSONSchema` for that.
1 parent f78a1c3 commit fb39add

14 files changed

Lines changed: 494 additions & 449 deletions

example/example.documentation.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ paths:
196196
required:
197197
- name
198198
- createdAt
199+
examples:
200+
- name: John Doe
201+
createdAt: 2021-12-31T00:00:00.000Z
199202
required:
200203
- status
201204
- data

express-zod-api/src/diagnostics.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
1-
import type { $ZodShape } from "@zod/core";
2-
import * as R from "ramda";
31
import { z } from "zod";
42
import { responseVariants } from "./api-response";
53
import { FlatObject, getRoutePathParams } from "./common-helpers";
64
import { contentTypes } from "./content-type";
75
import { findJsonIncompatible } from "./deep-checks";
86
import { AbstractEndpoint } from "./endpoint";
9-
import { extractObjectSchema } from "./io-schema";
7+
import { flattenIO } from "./json-schema-helpers";
108
import { ActualLogger } from "./logger-helpers";
119

1210
export class Diagnostics {
1311
#verifiedEndpoints = new WeakSet<AbstractEndpoint>();
1412
#verifiedPaths = new WeakMap<
1513
AbstractEndpoint,
16-
{ shape: $ZodShape; paths: string[] }
14+
{ flat: ReturnType<typeof flattenIO>; paths: string[] }
1715
>();
1816

1917
constructor(protected logger: ActualLogger) {}
@@ -65,20 +63,22 @@ export class Diagnostics {
6563
if (ref?.paths.includes(path)) return;
6664
const params = getRoutePathParams(path);
6765
if (params.length === 0) return; // next statement can be expensive
68-
const { shape } =
69-
ref ||
70-
R.tryCatch(extractObjectSchema, (err) => {
71-
this.logger.warn("Diagnostics::checkPathParams()", err);
72-
return z.object({});
73-
})(endpoint.inputSchema);
66+
const flat =
67+
ref?.flat ||
68+
flattenIO(
69+
z.toJSONSchema(endpoint.inputSchema, {
70+
unrepresentable: "any",
71+
io: "input",
72+
}),
73+
);
7474
for (const param of params) {
75-
if (param in shape) continue;
75+
if (param in flat.properties) continue;
7676
this.logger.warn(
7777
"The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.",
7878
Object.assign(ctx, { path, param }),
7979
);
8080
}
8181
if (ref) ref.paths.push(path);
82-
else this.#verifiedPaths.set(endpoint, { shape, paths: [path] });
82+
else this.#verifiedPaths.set(endpoint, { flat, paths: [path] });
8383
}
8484
}

express-zod-api/src/documentation-helpers.ts

Lines changed: 96 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
$ZodObject,
23
$ZodPipe,
34
$ZodTransform,
45
$ZodTuple,
@@ -44,7 +45,8 @@ import { ezDateOutBrand } from "./date-out-schema";
4445
import { contentTypes } from "./content-type";
4546
import { DocumentationError } from "./errors";
4647
import { ezFileBrand } from "./file-schema";
47-
import { extractObjectSchema, IOSchema } from "./io-schema";
48+
import { IOSchema } from "./io-schema";
49+
import { flattenIO } from "./json-schema-helpers";
4850
import { Alternatives } from "./logical-container";
4951
import { metaSymbol } from "./metadata";
5052
import { Method } from "./method";
@@ -80,13 +82,7 @@ export type IsHeader = (
8082

8183
export type BrandHandling = Record<string | symbol, Depicter>;
8284

83-
interface ReqResHandlingProps<S extends $ZodType>
84-
extends Omit<OpenAPIContext, "isResponse"> {
85-
schema: S;
86-
composition: "inline" | "components";
87-
description?: string;
88-
brandHandling?: BrandHandling;
89-
}
85+
type ReqResCommons = Omit<OpenAPIContext, "isResponse">;
9086

9187
const shortDescriptionLimit = 50;
9288
const isoDateDocumentationUrl =
@@ -164,6 +160,7 @@ const canMerge = R.pipe(
164160
R.isEmpty,
165161
);
166162

163+
/** @todo DNRY with flattenIO() */
167164
const intersect = (
168165
children: Array<JSONSchema.BaseSchema>,
169166
): JSONSchema.ObjectSchema => {
@@ -209,6 +206,21 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({
209206
...jsonSchema,
210207
});
211208

209+
export const depictObject: Depicter = (
210+
{ zodSchema, jsonSchema },
211+
{ isResponse },
212+
) => {
213+
if (isResponse) return jsonSchema;
214+
if (!isSchema<$ZodObject>(zodSchema, "object")) return jsonSchema;
215+
const { required = [] } = jsonSchema as JSONSchema.ObjectSchema;
216+
const result: string[] = [];
217+
for (const key of required) {
218+
const valueSchema = zodSchema._zod.def.shape[key];
219+
if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key);
220+
}
221+
return { ...jsonSchema, required: result };
222+
};
223+
212224
const ensureCompliance = ({
213225
$ref,
214226
type,
@@ -306,7 +318,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => {
306318
ctx.isResponse ? "in" : "out"
307319
];
308320
if (!isSchema<$ZodTransform>(target, "transform")) return jsonSchema;
309-
const opposingDepiction = depict(opposite, { ctx });
321+
const opposingDepiction = ensureCompliance(depict(opposite, { ctx }));
310322
if (isSchemaObject(opposingDepiction)) {
311323
if (!ctx.isResponse) {
312324
const { type: opposingType, ...rest } = opposingDepiction;
@@ -347,39 +359,6 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>
347359
)
348360
: undefined;
349361

350-
export const depictExamples = (
351-
schema: $ZodType,
352-
isResponse: boolean,
353-
omitProps: string[] = [],
354-
): ExamplesObject | undefined =>
355-
R.pipe(
356-
getExamples,
357-
R.map(
358-
R.when(
359-
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
360-
R.omit(omitProps),
361-
),
362-
),
363-
enumerateExamples,
364-
)({
365-
schema,
366-
variant: isResponse ? "parsed" : "original",
367-
validate: true,
368-
pullProps: true,
369-
});
370-
371-
export const depictParamExamples = (
372-
schema: z.ZodType,
373-
param: string,
374-
): ExamplesObject | undefined => {
375-
return R.pipe(
376-
getExamples,
377-
R.filter(R.both(isObject, R.has(param))),
378-
R.pluck(param),
379-
enumerateExamples,
380-
)({ schema, variant: "original", validate: true, pullProps: true });
381-
};
382-
383362
export const defaultIsHeader = (
384363
name: string,
385364
familiar?: string[],
@@ -391,20 +370,22 @@ export const defaultIsHeader = (
391370
export const depictRequestParams = ({
392371
path,
393372
method,
394-
schema,
373+
request,
395374
inputSources,
396375
makeRef,
397376
composition,
398-
brandHandling,
399377
isHeader,
400378
security,
401379
description = `${method.toUpperCase()} ${path} Parameter`,
402-
}: ReqResHandlingProps<IOSchema> & {
380+
}: ReqResCommons & {
381+
composition: "inline" | "components";
382+
description?: string;
383+
request: JSONSchema.BaseSchema;
403384
inputSources: InputSource[];
404385
isHeader?: IsHeader;
405386
security?: Alternatives<Security>;
406387
}) => {
407-
const objectSchema = extractObjectSchema(schema);
388+
const flat = flattenIO(request);
408389
const pathParams = getRoutePathParams(path);
409390
const isQueryEnabled = inputSources.includes("query");
410391
const areParamsEnabled = inputSources.includes("params");
@@ -419,8 +400,8 @@ export const depictRequestParams = ({
419400
areHeadersEnabled &&
420401
(isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders));
421402

422-
return Object.entries(objectSchema.shape).reduce<ParameterObject[]>(
423-
(acc, [name, paramSchema]) => {
403+
return Object.entries(flat.properties).reduce<ParameterObject[]>(
404+
(acc, [name, jsonSchema]) => {
424405
const location = isPathParam(name)
425406
? "path"
426407
: isHeaderParam(name)
@@ -429,22 +410,26 @@ export const depictRequestParams = ({
429410
? "query"
430411
: undefined;
431412
if (!location) return acc;
432-
const depicted = depict(paramSchema, {
433-
rules: { ...brandHandling, ...depicters },
434-
ctx: { isResponse: false, makeRef, path, method },
435-
});
413+
const depicted = ensureCompliance(jsonSchema);
436414
const result =
437415
composition === "components"
438-
? makeRef(paramSchema, depicted, makeCleanId(description, name))
416+
? makeRef(jsonSchema, depicted, makeCleanId(description, name))
439417
: depicted;
440418
return acc.concat({
441419
name,
442420
in: location,
443-
deprecated: globalRegistry.get(paramSchema)?.deprecated,
444-
required: !doesAccept(paramSchema, undefined),
421+
deprecated: jsonSchema.deprecated,
422+
required: flat.required.includes(name),
445423
description: depicted.description || description,
446424
schema: result,
447-
examples: depictParamExamples(objectSchema, name),
425+
examples: enumerateExamples(
426+
isSchemaObject(depicted) && depicted.examples?.length
427+
? depicted.examples // own examples or from the flat:
428+
: R.pluck(
429+
name,
430+
flat.examples.filter(R.both(isObject, R.has(name))),
431+
),
432+
),
448433
});
449434
},
450435
[],
@@ -462,6 +447,7 @@ const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =
462447
pipe: depictPipeline,
463448
literal: depictLiteral,
464449
enum: depictEnum,
450+
object: depictObject,
465451
[ezDateInBrand]: depictDateIn,
466452
[ezDateOutBrand]: depictDateOut,
467453
[ezUploadBrand]: depictUpload,
@@ -477,6 +463,7 @@ const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
477463
schema: zodSchema,
478464
variant: isResponse ? "parsed" : "original",
479465
validate: true,
466+
pullProps: true,
480467
});
481468
if (examples.length) result.examples = examples.slice();
482469
return result;
@@ -506,7 +493,7 @@ const fixReferences = (
506493
}
507494
if (R.is(Array, entry)) stack.push(...R.values(entry));
508495
}
509-
return ensureCompliance(subject);
496+
return subject;
510497
};
511498

512499
/** @link https://github.com/colinhacks/zod/issues/4275 */
@@ -574,11 +561,6 @@ export const excludeParamsFromDepiction = (
574561
return [result, hasRequired || Boolean(result.required?.length)];
575562
};
576563

577-
export const excludeExamplesFromDepiction = (
578-
depicted: SchemaObject | ReferenceObject,
579-
): SchemaObject | ReferenceObject =>
580-
isReferenceObject(depicted) ? depicted : R.omit(["examples"], depicted);
581-
582564
export const depictResponse = ({
583565
method,
584566
path,
@@ -593,25 +575,34 @@ export const depictResponse = ({
593575
description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
594576
hasMultipleStatusCodes ? statusCode : ""
595577
}`.trim(),
596-
}: ReqResHandlingProps<$ZodType> & {
578+
}: ReqResCommons & {
579+
schema: $ZodType;
580+
composition: "inline" | "components";
581+
description?: string;
582+
brandHandling?: BrandHandling;
597583
mimeTypes: ReadonlyArray<string> | null;
598584
variant: ResponseVariant;
599585
statusCode: number;
600586
hasMultipleStatusCodes: boolean;
601587
}): ResponseObject => {
602588
if (!mimeTypes) return { description };
603-
const depictedSchema = excludeExamplesFromDepiction(
589+
const response = ensureCompliance(
604590
depict(schema, {
605591
rules: { ...brandHandling, ...depicters },
606592
ctx: { isResponse: true, makeRef, path, method },
607593
}),
608594
);
595+
const examples = [];
596+
if (isSchemaObject(response) && response.examples) {
597+
examples.push(...response.examples);
598+
delete response.examples; // moving them up
599+
}
609600
const media: MediaTypeObject = {
610601
schema:
611602
composition === "components"
612-
? makeRef(schema, depictedSchema, makeCleanId(description))
613-
: depictedSchema,
614-
examples: depictExamples(schema, true),
603+
? makeRef(schema, response, makeCleanId(description))
604+
: response,
605+
examples: enumerateExamples(examples),
615606
};
616607
return { description, content: R.fromPairs(R.xprod(mimeTypes, [media])) };
617608
};
@@ -708,34 +699,62 @@ export const depictSecurityRefs = (
708699
}, {}),
709700
);
710701

702+
export const depictRequest = ({
703+
schema,
704+
brandHandling,
705+
makeRef,
706+
path,
707+
method,
708+
}: ReqResCommons & {
709+
schema: IOSchema;
710+
brandHandling?: BrandHandling;
711+
}) =>
712+
depict(schema, {
713+
rules: { ...brandHandling, ...depicters },
714+
ctx: { isResponse: false, makeRef, path, method },
715+
});
716+
711717
export const depictBody = ({
712718
method,
713719
path,
714720
schema,
721+
request,
715722
mimeType,
716723
makeRef,
717724
composition,
718-
brandHandling,
719725
paramNames,
720726
description = `${method.toUpperCase()} ${path} Request body`,
721-
}: ReqResHandlingProps<IOSchema> & {
727+
}: ReqResCommons & {
728+
schema: IOSchema;
729+
composition: "inline" | "components";
730+
description?: string;
731+
request: JSONSchema.BaseSchema;
722732
mimeType: string;
723733
paramNames: string[];
724734
}) => {
725735
const [withoutParams, hasRequired] = excludeParamsFromDepiction(
726-
depict(schema, {
727-
rules: { ...brandHandling, ...depicters },
728-
ctx: { isResponse: false, makeRef, path, method },
729-
}),
736+
ensureCompliance(request),
730737
paramNames,
731738
);
732-
const bodyDepiction = excludeExamplesFromDepiction(withoutParams);
739+
const examples = [];
740+
if (isSchemaObject(withoutParams) && withoutParams.examples) {
741+
examples.push(...withoutParams.examples);
742+
delete withoutParams.examples; // pull up
743+
}
733744
const media: MediaTypeObject = {
734745
schema:
735746
composition === "components"
736-
? makeRef(schema, bodyDepiction, makeCleanId(description))
737-
: bodyDepiction,
738-
examples: depictExamples(extractObjectSchema(schema), false, paramNames),
747+
? makeRef(schema, withoutParams, makeCleanId(description))
748+
: withoutParams,
749+
examples: enumerateExamples(
750+
examples.length
751+
? examples
752+
: flattenIO(request)
753+
.examples.filter(
754+
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
755+
)
756+
.map(R.omit(paramNames)),
757+
),
739758
};
740759
const body: RequestBodyObject = {
741760
description,

0 commit comments

Comments
 (0)