Skip to content
69 changes: 42 additions & 27 deletions express-zod-api/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { z } from "zod";
import { responseVariants } from "./api-response";
import { FlatObject, getRoutePathParams } from "./common-helpers";
import { getRoutePathParams } from "./common-helpers";
import { contentTypes } from "./content-type";
import { findJsonIncompatible } from "./deep-checks";
import { AbstractEndpoint } from "./endpoint";
import { flattenIO } from "./json-schema-helpers";
import { ActualLogger } from "./logger-helpers";
import { Method } from "./method";
import type { OnEndpoint } from "./routing-walker";

interface Cache {
hasValidSchema: boolean;
flat?: ReturnType<typeof flattenIO>;
paths: string[];
}

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

constructor(protected logger: ActualLogger) {}

public checkSchema(endpoint: AbstractEndpoint, ctx: FlatObject): void {
if (this.#verifiedEndpoints.has(endpoint)) return;
#checkSchema(
ref: Cache,
endpoint: AbstractEndpoint,
ctx: { method: Method; path: string },
): void {
if (ref.hasValidSchema) return;
for (const dir of ["input", "output"] as const) {
const stack = [
z.toJSONSchema(endpoint[`${dir}Schema`], { unrepresentable: "any" }),
Expand All @@ -35,7 +43,7 @@ export class Diagnostics {
if (reason) {
this.logger.warn(
"The final input schema of the endpoint contains an unsupported JSON payload type.",
Object.assign(ctx, { reason }),
{ ...ctx, reason },
);
}
}
Expand All @@ -46,39 +54,46 @@ export class Diagnostics {
if (reason) {
this.logger.warn(
`The final ${variant} response schema of the endpoint contains an unsupported JSON payload type.`,
Object.assign(ctx, { reason }),
{ ...ctx, reason },
);
}
}
}
this.#verifiedEndpoints.add(endpoint);
ref.hasValidSchema = true;
}

public checkPathParams(
#checkPathParams(
ref: Cache,
method: Method,
path: string,
endpoint: AbstractEndpoint,
ctx: FlatObject,
): void {
const ref = this.#verifiedPaths.get(endpoint);
if (ref?.paths.includes(path)) return;
if (ref.paths.includes(path)) return;
const params = getRoutePathParams(path);
if (params.length === 0) return; // next statement can be expensive
const flat =
ref?.flat ||
flattenIO(
z.toJSONSchema(endpoint.inputSchema, {
unrepresentable: "any",
io: "input",
}),
);
ref.flat ??= flattenIO(
z.toJSONSchema(endpoint.inputSchema, {
unrepresentable: "any",
io: "input",
}),
);
for (const param of params) {
if (param in flat.properties) continue;
if (param in ref.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 }),
{ method, path, param },
);
}
if (ref) ref.paths.push(path);
else this.#verifiedPaths.set(endpoint, { flat, paths: [path] });
ref.paths.push(path);
}

public check: OnEndpoint = (method, path, endpoint) => {
let ref = this.#verified.get(endpoint);
if (!ref) {
ref = { hasValidSchema: false, paths: [] };
this.#verified.set(endpoint, ref);
}
this.#checkSchema(ref, endpoint, { method, path });
this.#checkPathParams(ref, method, path, endpoint);
};
}
3 changes: 1 addition & 2 deletions express-zod-api/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ const collectSiblings = ({
const doc = isProduction() ? undefined : new Diagnostics(getLogger());
const familiar = new Map<string, Siblings>();
const onEndpoint: OnEndpoint = (method, path, endpoint) => {
doc?.checkSchema(endpoint, { path, method });
doc?.checkPathParams(path, endpoint, { method });
doc?.check(method, path, endpoint);
const matchingParsers = parsers?.[endpoint.requestType] || [];
const value = R.pair(matchingParsers, endpoint);
if (!familiar.has(path))
Expand Down