diff --git a/eslint.config.js b/eslint.config.js index 66aa6a6fc..60798d6f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -216,6 +216,7 @@ export default tsPlugin.config( name: "source/ez", files: ["express-zod-api/src/*.ts"], rules: { + complexity: ["error", 20], "allowed/dependencies": ["error", { packageDir: ezDir }], "no-restricted-syntax": [ "warn", diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 1d96da0da..04d94a164 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -2,17 +2,24 @@ import * as R from "ramda"; import { combinations, FlatObject, isObject } from "./common-helpers"; import type { z } from "zod"; -const isJsonObjectSchema = ( +type MergeMode = "coerce" | "throw"; +type FlattenObjectSchema = z.core.JSONSchema.ObjectSchema & + Required>; + +/** @internal */ +export const isJsonObjectSchema = ( subject: z.core.JSONSchema.BaseSchema, ): subject is z.core.JSONSchema.ObjectSchema => subject.type === "object"; -const propsMerger = R.mergeDeepWith((a: unknown, b: unknown) => { +/** @internal */ +export const propsMerger = R.mergeDeepWith((a: unknown, b: unknown) => { if (Array.isArray(a) && Array.isArray(b)) return R.concat(a, b); if (a === b) return b; throw new Error("Can not flatten properties", { cause: { a, b } }); }); -const canMerge = R.pipe( +/** @internal */ +export const canMerge = R.pipe( Object.keys, R.without([ "type", @@ -25,33 +32,65 @@ const canMerge = R.pipe( R.isEmpty, ); -const nestOptional = R.pair(true); +/** @internal */ +export const nestOptional = R.pair(true); +type Stack = Array>; + +/** @internal */ +export const processAllOf = ( + subject: z.core.JSONSchema.BaseSchema, + mode: MergeMode, + isOptional: boolean, +) => { + if (!("allOf" in subject) || !subject.allOf) return []; + return subject.allOf.map((one) => { + if (mode === "throw" && !(one.type === "object" && canMerge(one))) + throw new Error("Can not merge"); + return R.pair(isOptional, one); + }); +}; + +/** @internal */ +export const processVariants = (subject: z.core.JSONSchema.BaseSchema) => { + const result: Stack = []; + if (subject.anyOf) result.push(...R.map(nestOptional, subject.anyOf)); + if (subject.oneOf) result.push(...R.map(nestOptional, subject.oneOf)); + return result; +}; + +/** @internal */ +export const processPropertyNames = ( + subject: z.core.JSONSchema.ObjectSchema, + target: FlattenObjectSchema, + requiredKeys: string[], + isOptional: boolean, +) => { + if (!isObject(subject.propertyNames)) return; + const keys: string[] = []; + if (typeof subject.propertyNames.const === "string") + keys.push(subject.propertyNames.const); + if (subject.propertyNames.enum) { + keys.push( + ...subject.propertyNames.enum.filter((one) => typeof one === "string"), + ); + } + const value = { ...Object(subject.additionalProperties) }; // it can be bool + for (const key of keys) target.properties[key] ??= value; + if (!isOptional) requiredKeys.push(...keys); +}; export const flattenIO = ( jsonSchema: z.core.JSONSchema.BaseSchema, - mode: "coerce" | "throw" = "coerce", + mode: MergeMode = "coerce", ) => { - const stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] - const flat: z.core.JSONSchema.ObjectSchema & - Required> = { - type: "object", - properties: {}, - }; + const stack: Stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] + const flat: FlattenObjectSchema = { type: "object", properties: {} }; const flatRequired: string[] = []; while (stack.length) { const [isOptional, entry] = stack.shift()!; if (entry.description) flat.description ??= entry.description; - if (entry.allOf) { - stack.push( - ...entry.allOf.map((one) => { - if (mode === "throw" && !(one.type === "object" && canMerge(one))) - throw new Error("Can not merge"); - return R.pair(isOptional, one); - }), - ); - } - if (entry.anyOf) stack.push(...R.map(nestOptional, entry.anyOf)); - if (entry.oneOf) stack.push(...R.map(nestOptional, entry.oneOf)); + stack.push(...processAllOf(entry, mode, isOptional)); + stack.push(...processVariants(entry)); if (entry.examples?.length) { if (isOptional) { flat.examples = R.concat(flat.examples || [], entry.examples); @@ -72,19 +111,7 @@ export const flattenIO = ( ); if (!isOptional && entry.required) flatRequired.push(...entry.required); } - if (isObject(entry.propertyNames)) { - const keys: string[] = []; - if (typeof entry.propertyNames.const === "string") - keys.push(entry.propertyNames.const); - if (entry.propertyNames.enum) { - keys.push( - ...entry.propertyNames.enum.filter((one) => typeof one === "string"), - ); - } - const value = { ...Object(entry.additionalProperties) }; // it can be bool - for (const key of keys) flat.properties[key] ??= value; - if (!isOptional) flatRequired.push(...keys); - } + processPropertyNames(entry, flat, flatRequired, isOptional); } if (flatRequired.length) flat.required = [...new Set(flatRequired)]; return flat; diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index df3458fe0..b18c08016 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -1,7 +1,247 @@ import { z } from "zod"; -import { flattenIO } from "../src/json-schema-helpers"; +import { + flattenIO, + isJsonObjectSchema, + propsMerger, + canMerge, + nestOptional, + processAllOf, + processVariants, + processPropertyNames, + pullRequestExamples, +} from "../src/json-schema-helpers"; describe("JSON Schema helpers", () => { + describe("isJsonObjectSchema()", () => { + test("should return true for object schema", () => { + expect(isJsonObjectSchema({ type: "object" })).toBe(true); + }); + + test.each(["string", "array"] as const)( + "should return false for non-object schema", + (one) => { + expect(isJsonObjectSchema({ type: one })).toBe(false); + }, + ); + }); + + describe("propsMerger()", () => { + test("should merge objects deeply", () => { + expect(propsMerger({ a: { b: 1 } }, { a: { c: 2 } })).toStrictEqual({ + a: { b: 1, c: 2 }, + }); + }); + + test("should throw when leaf values cannot be merged", () => { + expect(() => propsMerger({ a: 1 }, { a: "string" })).toThrow( + "Can not flatten properties", + ); + }); + }); + + describe("canMerge()", () => { + test("should return true for empty object", () => { + expect(canMerge({})).toBe(true); + }); + + test("should return true for object with only mergeable keys", () => { + expect(canMerge({ type: "object", properties: {} })).toBe(true); + }); + + test.each([{ title: "test" }, { format: "date-time" }])( + "should return false for object with non-mergeable keys", + (subj) => { + expect(canMerge(subj)).toBe(false); + }, + ); + }); + + describe("nestOptional()", () => { + test("should pair true with given argument", () => { + expect(nestOptional({ type: "string" })).toEqual([ + true, + { type: "string" }, + ]); + }); + }); + + describe("processAllOf()", () => { + test("should return empty array when no allOf", () => { + const result = processAllOf( + { type: "object", properties: {} }, + "coerce", + false, + ); + expect(result).toEqual([]); + }); + + test("should map allOf entries with isOptional flag in coerce mode", () => { + const result = processAllOf( + { + type: "object", + allOf: [{ type: "object", properties: { a: { type: "string" } } }], + }, + "coerce", + true, + ); + expect(result).toEqual([ + [true, { type: "object", properties: { a: { type: "string" } } }], + ]); + }); + + test("should throw in throw mode when schema cannot be merged", () => { + expect(() => + processAllOf( + { type: "object", allOf: [{ type: "string" }] }, + "throw", + false, + ), + ).toThrow("Can not merge"); + }); + + test("should allow mergeable schemas in throw mode", () => { + const result = processAllOf( + { + type: "object", + allOf: [{ type: "object", properties: { a: { type: "string" } } }], + }, + "throw", + false, + ); + expect(result).toEqual([ + [false, { type: "object", properties: { a: { type: "string" } } }], + ]); + }); + }); + + describe("processVariants()", () => { + test("should return empty array when no anyOf/oneOf", () => { + const result = processVariants({ type: "object", properties: {} }); + expect(result).toEqual([]); + }); + + test("should process anyOf as optional", () => { + const result = processVariants({ + type: "object", + anyOf: [{ type: "string" }, { type: "number" }], + }); + expect(result).toEqual([ + [true, { type: "string" }], + [true, { type: "number" }], + ]); + }); + + test("should process oneOf as optional", () => { + const result = processVariants({ + type: "object", + oneOf: [{ type: "string" }, { type: "number" }], + }); + expect(result).toEqual([ + [true, { type: "string" }], + [true, { type: "number" }], + ]); + }); + }); + + describe("processPropertyNames()", () => { + test("should not modify flat when no propertyNames", () => { + const flat = { type: "object" as const, properties: {} }; + const flatRequired: string[] = []; + processPropertyNames( + { type: "object", properties: {} }, + flat, + flatRequired, + false, + ); + expect(flat.properties).toEqual({}); + expect(flatRequired).toEqual([]); + }); + + test("should extract const key", () => { + const flat = { type: "object" as const, properties: {} }; + const flatRequired: string[] = []; + processPropertyNames( + { type: "object", propertyNames: { const: "key" } }, + flat, + flatRequired, + false, + ); + expect(flat.properties).toHaveProperty("key"); + expect(flatRequired).toContain("key"); + }); + + test("should extract enum keys", () => { + const flat = { type: "object" as const, properties: {} }; + const flatRequired: string[] = []; + processPropertyNames( + { type: "object", propertyNames: { enum: ["a", "b"] } }, + flat, + flatRequired, + false, + ); + expect(flat.properties).toHaveProperty("a"); + expect(flat.properties).toHaveProperty("b"); + expect(flatRequired).toContain("a"); + expect(flatRequired).toContain("b"); + }); + + test("should not add to required when optional", () => { + const flat = { type: "object" as const, properties: {} }; + const flatRequired: string[] = []; + processPropertyNames( + { type: "object", propertyNames: { const: "key" } }, + flat, + flatRequired, + true, + ); + expect(flat.properties).toHaveProperty("key"); + expect(flatRequired).toEqual([]); + }); + }); + + describe("pullRequestExamples()", () => { + test("should return empty array for empty properties", () => { + expect(pullRequestExamples({ type: "object", properties: {} })).toEqual( + [], + ); + }); + + test("should return empty array when properties have no examples", () => { + expect( + pullRequestExamples({ + type: "object", + properties: { name: { type: "string" } }, + }), + ).toEqual([]); + }); + + test("should extract examples from properties", () => { + expect( + pullRequestExamples({ + type: "object", + properties: { name: { type: "string", examples: ["john", "jane"] } }, + }), + ).toEqual([{ name: "john" }, { name: "jane" }]); + }); + + test("should combine examples from multiple properties", () => { + expect( + pullRequestExamples({ + type: "object", + properties: { + name: { type: "string", examples: ["john", "jane"] }, + age: { type: "number", examples: [25, 30] }, + }, + }), + ).toEqual([ + { name: "john", age: 25 }, + { name: "john", age: 30 }, + { name: "jane", age: 25 }, + { name: "jane", age: 30 }, + ]); + }); + }); + describe("flattenIO()", () => { test("should pass the object schema through", () => { const subject = flattenIO({