diff --git a/eslint.config.js b/eslint.config.js index ed49374cb..c0090f571 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index cf1638e3c..c9a646929 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -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( (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 = diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 04d94a164..1186354d9 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -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", @@ -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) { diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index b18c08016..8cb2f67ff 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { flattenIO, isJsonObjectSchema, + mergeExamples, propsMerger, canMerge, nestOptional, @@ -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(