Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7cc1f3e
POC: detaching Diagnostics from extractObjectSchema by utilizing toJS…
RobinTail Apr 29, 2025
d1dce7b
rm redundant import.
RobinTail Apr 29, 2025
70e4a14
Making the new implementation to accept JSON Schema, so that it can b…
RobinTail Apr 29, 2025
9127977
Using the new implementation by depictRequestParams(), several things…
RobinTail Apr 29, 2025
ded851e
Using the new implementation by depictBody(). Pulled examples missing…
RobinTail Apr 29, 2025
c9c23b9
Removing old implementation and updating tests.
RobinTail Apr 29, 2025
1604cc0
Minor: todo name.
RobinTail Apr 29, 2025
bfaa733
REF: new implementation to return flat JSON schema object.
RobinTail Apr 29, 2025
feb5afd
Feat: processing required props by the new implementation.
RobinTail Apr 29, 2025
46a7bf8
Restoring required prop value in depictRequestParams.
RobinTail Apr 29, 2025
5e6347e
Ref: naming.
RobinTail Apr 29, 2025
9366d0f
Add depictObject to correct required props on requests.
RobinTail Apr 30, 2025
2dbf950
Fixed todo: detached depict() from ensureCompliance().
RobinTail Apr 30, 2025
caeee54
support examples by flattenIO and using them in depictRequestParams.
RobinTail Apr 30, 2025
73dcc90
Fix: handling own examples by depictBody.
RobinTail Apr 30, 2025
d2451ba
rm redundant import
RobinTail Apr 30, 2025
f46af92
rm unused helper.
RobinTail Apr 30, 2025
b7b0309
Fix: introducing depictRequest() to share its returns among depictReq…
RobinTail Apr 30, 2025
ca5d34f
RM: excludeExamplesFromDepicton() and depictExamples().
RobinTail Apr 30, 2025
8f617ed
Updating the example docs (has issue).
RobinTail Apr 30, 2025
3c90fa2
Fix: enabling pullProps in onEach.
RobinTail Apr 30, 2025
6551241
Updating snapshots and remove redundant ones.
RobinTail Apr 30, 2025
d84b914
Ref: taking examples from flattened IO.
RobinTail Apr 30, 2025
b23e91e
No combining examples for unions, testing flattenIO for examples.
RobinTail Apr 30, 2025
c3b3e6c
mv examples handling outta props condition.
RobinTail Apr 30, 2025
3b68db1
Testing records.
RobinTail Apr 30, 2025
44754b5
Merge branch 'make-v24' into poc-rm-extractObjectSchema
RobinTail Apr 30, 2025
1e9bc05
Merge branch 'make-v24' into poc-rm-extractObjectSchema
RobinTail Apr 30, 2025
29b37f8
Fix coverage for flattenIO.
RobinTail May 1, 2025
1332f66
rm console.log.
RobinTail May 1, 2025
3bc2c80
Fix coverage for depictResponse.
RobinTail May 1, 2025
4ee618f
Ref: straigthening condition on flattenIO.
RobinTail May 1, 2025
4f29485
Ref: consistent handling of examples pulling for depictBody
RobinTail May 1, 2025
adae75e
Restoring and adjusting the test for depictObject.
RobinTail May 1, 2025
bb4d7cc
Fix: prevent duplicates in the flattened required props list.
RobinTail May 1, 2025
8e8e4d8
REF: moving flattenIO into a dedicated JSON Schema helpers file.
RobinTail May 1, 2025
32946e8
todo for refactoring.
RobinTail May 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions example/example.documentation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ paths:
required:
- name
- createdAt
examples:
- name: John Doe
createdAt: 2021-12-31T00:00:00.000Z
required:
- status
- data
Expand Down
24 changes: 12 additions & 12 deletions express-zod-api/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import type { $ZodShape } from "@zod/core";
import * as R from "ramda";
import { z } from "zod";
import { responseVariants } from "./api-response";
import { FlatObject, getRoutePathParams } from "./common-helpers";
import { contentTypes } from "./content-type";
import { findJsonIncompatible } from "./deep-checks";
import { AbstractEndpoint } from "./endpoint";
import { extractObjectSchema } from "./io-schema";
import { flattenIO } from "./io-schema";
import { ActualLogger } from "./logger-helpers";

export class Diagnostics {
#verifiedEndpoints = new WeakSet<AbstractEndpoint>();
#verifiedPaths = new WeakMap<
AbstractEndpoint,
{ shape: $ZodShape; paths: string[] }
{ flat: ReturnType<typeof flattenIO>; paths: string[] }
>();

constructor(protected logger: ActualLogger) {}
Expand Down Expand Up @@ -65,20 +63,22 @@ export class Diagnostics {
if (ref?.paths.includes(path)) return;
const params = getRoutePathParams(path);
if (params.length === 0) return; // next statement can be expensive
const { shape } =
ref ||
R.tryCatch(extractObjectSchema, (err) => {
this.logger.warn("Diagnostics::checkPathParams()", err);
return z.object({});
})(endpoint.inputSchema);
const flat =
ref?.flat ||
flattenIO(
z.toJSONSchema(endpoint.inputSchema, {
unrepresentable: "any",
io: "input",
}),
);
for (const param of params) {
if (param in shape) continue;
if (param in flat.properties) continue;
this.logger.warn(
"The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.",
Object.assign(ctx, { path, param }),
);
}
if (ref) ref.paths.push(path);
else this.#verifiedPaths.set(endpoint, { shape, paths: [path] });
else this.#verifiedPaths.set(endpoint, { flat, paths: [path] });
}
}
162 changes: 87 additions & 75 deletions express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
$ZodObject,
$ZodPipe,
$ZodTransform,
$ZodTuple,
Expand Down Expand Up @@ -44,7 +45,7 @@ import { ezDateOutBrand } from "./date-out-schema";
import { contentTypes } from "./content-type";
import { DocumentationError } from "./errors";
import { ezFileBrand } from "./file-schema";
import { extractObjectSchema, IOSchema } from "./io-schema";
import { flattenIO, IOSchema } from "./io-schema";
import { Alternatives } from "./logical-container";
import { metaSymbol } from "./metadata";
import { Method } from "./method";
Expand Down Expand Up @@ -80,13 +81,7 @@ export type IsHeader = (

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

interface ReqResHandlingProps<S extends $ZodType>
extends Omit<OpenAPIContext, "isResponse"> {
schema: S;
composition: "inline" | "components";
description?: string;
brandHandling?: BrandHandling;
}
type ReqResCommons = Omit<OpenAPIContext, "isResponse">;

const shortDescriptionLimit = 50;
const isoDateDocumentationUrl =
Expand Down Expand Up @@ -209,6 +204,18 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({
...jsonSchema,
});

const depictObject: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
if (isResponse) return jsonSchema;
if (!isSchema<$ZodObject>(zodSchema, "object")) return jsonSchema;
const { required = [] } = jsonSchema as JSONSchema.ObjectSchema;
const result: string[] = [];
for (const key of required) {
const valueSchema = zodSchema._zod.def.shape[key];
if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key);
}
return { ...jsonSchema, required: result };
};

const ensureCompliance = ({
$ref,
type,
Expand Down Expand Up @@ -306,7 +313,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => {
ctx.isResponse ? "in" : "out"
];
if (!isSchema<$ZodTransform>(target, "transform")) return jsonSchema;
const opposingDepiction = depict(opposite, { ctx });
const opposingDepiction = ensureCompliance(depict(opposite, { ctx }));
if (isSchemaObject(opposingDepiction)) {
if (!ctx.isResponse) {
const { type: opposingType, ...rest } = opposingDepiction;
Expand Down Expand Up @@ -347,39 +354,6 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>
)
: undefined;

export const depictExamples = (
schema: $ZodType,
isResponse: boolean,
omitProps: string[] = [],
): ExamplesObject | undefined =>
R.pipe(
getExamples,
R.map(
R.when(
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
R.omit(omitProps),
),
),
enumerateExamples,
)({
schema,
variant: isResponse ? "parsed" : "original",
validate: true,
pullProps: true,
});

export const depictParamExamples = (
schema: z.ZodType,
param: string,
): ExamplesObject | undefined => {
return R.pipe(
getExamples,
R.filter(R.both(isObject, R.has(param))),
R.pluck(param),
enumerateExamples,
)({ schema, variant: "original", validate: true, pullProps: true });
};

export const defaultIsHeader = (
name: string,
familiar?: string[],
Expand All @@ -391,20 +365,22 @@ export const defaultIsHeader = (
export const depictRequestParams = ({
path,
method,
schema,
request,
inputSources,
makeRef,
composition,
brandHandling,
isHeader,
security,
description = `${method.toUpperCase()} ${path} Parameter`,
}: ReqResHandlingProps<IOSchema> & {
}: ReqResCommons & {
composition: "inline" | "components";
description?: string;
request: JSONSchema.BaseSchema;
inputSources: InputSource[];
isHeader?: IsHeader;
security?: Alternatives<Security>;
}) => {
const objectSchema = extractObjectSchema(schema);
const flat = flattenIO(request);
const pathParams = getRoutePathParams(path);
const isQueryEnabled = inputSources.includes("query");
const areParamsEnabled = inputSources.includes("params");
Expand All @@ -419,8 +395,8 @@ export const depictRequestParams = ({
areHeadersEnabled &&
(isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders));

return Object.entries(objectSchema.shape).reduce<ParameterObject[]>(
(acc, [name, paramSchema]) => {
return Object.entries(flat.properties).reduce<ParameterObject[]>(
(acc, [name, jsonSchema]) => {
const location = isPathParam(name)
? "path"
: isHeaderParam(name)
Expand All @@ -429,22 +405,26 @@ export const depictRequestParams = ({
? "query"
: undefined;
if (!location) return acc;
const depicted = depict(paramSchema, {
rules: { ...brandHandling, ...depicters },
ctx: { isResponse: false, makeRef, path, method },
});
const depicted = ensureCompliance(jsonSchema);
const result =
composition === "components"
? makeRef(paramSchema, depicted, makeCleanId(description, name))
? makeRef(jsonSchema, depicted, makeCleanId(description, name))
: depicted;
return acc.concat({
name,
in: location,
deprecated: globalRegistry.get(paramSchema)?.deprecated,
required: !doesAccept(paramSchema, undefined),
deprecated: jsonSchema.deprecated,
required: flat.required.includes(name),
description: depicted.description || description,
schema: result,
examples: depictParamExamples(objectSchema, name),
examples: enumerateExamples(
isSchemaObject(depicted) && depicted.examples?.length
? depicted.examples // own examples or from the flat:
: R.pluck(
name,
flat.examples.filter(R.both(isObject, R.has(name))),
),
),
});
},
[],
Expand All @@ -462,6 +442,7 @@ const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =
pipe: depictPipeline,
literal: depictLiteral,
enum: depictEnum,
object: depictObject,
[ezDateInBrand]: depictDateIn,
[ezDateOutBrand]: depictDateOut,
[ezUploadBrand]: depictUpload,
Expand All @@ -477,6 +458,7 @@ const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
schema: zodSchema,
variant: isResponse ? "parsed" : "original",
validate: true,
pullProps: true,
});
if (examples.length) result.examples = examples.slice();
return result;
Expand Down Expand Up @@ -506,7 +488,7 @@ const fixReferences = (
}
if (R.is(Array, entry)) stack.push(...R.values(entry));
}
return ensureCompliance(subject);
return subject;
};

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

export const excludeExamplesFromDepiction = (
depicted: SchemaObject | ReferenceObject,
): SchemaObject | ReferenceObject =>
isReferenceObject(depicted) ? depicted : R.omit(["examples"], depicted);

export const depictResponse = ({
method,
path,
Expand All @@ -593,25 +570,34 @@ export const depictResponse = ({
description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
hasMultipleStatusCodes ? statusCode : ""
}`.trim(),
}: ReqResHandlingProps<$ZodType> & {
}: ReqResCommons & {
schema: $ZodType;
composition: "inline" | "components";
description?: string;
brandHandling?: BrandHandling;
mimeTypes: ReadonlyArray<string> | null;
variant: ResponseVariant;
statusCode: number;
hasMultipleStatusCodes: boolean;
}): ResponseObject => {
if (!mimeTypes) return { description };
const depictedSchema = excludeExamplesFromDepiction(
const response = ensureCompliance(
depict(schema, {
rules: { ...brandHandling, ...depicters },
ctx: { isResponse: true, makeRef, path, method },
}),
);
const examples = [];
if (isSchemaObject(response) && response.examples) {
examples.push(...response.examples);
delete response.examples; // moving them up
}
const media: MediaTypeObject = {
schema:
composition === "components"
? makeRef(schema, depictedSchema, makeCleanId(description))
: depictedSchema,
examples: depictExamples(schema, true),
? makeRef(schema, response, makeCleanId(description))
: response,
examples: enumerateExamples(examples),
};
return { description, content: R.fromPairs(R.xprod(mimeTypes, [media])) };
};
Expand Down Expand Up @@ -708,34 +694,60 @@ export const depictSecurityRefs = (
}, {}),
);

export const depictRequest = ({
schema,
brandHandling,
makeRef,
path,
method,
}: ReqResCommons & {
schema: IOSchema;
brandHandling?: BrandHandling;
}) =>
depict(schema, {
rules: { ...brandHandling, ...depicters },
ctx: { isResponse: false, makeRef, path, method },
});

export const depictBody = ({
method,
path,
schema,
request,
mimeType,
makeRef,
composition,
brandHandling,
paramNames,
description = `${method.toUpperCase()} ${path} Request body`,
}: ReqResHandlingProps<IOSchema> & {
}: ReqResCommons & {
schema: IOSchema;
composition: "inline" | "components";
description?: string;
request: JSONSchema.BaseSchema;
mimeType: string;
paramNames: string[];
}) => {
const [withoutParams, hasRequired] = excludeParamsFromDepiction(
depict(schema, {
rules: { ...brandHandling, ...depicters },
ctx: { isResponse: false, makeRef, path, method },
}),
ensureCompliance(request),
paramNames,
);
const bodyDepiction = excludeExamplesFromDepiction(withoutParams);
const { examples = [], ...bodyDepiction } = isSchemaObject(withoutParams)
? withoutParams
: { ...withoutParams };
const media: MediaTypeObject = {
schema:
composition === "components"
? makeRef(schema, bodyDepiction, makeCleanId(description))
: bodyDepiction,
examples: depictExamples(extractObjectSchema(schema), false, paramNames),
examples: enumerateExamples(
examples.length
? examples
: flattenIO(request)
.examples.filter(
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
)
.map(R.omit(paramNames)),
),
};
const body: RequestBodyObject = {
description,
Expand Down
Loading