Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export default tsPlugin.config(
name: "source/ez",
files: ["express-zod-api/src/*.ts"],
rules: {
complexity: ["error", 18],
complexity: ["error", 16],
"allowed/dependencies": ["error", { packageDir: ezDir }],
"no-restricted-syntax": [
"warn",
Expand Down
23 changes: 11 additions & 12 deletions express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,26 +296,25 @@ export const depictRequestParams = ({
const isQueryEnabled = inputSources.includes("query");
const areParamsEnabled = inputSources.includes("params");
const areHeadersEnabled = inputSources.includes("headers");
const isPathParam = (name: string) =>
areParamsEnabled && pathParams.includes(name);
const securityHeaders = R.chain(
R.filter((entry: Security) => entry.type === "header"),
security ?? [],
).map(({ name }) => name);
const isHeaderParam = (name: string) =>
areHeadersEnabled &&
(isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders));

const getLocation = (name: string) => {
if (areParamsEnabled && pathParams.includes(name)) return "path";
if (
areHeadersEnabled &&
(isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders))
)
return "header";
if (isQueryEnabled) return "query";
};

return Object.entries(flat.properties).reduce<ParameterObject[]>(
(acc, [name, jsonSchema]) => {
if (!isObject(jsonSchema)) return acc;
const location = isPathParam(name)
? "path"
: isHeaderParam(name)
? "header"
: isQueryEnabled
? "query"
: undefined;
const location = getLocation(name);
if (!location) return acc;
const depicted = asOAS(jsonSchema);
const result =
Expand Down
30 changes: 19 additions & 11 deletions express-zod-api/src/json-schema-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ export const processPropertyNames = (
if (!isOptional) requiredKeys.push(...keys);
};

/** @internal */
export const mergeExamples = (
target: FlattenObjectSchema,
entry: z.core.JSONSchema.BaseSchema,
isOptional: boolean,
) => {
if (!entry.examples?.length) return;
if (isOptional) {
target.examples = R.concat(target.examples || [], entry.examples);
} else {
target.examples = combinations(
target.examples?.filter(isObject) || [],
entry.examples.filter(isObject),
([a, b]) => R.mergeDeepRight(a, b),
);
}
};

export const flattenIO = (
jsonSchema: z.core.JSONSchema.BaseSchema,
mode: MergeMode = "coerce",
Expand All @@ -91,17 +109,7 @@ export const flattenIO = (
if (entry.description) flat.description ??= entry.description;
stack.push(...processAllOf(entry, mode, isOptional));
stack.push(...processVariants(entry));
if (entry.examples?.length) {
if (isOptional) {
flat.examples = R.concat(flat.examples || [], entry.examples);
} else {
flat.examples = combinations(
flat.examples?.filter(isObject) || [],
entry.examples.filter(isObject),
([a, b]) => R.mergeDeepRight(a, b),
);
}
}
mergeExamples(flat, entry, isOptional);
if (!isJsonObjectSchema(entry)) continue;
stack.push([isOptional, { examples: pullRequestExamples(entry) }]);
if (entry.properties) {
Expand Down
41 changes: 41 additions & 0 deletions express-zod-api/tests/json-schema-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import {
flattenIO,
isJsonObjectSchema,
mergeExamples,
propsMerger,
canMerge,
nestOptional,
Expand Down Expand Up @@ -199,6 +200,46 @@ describe("JSON Schema helpers", () => {
});
});

describe("mergeExamples()", () => {
test("should do nothing when entry has no examples", () => {
const flat = { type: "object" as const, properties: {} };
mergeExamples(flat, { type: "string" }, false);
expect(flat).toEqual({ type: "object", properties: {} });
});

test("should concatenate examples when optional", () => {
const flat = {
type: "object" as const,
properties: {},
examples: [{ a: 1 }],
};
mergeExamples(flat, { examples: [{ b: 2 }, { c: 3 }] }, true);
expect(flat.examples).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
});

test.each([true, false])(
"should initialize examples when flat has none (isOptional=%s)",
(isOptional) => {
const flat = { type: "object" as const, properties: {} };
mergeExamples(flat, { examples: [{ a: 1 }] }, isOptional);
expect(flat).toHaveProperty("examples", [{ a: 1 }]);
},
);

test("should produce combinations when required", () => {
const flat = {
type: "object" as const,
properties: {},
examples: [{ a: 1 }],
};
mergeExamples(flat, { examples: [{ b: 2 }, { b: 3 }] }, false);
expect(flat.examples).toEqual([
{ a: 1, b: 2 },
{ a: 1, b: 3 },
]);
});
});

describe("pullRequestExamples()", () => {
test("should return empty array for empty properties", () => {
expect(pullRequestExamples({ type: "object", properties: {} })).toEqual(
Expand Down
Loading