From dd5bc6a111fe9e52c0fa3dcae6122193cb6f9391 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 09:59:13 +0200 Subject: [PATCH 01/19] feat: squashed version of #3344, limit-combinations branch. --- CHANGELOG.md | 26 ++++++++ express-zod-api/src/common-helpers.ts | 20 ++++-- express-zod-api/src/diagnostics.ts | 4 +- express-zod-api/src/documentation-helpers.ts | 29 ++++----- express-zod-api/src/documentation.ts | 24 +++++-- express-zod-api/src/endpoint.ts | 14 +++-- express-zod-api/src/integration.ts | 4 +- express-zod-api/src/json-schema-helpers.ts | 44 ++++++++----- express-zod-api/src/logical-container.ts | 6 +- express-zod-api/src/result-helpers.ts | 10 +-- express-zod-api/src/security.ts | 19 ++++++ express-zod-api/tests/common-helpers.spec.ts | 24 +++++-- .../tests/documentation-helpers.spec.ts | 4 +- express-zod-api/tests/endpoint.spec.ts | 4 +- .../tests/json-schema-helpers.spec.ts | 63 ++++++++++++++----- .../tests/logical-container.spec.ts | 12 ++++ express-zod-api/tests/result-helpers.spec.ts | 9 +++ express-zod-api/tests/security.spec.ts | 52 +++++++++++++++ 18 files changed, 294 insertions(+), 74 deletions(-) create mode 100644 express-zod-api/tests/security.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d582b9c6f2..7b16d29c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## Version 27 +### v27.4.0 + +- Introduced `maxCombinations` setting for Documentation generator: + - Limits cartesian product when generating examples by combining each property's own examples; + - Set to `0` to disable product but keep concatenations; + - Default is `Infinity`, but may change to reasonable number in v28 to avoid too many combinations; + - Example: 6 props with 2 examples each → cartesian product makes 2^6 = 64 request examples: + +```ts +const schema = z.object({ + id: z.number().example(1).example(2), + name: z.string().example("john").example("jane"), + age: z.number().example(18).example(21), + role: z.enum(["user", "admin"]).example("user").example("admin"), + active: z.boolean().example(true).example(false), + tags: z.array(z.string()).example(["vip"]).example(["new", "promo"]), +}); +// First 5: +// { id: 1, name: "john", age: 18, role: "user", active: true, tags: ["vip"] }, +// { id: 1, name: "john", age: 18, role: "user", active: true, tags: ["new", "promo"] }, +// { id: 1, name: "john", age: 18, role: "user", active: false, tags: ["vip"] }, +// { id: 1, name: "john", age: 18, role: "user", active: false, tags: ["new", "promo"] }, +// { id: 1, name: "john", age: 18, role: "admin", active: true, tags: ["vip"] }, +// ...and 59 more +``` + ### v27.3.0 - Supporting Node 26. diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index d727e4cc04..29adc59f96 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -109,11 +109,23 @@ export const isSchema = ( "_zod" in subject && (type ? R.path(["_zod", "def", "type"], subject) === type : true); +/** Configurable replacement for R.xprod(), but it also handles empty arrays */ export const combinations = ( - a: T[], - b: T[], - merge: (pair: [T, T]) => T, -): T[] => (a.length && b.length ? R.xprod(a, b).map(merge) : a.concat(b)); + left: T[], + right: T[], + /** @desc The function that combines elements */ + merge: (a: T, b: T) => T, + /** @desc Maximum number of combinations (only applies to Cartesian product of non-empty arrays) */ + limit = Infinity, +): T[] => { + if (!left.length || !right.length) return left.concat(right); + const result: T[] = []; + for (let idxL = 0; idxL < left.length && result.length < limit; idxL++) { + for (let idxR = 0; idxR < right.length && result.length < limit; idxR++) + result.push(merge(left[idxL], right[idxR])); + } + return result; +}; export const ucFirst = (subject: string) => subject.charAt(0).toUpperCase() + subject.slice(1).toLowerCase(); diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index c21b602fef..a134f7c68d 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -47,7 +47,8 @@ export class Diagnostics { } } for (const variant of responseVariants) { - for (const { mimeTypes, schema } of endpoint.getResponses(variant)) { + const responses = endpoint.getResponses(variant, { maxCombinations: 0 }); // no examples + for (const { mimeTypes, schema } of responses) { if (!mimeTypes?.includes(contentTypes.json)) continue; const reason = findJsonIncompatible(schema, "output"); if (reason) { @@ -75,6 +76,7 @@ export class Diagnostics { unrepresentable: "any", io: "input", }), + { maxCombinations: 0 }, // not required for this check ); for (const param of params) { if (param in ref.flat.properties) continue; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index d50aee9514..4cd7f8fb0b 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -55,6 +55,7 @@ interface ReqResCommons { ) => ReferenceObject; path: string; method: ClientMethod; + maxCombinations?: number; } export interface OpenAPIContext extends ReqResCommons { @@ -126,9 +127,9 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { }; export const depictIntersection = R.tryCatch( - ({ jsonSchema }) => { + ({ jsonSchema }, { maxCombinations }) => { if (!jsonSchema.allOf) throw "no allOf"; - return flattenIO(jsonSchema, "throw"); + return flattenIO(jsonSchema, { isStrict: true, maxCombinations }); }, (_err, { jsonSchema }) => jsonSchema, ); @@ -267,9 +268,9 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => export const defaultIsHeader = ( name: string, - familiar?: string[], + familiar?: Set, ): name is `x-${string}` => - familiar?.includes(name) || + familiar?.has(name) || name.startsWith("x-") || getWellKnownHeaders().has(name); @@ -281,7 +282,8 @@ export const depictRequestParams = ({ makeRef, composition, isHeader, - security, + securityHeaders, + maxCombinations, description = `${method.toUpperCase()} ${path} Parameter`, }: ReqResCommons & { composition: "inline" | "components"; @@ -289,17 +291,13 @@ export const depictRequestParams = ({ request: z.core.JSONSchema.BaseSchema; inputSources: InputSource[]; isHeader?: IsHeader; - security?: Alternatives; + securityHeaders?: Set; }) => { - const flat = flattenIO(request); + const flat = flattenIO(request, { maxCombinations }); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); const areHeadersEnabled = inputSources.includes("headers"); - const securityHeaders = R.chain( - R.filter((entry: Security) => entry.type === "header"), - security ?? [], - ).map(({ name }) => name); const getLocation = (name: string) => { if (areParamsEnabled && pathParams.includes(name)) return "path"; @@ -458,6 +456,7 @@ export const depictResponse = ({ hasMultipleStatusCodes, statusCode, brandHandling, + maxCombinations, description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), @@ -475,7 +474,7 @@ export const depictResponse = ({ const response = asOAS( depict(schema, { rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: true, makeRef, path, method }, + ctx: { isResponse: true, makeRef, path, method, maxCombinations }, }), ); const examples = []; @@ -591,13 +590,14 @@ export const depictRequest = ({ makeRef, path, method, + maxCombinations, }: ReqResCommons & { schema: IOSchema; brandHandling?: BrandHandling; }) => depict(schema, { rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, + ctx: { isResponse: false, makeRef, path, method, maxCombinations }, }); export const depictBody = ({ @@ -609,6 +609,7 @@ export const depictBody = ({ makeRef, composition, paramNames, + maxCombinations, description = `${method.toUpperCase()} ${path} Request body`, }: ReqResCommons & { schema: IOSchema; @@ -635,7 +636,7 @@ export const depictBody = ({ examples: enumerateExamples( examples.length ? examples - : flattenIO(request) + : flattenIO(request, { maxCombinations }) .examples?.filter( (one): one is FlatObject => isObject(one) && !Array.isArray(one), ) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 173930cbe6..a2c07d16c5 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -13,6 +13,7 @@ import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { getInputSources, makeCleanId } from "./common-helpers"; import { CommonConfig } from "./config-type"; +import { getSecurityHeaders } from "./security"; import { processContainers } from "./logical-container"; import { ClientMethod } from "./method"; import { @@ -86,6 +87,14 @@ interface DocumentationParams { * @example { users: "About users", files: { description: "About files", url: "https://example.com" } } * */ tags?: Parameters[0]; + /** + * @desc Limits cartesian product when generating examples by combining each property's own examples. + * @desc Applies to: request/response examples, security scheme alternatives. + * @example 0 — disables product combinations, keeps concatenations + * @default Infinity + * @todo set to 10 or 20 in v28 to avoid too many combinations + * */ + maxCombinations?: number; } export class Documentation extends OpenApiBuilder { @@ -158,6 +167,7 @@ export class Documentation extends OpenApiBuilder { brandHandling, tags, isHeader, + maxCombinations, hasSummaryFromDescription = true, hasHeadMethod = true, composition = "inline", @@ -173,6 +183,7 @@ export class Documentation extends OpenApiBuilder { endpoint, composition, brandHandling, + maxCombinations, makeRef: this.#makeRef.bind(this), }; const { description, shortDescription, scopes, inputSchema } = endpoint; @@ -189,12 +200,12 @@ export class Documentation extends OpenApiBuilder { ); const request = depictRequest({ ...commons, schema: inputSchema }); - const security = processContainers(endpoint.security); + const securityHeaders = getSecurityHeaders(endpoint.security); const depictedParams = depictRequestParams({ ...commons, inputSources, isHeader, - security, + securityHeaders, request, description: descriptions?.requestParameter?.call(null, { method, @@ -205,7 +216,9 @@ export class Documentation extends OpenApiBuilder { const responses: ResponsesObject = {}; for (const variant of responseVariants) { - const apiResponses = endpoint.getResponses(variant); + const apiResponses = endpoint.getResponses(variant, { + maxCombinations, + }); for (const { mimeTypes, schema, statusCodes } of apiResponses) { for (const statusCode of statusCodes) { responses[statusCode] = depictResponse({ @@ -243,7 +256,10 @@ export class Documentation extends OpenApiBuilder { : undefined; const securityRefs = depictSecurityRefs( - depictSecurity(security, inputSources), + depictSecurity( + processContainers(endpoint.security, maxCombinations), + inputSources, + ), scopes, (securitySchema) => { const name = this.#ensureUniqSecuritySchemaName(securitySchema); diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index eaf2f4ce4e..9b59721d52 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -57,6 +57,7 @@ export abstract class AbstractEndpoint { /** @internal */ public abstract getResponses( variant: ResponseVariant, + params: { maxCombinations?: number }, ): ReadonlyArray; /** @internal */ public abstract getOperationId(method: ClientMethod): string | undefined; @@ -90,16 +91,16 @@ export class Endpoint< readonly #def: ConstructorParameters>[0]; /** considered expensive operation, only required for generators */ - #ensureOutputExamples = R.once(() => { + #ensureOutputExamples(limit?: number) { if (globalRegistry.get(this.#def.outputSchema)?.examples?.length) return; // examples on output schema, or pull up: if (!isSchema(this.#def.outputSchema, "object")) return; - const examples = pullResponseExamples(this.#def.outputSchema); + const examples = pullResponseExamples(this.#def.outputSchema, limit); if (!examples.length) return; const current = this.#def.outputSchema.meta(); globalRegistry .remove(this.#def.outputSchema) // reassign to avoid cloning .add(this.#def.outputSchema, { ...current, examples }); - }); + } constructor(def: { deprecated?: boolean; @@ -172,8 +173,11 @@ export class Endpoint< } /** @internal */ - public override getResponses(variant: ResponseVariant) { - if (variant === "positive") this.#ensureOutputExamples(); + public override getResponses( + variant: ResponseVariant, + { maxCombinations }: { maxCombinations?: number }, + ) { + if (variant === "positive") this.#ensureOutputExamples(maxCombinations); return Object.freeze( variant === "negative" ? this.#def.resultHandler.getNegativeResponse() diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 46f50438cb..e9b1599a11 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -107,7 +107,9 @@ export class Integration extends IntegrationBase { this.#program.push(input); const dictionaries = responseVariants.reduce( (agg, responseVariant) => { - const responses = endpoint.getResponses(responseVariant); + const responses = endpoint.getResponses(responseVariant, { + maxCombinations: 0, // not using examples yet + }); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { const hasContent = shouldHaveContent(method, mimeTypes); const variantType = this.api.makeType( diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 3eceb01a1f..3ceaea9903 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -3,7 +3,6 @@ import { combinations, FlatObject, isObject } from "./common-helpers"; import type { z } from "zod"; import type { SchemaObject } from "openapi3-ts/oas31"; -type MergeMode = "coerce" | "throw"; type FlattenObjectSchema = z.core.JSONSchema.ObjectSchema & Required>; @@ -42,12 +41,11 @@ type Stack = Array>; /** @internal */ export const processAllOf = ( subject: z.core.JSONSchema.BaseSchema, - mode: MergeMode, - isOptional: boolean, + { isStrict, isOptional }: { isStrict: boolean; isOptional: boolean }, ) => { if (!("allOf" in subject) || !subject.allOf) return []; return subject.allOf.map((one) => { - if (mode === "throw" && !(one.type === "object" && canMerge(one))) + if (isStrict && !(one.type === "object" && canMerge(one))) throw new Error("Can not merge"); return R.pair(isOptional, one); }); @@ -86,7 +84,10 @@ export const processPropertyNames = ( export const mergeExamples = ( target: FlattenObjectSchema, entry: z.core.JSONSchema.BaseSchema, - isOptional: boolean, + { + isOptional, + maxCombinations, + }: { isOptional: boolean; maxCombinations?: number }, ) => { if (!entry.examples?.length) return; if (isOptional) { @@ -95,14 +96,22 @@ export const mergeExamples = ( target.examples = combinations( target.examples?.filter(isObject) || [], entry.examples.filter(isObject), - ([a, b]) => R.mergeDeepRight(a, b), + R.mergeDeepRight, + maxCombinations, ); } }; export const flattenIO = ( jsonSchema: z.core.JSONSchema.BaseSchema, - mode: MergeMode = "coerce", + { + isStrict = false, + maxCombinations, + }: { + /** @default false */ + isStrict?: boolean; + maxCombinations?: number; + } = {}, ) => { const stack: Stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] const flat: FlattenObjectSchema = { type: "object", properties: {} }; @@ -110,13 +119,16 @@ export const flattenIO = ( for (let idx = 0; idx < stack.length; idx++) { const [isOptional, entry] = stack[idx]; if (entry.description) flat.description ??= entry.description; - stack.push(...processAllOf(entry, mode, isOptional)); + stack.push(...processAllOf(entry, { isStrict, isOptional })); stack.push(...processVariants(entry)); - mergeExamples(flat, entry, isOptional); + mergeExamples(flat, entry, { isOptional, maxCombinations }); if (!isJsonObjectSchema(entry)) continue; - stack.push([isOptional, { examples: pullRequestExamples(entry) }]); + stack.push([ + isOptional, + { examples: pullRequestExamples(entry, maxCombinations) }, + ]); if (entry.properties) { - flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( + flat.properties = (isStrict ? propsMerger : R.mergeDeepRight)( flat.properties, entry.properties, ); @@ -129,14 +141,14 @@ export const flattenIO = ( }; /** @see pullResponseExamples */ -export const pullRequestExamples = (subject: z.core.JSONSchema.ObjectSchema) => +export const pullRequestExamples = ( + subject: z.core.JSONSchema.ObjectSchema, + limit?: number, +) => Object.entries(subject.properties || {}).reduce( (acc, [key, prop]) => { const { examples = [] } = isObject(prop) ? prop : {}; - return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ - ...left, - ...right, - })); + return combinations(acc, examples.map(R.objOf(key)), R.mergeRight, limit); }, [], ); diff --git a/express-zod-api/src/logical-container.ts b/express-zod-api/src/logical-container.ts index ffb92ee030..782b5de667 100644 --- a/express-zod-api/src/logical-container.ts +++ b/express-zod-api/src/logical-container.ts @@ -27,6 +27,7 @@ export type Alternatives = Array>; export const processContainers = ( containers: LogicalContainer[], + maxCombinations?: number, ): Alternatives => { const simples = R.filter(isSimple, containers); const ands = R.chain(R.prop("and"), R.filter(isLogicalAnd, containers)); @@ -36,10 +37,11 @@ export const processContainers = ( const alternators = R.map(R.prop("or"), R.concat(ors, orsInAnds)); // no chain! return alternators.reduce( (acc, entry) => - combinations( + combinations>( acc, R.map((opt) => (isSimple(opt) ? [opt] : opt.and), entry), - ([a, b]) => R.concat(a, b), + R.concat, + maxCombinations, ), R.reject(R.isEmpty, [persistent]), ); diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index 0f7cd11627..04406b2fe2 100644 --- a/express-zod-api/src/result-helpers.ts +++ b/express-zod-api/src/result-helpers.ts @@ -88,14 +88,14 @@ export const getPublicErrorMessage = (error: HttpError): string => : error.message; /** @see pullRequestExamples */ -export const pullResponseExamples = (subject: T) => +export const pullResponseExamples = ( + subject: T, + limit?: number, +) => Object.entries(subject._zod.def.shape).reduce( (acc, [key, schema]) => { const { examples = [] } = globalRegistry.get(schema) || {}; - return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ - ...left, - ...right, - })); + return combinations(acc, examples.map(R.objOf(key)), R.mergeRight, limit); }, [], ); diff --git a/express-zod-api/src/security.ts b/express-zod-api/src/security.ts index 6bb64e549c..c60c9e275f 100644 --- a/express-zod-api/src/security.ts +++ b/express-zod-api/src/security.ts @@ -1,3 +1,10 @@ +import * as R from "ramda"; +import { + isLogicalAnd, + isLogicalOr, + type LogicalContainer, +} from "./logical-container"; + export interface BasicSecurity { type: "basic"; } @@ -93,3 +100,15 @@ export type Security = | CookieSecurity | OpenIdSecurity | OAuth2Security; + +const pickHeaders = (container: LogicalContainer): string[] => { + if (isLogicalAnd(container)) return R.chain(pickHeaders, container.and); + if (isLogicalOr(container)) return R.chain(pickHeaders, container.or); + if (container.type === "header") return [container.name]; + return []; +}; + +/** @desc Extract header security names from logical containers without generating combinations */ +export const getSecurityHeaders = ( + containers: LogicalContainer[], +): Set => new Set(R.chain(pickHeaders, containers)); diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 96b24cc2da..16ec43404e 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -270,15 +270,31 @@ describe("Common Helpers", () => { describe("combinations()", () => { test("should run callback on each combination of items from two arrays", () => { - expect(combinations([1, 2], [4, 5, 6], ([a, b]) => a + b)).toEqual([ + expect(combinations([1, 2], [4, 5, 6], (a, b) => a + b)).toEqual([ 5, 6, 7, 6, 7, 8, ]); }); test("should handle one or two arrays are empty", () => { - expect(combinations([], [4, 5, 6], ([a, b]) => a + b)).toEqual([4, 5, 6]); - expect(combinations([1, 2, 3], [], ([a, b]) => a + b)).toEqual([1, 2, 3]); - expect(combinations([], [], ([a, b]) => a + b)).toEqual([]); + expect(combinations([], [4, 5, 6], (a, b) => a + b)).toEqual([4, 5, 6]); + expect(combinations([1, 2, 3], [], (a, b) => a + b)).toEqual([1, 2, 3]); + expect(combinations([], [], (a, b) => a + b)).toEqual([]); + }); + + test("should not apply the limit when returns original array", () => { + expect(combinations([1, 2, 3], [], (a, b) => a + b, 1)).toEqual([ + 1, 2, 3, + ]); + }); + + test("should limit the number of combinations", () => { + expect(combinations([1, 2], [4, 5, 6], (a, b) => a + b, 4)).toEqual([ + 5, 6, 7, 6, + ]); + }); + + test.each([0, -1, NaN])("should return empty for limit=%s", (limit) => { + expect(combinations([1, 2], [3, 4], (a, b) => a + b, limit)).toEqual([]); }); }); diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 9f5d45d373..c03845e488 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -368,7 +368,7 @@ describe("Documentation helpers", () => { { name: "authorization", expected: true }, { name: "secure", - familiar: ["secure"], + familiar: new Set(["secure"]), expected: true, }, { name: "unknown", expected: false }, @@ -464,7 +464,7 @@ describe("Documentation helpers", () => { }, inputSources: ["query", "headers", "params"], composition: "inline", - security: [[{ type: "header", name: "secure" }]], + securityHeaders: new Set(["secure"]), ...requestCtx, }), ).toMatchSnapshot(); diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index 83584bc4f8..b6d312f91e 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -282,7 +282,9 @@ describe("Endpoint", () => { output: z.object({ something: z.number() }), handler: vi.fn(), }); - const responses = endpoint.getResponses(variant); + const responses = endpoint.getResponses(variant, { + maxCombinations: 0, + }); expect(responses).toMatchSnapshot(); expect(() => (responses as any[]).push()).toThrowError(/read only/); }, diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index 8cb2f67ffa..4cbbf922fa 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -70,44 +70,40 @@ describe("JSON Schema helpers", () => { test("should return empty array when no allOf", () => { const result = processAllOf( { type: "object", properties: {} }, - "coerce", - false, + { isStrict: false, isOptional: false }, ); expect(result).toEqual([]); }); - test("should map allOf entries with isOptional flag in coerce mode", () => { + test("should map allOf entries with isOptional flag in non-strict mode", () => { const result = processAllOf( { type: "object", allOf: [{ type: "object", properties: { a: { type: "string" } } }], }, - "coerce", - true, + { isStrict: false, isOptional: true }, ); expect(result).toEqual([ [true, { type: "object", properties: { a: { type: "string" } } }], ]); }); - test("should throw in throw mode when schema cannot be merged", () => { + test("should throw in strict mode when schema cannot be merged", () => { expect(() => processAllOf( { type: "object", allOf: [{ type: "string" }] }, - "throw", - false, + { isStrict: true, isOptional: false }, ), ).toThrow("Can not merge"); }); - test("should allow mergeable schemas in throw mode", () => { + test("should allow mergeable schemas in strict mode", () => { const result = processAllOf( { type: "object", allOf: [{ type: "object", properties: { a: { type: "string" } } }], }, - "throw", - false, + { isStrict: true, isOptional: false }, ); expect(result).toEqual([ [false, { type: "object", properties: { a: { type: "string" } } }], @@ -203,7 +199,7 @@ 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); + mergeExamples(flat, { type: "string" }, { isOptional: false }); expect(flat).toEqual({ type: "object", properties: {} }); }); @@ -213,7 +209,11 @@ describe("JSON Schema helpers", () => { properties: {}, examples: [{ a: 1 }], }; - mergeExamples(flat, { examples: [{ b: 2 }, { c: 3 }] }, true); + mergeExamples( + flat, + { examples: [{ b: 2 }, { c: 3 }] }, + { isOptional: true }, + ); expect(flat.examples).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]); }); @@ -221,7 +221,7 @@ describe("JSON Schema helpers", () => { "should initialize examples when flat has none (isOptional=%s)", (isOptional) => { const flat = { type: "object" as const, properties: {} }; - mergeExamples(flat, { examples: [{ a: 1 }] }, isOptional); + mergeExamples(flat, { examples: [{ a: 1 }] }, { isOptional }); expect(flat).toHaveProperty("examples", [{ a: 1 }]); }, ); @@ -232,12 +232,30 @@ describe("JSON Schema helpers", () => { properties: {}, examples: [{ a: 1 }], }; - mergeExamples(flat, { examples: [{ b: 2 }, { b: 3 }] }, false); + mergeExamples( + flat, + { examples: [{ b: 2 }, { b: 3 }] }, + { isOptional: false }, + ); expect(flat.examples).toEqual([ { a: 1, b: 2 }, { a: 1, b: 3 }, ]); }); + + test("should apply limit to combinations when required", () => { + const flat = { + type: "object" as const, + properties: {}, + examples: [{ a: 1 }], + }; + mergeExamples( + flat, + { examples: [{ b: 2 }, { b: 3 }] }, + { isOptional: false, maxCombinations: 1 }, + ); + expect(flat.examples).toHaveLength(1); + }); }); describe("pullRequestExamples()", () => { @@ -281,6 +299,21 @@ describe("JSON Schema helpers", () => { { name: "jane", age: 30 }, ]); }); + + test("should respect the given limit", () => { + expect( + pullRequestExamples( + { + type: "object", + properties: { + name: { type: "string", examples: ["john", "jane"] }, + age: { type: "number", examples: [25, 30] }, + }, + }, + 2, + ), + ).toHaveLength(2); + }); }); describe("flattenIO()", () => { diff --git a/express-zod-api/tests/logical-container.spec.ts b/express-zod-api/tests/logical-container.spec.ts index bddcdbf8ac..5f4854312d 100644 --- a/express-zod-api/tests/logical-container.spec.ts +++ b/express-zod-api/tests/logical-container.spec.ts @@ -196,5 +196,17 @@ describe("LogicalContainer", () => { processContainers([{ type: "bearer", format: "JWT" }, { and: [] }]), ).toEqual([[{ type: "bearer", format: "JWT" }]]); }); + + test("should control the maximum combinations", () => { + expect( + processContainers( + [ + { or: [{ and: [1, 2] }, { and: [3, 4] }] }, + { or: [{ and: [5, 6] }, { and: [7, 8] }] }, + ], + 2, + ), + ).toHaveLength(2); + }); }); }); diff --git a/express-zod-api/tests/result-helpers.spec.ts b/express-zod-api/tests/result-helpers.spec.ts index 50526c067d..b78028f52f 100644 --- a/express-zod-api/tests/result-helpers.spec.ts +++ b/express-zod-api/tests/result-helpers.spec.ts @@ -110,6 +110,15 @@ describe("Result helpers", () => { { a: "three", b: 2, c: false }, ]); }); + + test("respects the given limit", () => { + const schema = z.object({ + a: z.string().meta({ examples: ["one", "two", "three"] }), + b: z.number().meta({ examples: [1, 2] }), + c: z.boolean().meta({ examples: [false] }), + }); + expect(pullResponseExamples(schema, 3)).toHaveLength(3); + }); }); describe.each(["development", "production"])( diff --git a/express-zod-api/tests/security.spec.ts b/express-zod-api/tests/security.spec.ts new file mode 100644 index 0000000000..6a8244a6ff --- /dev/null +++ b/express-zod-api/tests/security.spec.ts @@ -0,0 +1,52 @@ +import { getSecurityHeaders } from "../src/security"; + +describe("getSecurityHeaders()", () => { + test("should extract header names and ignore others", () => { + expect( + Array.from( + getSecurityHeaders([ + { type: "header", name: "Auth" }, + { type: "cookie", name: "session" }, + { type: "bearer" }, + { type: "header", name: "Key" }, + { type: "openid", url: "https://auth.example.com" }, + ]), + ), + ).toEqual(["Auth", "Key"]); + }); + + test("should handle empty array", () => { + expect(getSecurityHeaders([])).toHaveProperty("size", 0); + }); + + test.each([ + { + and: [ + { type: "header" as const, name: "A" }, + { type: "bearer" as const }, + ], + }, + { + or: [{ type: "header" as const, name: "A" }, { type: "bearer" as const }], + }, + ])("should extract headers from AND/OR %#", (container) => { + expect(Array.from(getSecurityHeaders([container]))).toEqual(["A"]); + }); + + test.each([ + { + and: [ + { type: "header" as const, name: "A1" }, + { or: [{ type: "header" as const, name: "A2" }] }, + ], + }, + { + or: [ + { type: "header" as const, name: "A1" }, + { and: [{ type: "header" as const, name: "A2" }] }, + ], + }, + ])("should extract headers from nested AND/OR", (container) => { + expect(Array.from(getSecurityHeaders([container]))).toEqual(["A1", "A2"]); + }); +}); From e4f044145852a9c77758cf98b74c95d305be4784 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 10:47:34 +0200 Subject: [PATCH 02/19] fix(combinations): also limit the concatenation result. --- express-zod-api/src/common-helpers.ts | 4 ++-- express-zod-api/tests/common-helpers.spec.ts | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 29adc59f96..36eedf4263 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -115,10 +115,10 @@ export const combinations = ( right: T[], /** @desc The function that combines elements */ merge: (a: T, b: T) => T, - /** @desc Maximum number of combinations (only applies to Cartesian product of non-empty arrays) */ + /** @desc Maximum number of combinations */ limit = Infinity, ): T[] => { - if (!left.length || !right.length) return left.concat(right); + if (!left.length || !right.length) return left.concat(right).slice(0, limit); const result: T[] = []; for (let idxL = 0; idxL < left.length && result.length < limit; idxL++) { for (let idxR = 0; idxR < right.length && result.length < limit; idxR++) diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 16ec43404e..89ddb78550 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -281,16 +281,11 @@ describe("Common Helpers", () => { expect(combinations([], [], (a, b) => a + b)).toEqual([]); }); - test("should not apply the limit when returns original array", () => { - expect(combinations([1, 2, 3], [], (a, b) => a + b, 1)).toEqual([ - 1, 2, 3, - ]); - }); - test("should limit the number of combinations", () => { expect(combinations([1, 2], [4, 5, 6], (a, b) => a + b, 4)).toEqual([ 5, 6, 7, 6, ]); + expect(combinations([1, 2, 3], [], (a, b) => a + b, 1)).toEqual([1]); }); test.each([0, -1, NaN])("should return empty for limit=%s", (limit) => { From 83e0642ae45cfb30c371fedf79d77338f9b09c25 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 11:06:13 +0200 Subject: [PATCH 03/19] fix(combinations): early exit for non-positive limit. --- express-zod-api/src/common-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 36eedf4263..18c659a59f 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -118,6 +118,7 @@ export const combinations = ( /** @desc Maximum number of combinations */ limit = Infinity, ): T[] => { + if (!(limit > 0)) return []; if (!left.length || !right.length) return left.concat(right).slice(0, limit); const result: T[] = []; for (let idxL = 0; idxL < left.length && result.length < limit; idxL++) { From 0e55963ede4f99572ed32c30f39adcdc49683b08 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 11:55:32 +0200 Subject: [PATCH 04/19] mv(mergeExamples): renaming maxCombinations to limit and applying it to concatenations as well. --- express-zod-api/src/json-schema-helpers.ts | 15 ++++++++------- .../tests/json-schema-helpers.spec.ts | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 3ceaea9903..4b9db27e32 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -84,20 +84,21 @@ export const processPropertyNames = ( export const mergeExamples = ( target: FlattenObjectSchema, entry: z.core.JSONSchema.BaseSchema, - { - isOptional, - maxCombinations, - }: { isOptional: boolean; maxCombinations?: number }, + { isOptional, limit = Infinity }: { isOptional: boolean; limit?: number }, ) => { if (!entry.examples?.length) return; if (isOptional) { - target.examples = R.concat(target.examples || [], entry.examples); + if (!(limit > 0)) return; + target.examples = R.concat(target.examples || [], entry.examples).slice( + 0, + limit, + ); } else { target.examples = combinations( target.examples?.filter(isObject) || [], entry.examples.filter(isObject), R.mergeDeepRight, - maxCombinations, + limit, ); } }; @@ -121,7 +122,7 @@ export const flattenIO = ( if (entry.description) flat.description ??= entry.description; stack.push(...processAllOf(entry, { isStrict, isOptional })); stack.push(...processVariants(entry)); - mergeExamples(flat, entry, { isOptional, maxCombinations }); + mergeExamples(flat, entry, { isOptional, limit: maxCombinations }); if (!isJsonObjectSchema(entry)) continue; stack.push([ isOptional, diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index 4cbbf922fa..1dc1332daa 100644 --- a/express-zod-api/tests/json-schema-helpers.spec.ts +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -252,7 +252,21 @@ describe("JSON Schema helpers", () => { mergeExamples( flat, { examples: [{ b: 2 }, { b: 3 }] }, - { isOptional: false, maxCombinations: 1 }, + { isOptional: false, limit: 1 }, + ); + expect(flat.examples).toHaveLength(1); + }); + + test("should apply limit to concatenations when optional", () => { + const flat = { + type: "object" as const, + properties: {}, + examples: [{ a: 1 }], + }; + mergeExamples( + flat, + { examples: [{ b: 2 }, { c: 3 }] }, + { isOptional: true, limit: 1 }, ); expect(flat.examples).toHaveLength(1); }); From ac657e0aa91a61ede5f658fd8e8183ee330a1466 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 11:58:06 +0200 Subject: [PATCH 05/19] mv(flattenIO): renaming option to maxExamples. --- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation-helpers.ts | 9 ++++++--- express-zod-api/src/json-schema-helpers.ts | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index a134f7c68d..061a0b95a4 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -76,7 +76,7 @@ export class Diagnostics { unrepresentable: "any", io: "input", }), - { maxCombinations: 0 }, // not required for this check + { maxExamples: 0 }, // not required for this check ); for (const param of params) { if (param in ref.flat.properties) continue; diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 4cd7f8fb0b..7630d53fda 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -129,7 +129,10 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { export const depictIntersection = R.tryCatch( ({ jsonSchema }, { maxCombinations }) => { if (!jsonSchema.allOf) throw "no allOf"; - return flattenIO(jsonSchema, { isStrict: true, maxCombinations }); + return flattenIO(jsonSchema, { + isStrict: true, + maxExamples: maxCombinations, + }); }, (_err, { jsonSchema }) => jsonSchema, ); @@ -293,7 +296,7 @@ export const depictRequestParams = ({ isHeader?: IsHeader; securityHeaders?: Set; }) => { - const flat = flattenIO(request, { maxCombinations }); + const flat = flattenIO(request, { maxExamples: maxCombinations }); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); @@ -636,7 +639,7 @@ export const depictBody = ({ examples: enumerateExamples( examples.length ? examples - : flattenIO(request, { maxCombinations }) + : flattenIO(request, { maxExamples: maxCombinations }) .examples?.filter( (one): one is FlatObject => isObject(one) && !Array.isArray(one), ) diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 4b9db27e32..1c6387f791 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -107,11 +107,11 @@ export const flattenIO = ( jsonSchema: z.core.JSONSchema.BaseSchema, { isStrict = false, - maxCombinations, + maxExamples, }: { /** @default false */ isStrict?: boolean; - maxCombinations?: number; + maxExamples?: number; } = {}, ) => { const stack: Stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] @@ -122,11 +122,11 @@ export const flattenIO = ( if (entry.description) flat.description ??= entry.description; stack.push(...processAllOf(entry, { isStrict, isOptional })); stack.push(...processVariants(entry)); - mergeExamples(flat, entry, { isOptional, limit: maxCombinations }); + mergeExamples(flat, entry, { isOptional, limit: maxExamples }); if (!isJsonObjectSchema(entry)) continue; stack.push([ isOptional, - { examples: pullRequestExamples(entry, maxCombinations) }, + { examples: pullRequestExamples(entry, maxExamples) }, ]); if (entry.properties) { flat.properties = (isStrict ? propsMerger : R.mergeDeepRight)( From 07c81c0945491aa5df509c63e27c1d451f5845fe Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 12:00:57 +0200 Subject: [PATCH 06/19] mv(getResponses): renaming parametere to maxExamples. --- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation.ts | 2 +- express-zod-api/src/endpoint.ts | 6 +++--- express-zod-api/src/integration.ts | 2 +- express-zod-api/tests/endpoint.spec.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 061a0b95a4..35e4335756 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -47,7 +47,7 @@ export class Diagnostics { } } for (const variant of responseVariants) { - const responses = endpoint.getResponses(variant, { maxCombinations: 0 }); // no examples + const responses = endpoint.getResponses(variant, { maxExamples: 0 }); for (const { mimeTypes, schema } of responses) { if (!mimeTypes?.includes(contentTypes.json)) continue; const reason = findJsonIncompatible(schema, "output"); diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index a2c07d16c5..b9b8294768 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -217,7 +217,7 @@ export class Documentation extends OpenApiBuilder { const responses: ResponsesObject = {}; for (const variant of responseVariants) { const apiResponses = endpoint.getResponses(variant, { - maxCombinations, + maxExamples: maxCombinations, }); for (const { mimeTypes, schema, statusCodes } of apiResponses) { for (const statusCode of statusCodes) { diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index 9b59721d52..7b300bc027 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -57,7 +57,7 @@ export abstract class AbstractEndpoint { /** @internal */ public abstract getResponses( variant: ResponseVariant, - params: { maxCombinations?: number }, + params: { maxExamples?: number }, ): ReadonlyArray; /** @internal */ public abstract getOperationId(method: ClientMethod): string | undefined; @@ -175,9 +175,9 @@ export class Endpoint< /** @internal */ public override getResponses( variant: ResponseVariant, - { maxCombinations }: { maxCombinations?: number }, + { maxExamples }: { maxExamples?: number }, ) { - if (variant === "positive") this.#ensureOutputExamples(maxCombinations); + if (variant === "positive") this.#ensureOutputExamples(maxExamples); return Object.freeze( variant === "negative" ? this.#def.resultHandler.getNegativeResponse() diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index e9b1599a11..5d39103504 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -108,7 +108,7 @@ export class Integration extends IntegrationBase { const dictionaries = responseVariants.reduce( (agg, responseVariant) => { const responses = endpoint.getResponses(responseVariant, { - maxCombinations: 0, // not using examples yet + maxExamples: 0, // not using examples yet }); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { const hasContent = shouldHaveContent(method, mimeTypes); diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index b6d312f91e..2d0e16210f 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -283,7 +283,7 @@ describe("Endpoint", () => { handler: vi.fn(), }); const responses = endpoint.getResponses(variant, { - maxCombinations: 0, + maxExamples: 0, }); expect(responses).toMatchSnapshot(); expect(() => (responses as any[]).push()).toThrowError(/read only/); From 9003613a00ae031a0b5bf5688d4aa9ad6ee1081f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 13:58:00 +0200 Subject: [PATCH 07/19] mv: making Documentation param limits with nestes examples and security. --- express-zod-api/src/documentation-helpers.ts | 25 +++++++-------- express-zod-api/src/documentation.ts | 32 +++++++++++--------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 7630d53fda..bf76bf6666 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -55,7 +55,7 @@ interface ReqResCommons { ) => ReferenceObject; path: string; method: ClientMethod; - maxCombinations?: number; + maxExamples?: number; } export interface OpenAPIContext extends ReqResCommons { @@ -127,12 +127,9 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { }; export const depictIntersection = R.tryCatch( - ({ jsonSchema }, { maxCombinations }) => { + ({ jsonSchema }, { maxExamples }) => { if (!jsonSchema.allOf) throw "no allOf"; - return flattenIO(jsonSchema, { - isStrict: true, - maxExamples: maxCombinations, - }); + return flattenIO(jsonSchema, { isStrict: true, maxExamples }); }, (_err, { jsonSchema }) => jsonSchema, ); @@ -286,7 +283,7 @@ export const depictRequestParams = ({ composition, isHeader, securityHeaders, - maxCombinations, + maxExamples, description = `${method.toUpperCase()} ${path} Parameter`, }: ReqResCommons & { composition: "inline" | "components"; @@ -296,7 +293,7 @@ export const depictRequestParams = ({ isHeader?: IsHeader; securityHeaders?: Set; }) => { - const flat = flattenIO(request, { maxExamples: maxCombinations }); + const flat = flattenIO(request, { maxExamples }); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); @@ -459,7 +456,7 @@ export const depictResponse = ({ hasMultipleStatusCodes, statusCode, brandHandling, - maxCombinations, + maxExamples, description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), @@ -477,7 +474,7 @@ export const depictResponse = ({ const response = asOAS( depict(schema, { rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: true, makeRef, path, method, maxCombinations }, + ctx: { isResponse: true, makeRef, path, method, maxExamples }, }), ); const examples = []; @@ -593,14 +590,14 @@ export const depictRequest = ({ makeRef, path, method, - maxCombinations, + maxExamples, }: ReqResCommons & { schema: IOSchema; brandHandling?: BrandHandling; }) => depict(schema, { rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method, maxCombinations }, + ctx: { isResponse: false, makeRef, path, method, maxExamples }, }); export const depictBody = ({ @@ -612,7 +609,7 @@ export const depictBody = ({ makeRef, composition, paramNames, - maxCombinations, + maxExamples, description = `${method.toUpperCase()} ${path} Request body`, }: ReqResCommons & { schema: IOSchema; @@ -639,7 +636,7 @@ export const depictBody = ({ examples: enumerateExamples( examples.length ? examples - : flattenIO(request, { maxExamples: maxCombinations }) + : flattenIO(request, { maxExamples }) .examples?.filter( (one): one is FlatObject => isObject(one) && !Array.isArray(one), ) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index b9b8294768..f8ba48ec78 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -87,14 +87,20 @@ interface DocumentationParams { * @example { users: "About users", files: { description: "About files", url: "https://example.com" } } * */ tags?: Parameters[0]; - /** - * @desc Limits cartesian product when generating examples by combining each property's own examples. - * @desc Applies to: request/response examples, security scheme alternatives. - * @example 0 — disables product combinations, keeps concatenations - * @default Infinity - * @todo set to 10 or 20 in v28 to avoid too many combinations - * */ - maxCombinations?: number; + limits?: { + /** + * @desc Limits the number of examples + * @default Infinity + * @todo set to 10 or 20 in v28 to avoid too many combinations + * */ + examples?: number; + /** + * @desc Limits the number of security scheme combinations + * @default Infinity + * @todo set to 10 or 20 in v28 to avoid too many combinations + * */ + security?: number; + }; } export class Documentation extends OpenApiBuilder { @@ -167,10 +173,10 @@ export class Documentation extends OpenApiBuilder { brandHandling, tags, isHeader, - maxCombinations, hasSummaryFromDescription = true, hasHeadMethod = true, composition = "inline", + limits: { examples: maxExamples, security: maxSecurity } = {}, }: DocumentationParams) { super(); this.addInfo({ title, version }); @@ -183,7 +189,7 @@ export class Documentation extends OpenApiBuilder { endpoint, composition, brandHandling, - maxCombinations, + maxExamples, makeRef: this.#makeRef.bind(this), }; const { description, shortDescription, scopes, inputSchema } = endpoint; @@ -216,9 +222,7 @@ export class Documentation extends OpenApiBuilder { const responses: ResponsesObject = {}; for (const variant of responseVariants) { - const apiResponses = endpoint.getResponses(variant, { - maxExamples: maxCombinations, - }); + const apiResponses = endpoint.getResponses(variant, { maxExamples }); for (const { mimeTypes, schema, statusCodes } of apiResponses) { for (const statusCode of statusCodes) { responses[statusCode] = depictResponse({ @@ -257,7 +261,7 @@ export class Documentation extends OpenApiBuilder { const securityRefs = depictSecurityRefs( depictSecurity( - processContainers(endpoint.security, maxCombinations), + processContainers(endpoint.security, maxSecurity), inputSources, ), scopes, From 13f4df43da81f50ad18a15f8e25776c4410384b6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 14:07:00 +0200 Subject: [PATCH 08/19] ref: extracting defaultMaxCombinations. --- express-zod-api/src/common-helpers.ts | 5 ++++- express-zod-api/src/documentation.ts | 8 ++++---- express-zod-api/src/json-schema-helpers.ts | 12 ++++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 18c659a59f..3e89381a51 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -109,6 +109,9 @@ export const isSchema = ( "_zod" in subject && (type ? R.path(["_zod", "def", "type"], subject) === type : true); +/** @todo set to 20 in v28 to avoid too many combinations */ +export const defaultMaxCombinations = Infinity; + /** Configurable replacement for R.xprod(), but it also handles empty arrays */ export const combinations = ( left: T[], @@ -116,7 +119,7 @@ export const combinations = ( /** @desc The function that combines elements */ merge: (a: T, b: T) => T, /** @desc Maximum number of combinations */ - limit = Infinity, + limit = defaultMaxCombinations, ): T[] => { if (!(limit > 0)) return []; if (!left.length || !right.length) return left.concat(right).slice(0, limit); diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index f8ba48ec78..eb079cd9b9 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -90,14 +90,14 @@ interface DocumentationParams { limits?: { /** * @desc Limits the number of examples - * @default Infinity - * @todo set to 10 or 20 in v28 to avoid too many combinations + * @default defaultMaxCombinations + * @see defaultMaxCombinations * */ examples?: number; /** * @desc Limits the number of security scheme combinations - * @default Infinity - * @todo set to 10 or 20 in v28 to avoid too many combinations + * @default defaultMaxCombinations + * @see defaultMaxCombinations * */ security?: number; }; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 1c6387f791..62950656db 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -1,5 +1,10 @@ import * as R from "ramda"; -import { combinations, FlatObject, isObject } from "./common-helpers"; +import { + combinations, + defaultMaxCombinations, + FlatObject, + isObject, +} from "./common-helpers"; import type { z } from "zod"; import type { SchemaObject } from "openapi3-ts/oas31"; @@ -84,7 +89,10 @@ export const processPropertyNames = ( export const mergeExamples = ( target: FlattenObjectSchema, entry: z.core.JSONSchema.BaseSchema, - { isOptional, limit = Infinity }: { isOptional: boolean; limit?: number }, + { + isOptional, + limit = defaultMaxCombinations, + }: { isOptional: boolean; limit?: number }, ) => { if (!entry.examples?.length) return; if (isOptional) { From a81e031c072754396124bdeb7831e53c87c7f813 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 14:09:04 +0200 Subject: [PATCH 09/19] ref: early return for processContainers. --- express-zod-api/src/logical-container.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/logical-container.ts b/express-zod-api/src/logical-container.ts index 782b5de667..6a6c70e68a 100644 --- a/express-zod-api/src/logical-container.ts +++ b/express-zod-api/src/logical-container.ts @@ -1,5 +1,9 @@ import * as R from "ramda"; -import { combinations, isObject } from "./common-helpers"; +import { + combinations, + defaultMaxCombinations, + isObject, +} from "./common-helpers"; type LogicalOr = { or: T[] }; type LogicalAnd = { and: T[] }; @@ -27,8 +31,9 @@ export type Alternatives = Array>; export const processContainers = ( containers: LogicalContainer[], - maxCombinations?: number, + maxCombinations = defaultMaxCombinations, ): Alternatives => { + if (!(maxCombinations > 0)) return []; const simples = R.filter(isSimple, containers); const ands = R.chain(R.prop("and"), R.filter(isLogicalAnd, containers)); const [simpleAnds, orsInAnds] = R.partition(isSimple, ands); From 72fdf78789ef018e52d8933d68eeceff542ce567 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 14:11:05 +0200 Subject: [PATCH 10/19] ref: defaults and early returns for pulling helpers. --- express-zod-api/src/json-schema-helpers.ts | 8 +++++--- express-zod-api/src/result-helpers.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 62950656db..ff1027b9ea 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -152,12 +152,14 @@ export const flattenIO = ( /** @see pullResponseExamples */ export const pullRequestExamples = ( subject: z.core.JSONSchema.ObjectSchema, - limit?: number, -) => - Object.entries(subject.properties || {}).reduce( + limit = defaultMaxCombinations, +) => { + if (!(limit > 0)) return []; + return Object.entries(subject.properties || {}).reduce( (acc, [key, prop]) => { const { examples = [] } = isObject(prop) ? prop : {}; return combinations(acc, examples.map(R.objOf(key)), R.mergeRight, limit); }, [], ); +}; diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index 04406b2fe2..db124e1f6c 100644 --- a/express-zod-api/src/result-helpers.ts +++ b/express-zod-api/src/result-helpers.ts @@ -5,6 +5,7 @@ import { globalRegistry, z } from "zod"; import { NormalizedResponse, ResponseVariant } from "./api-response"; import { combinations, + defaultMaxCombinations, FlatObject, getMessageFromError, isProduction, @@ -90,12 +91,14 @@ export const getPublicErrorMessage = (error: HttpError): string => /** @see pullRequestExamples */ export const pullResponseExamples = ( subject: T, - limit?: number, -) => - Object.entries(subject._zod.def.shape).reduce( + limit = defaultMaxCombinations, +) => { + if (!(limit > 0)) return []; + return Object.entries(subject._zod.def.shape).reduce( (acc, [key, schema]) => { const { examples = [] } = globalRegistry.get(schema) || {}; return combinations(acc, examples.map(R.objOf(key)), R.mergeRight, limit); }, [], ); +}; From a9eacbbfa9b098e626cb21188364daf496e0cf39 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 14:22:52 +0200 Subject: [PATCH 11/19] fix: only run Endpoint::ensureOutputExamples() when maxExamples is positive, now also with default. --- express-zod-api/src/endpoint.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index 7b300bc027..4a1e925db5 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -9,6 +9,7 @@ import { getInput, ensureError, isSchema, + defaultMaxCombinations, } from "./common-helpers"; import { CommonConfig } from "./config-type"; import { @@ -91,7 +92,7 @@ export class Endpoint< readonly #def: ConstructorParameters>[0]; /** considered expensive operation, only required for generators */ - #ensureOutputExamples(limit?: number) { + #ensureOutputExamples(limit: number) { if (globalRegistry.get(this.#def.outputSchema)?.examples?.length) return; // examples on output schema, or pull up: if (!isSchema(this.#def.outputSchema, "object")) return; const examples = pullResponseExamples(this.#def.outputSchema, limit); @@ -175,9 +176,10 @@ export class Endpoint< /** @internal */ public override getResponses( variant: ResponseVariant, - { maxExamples }: { maxExamples?: number }, + { maxExamples = defaultMaxCombinations }: { maxExamples?: number }, ) { - if (variant === "positive") this.#ensureOutputExamples(maxExamples); + if (variant === "positive" && maxExamples > 0) + this.#ensureOutputExamples(maxExamples); return Object.freeze( variant === "negative" ? this.#def.resultHandler.getNegativeResponse() From 4eb589191e94122f276b0820feabb233e4dd203d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 16:36:40 +0200 Subject: [PATCH 12/19] fix(processContainers): prohibit zero security schemas. --- express-zod-api/src/documentation.ts | 4 ++-- express-zod-api/src/logical-container.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index eb079cd9b9..dd796e9ffb 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -95,9 +95,9 @@ interface DocumentationParams { * */ examples?: number; /** - * @desc Limits the number of security scheme combinations + * @desc Limits the number of security schemas combinations. Must be at least 1. * @default defaultMaxCombinations - * @see defaultMaxCombinations + * @see Middleware * */ security?: number; }; diff --git a/express-zod-api/src/logical-container.ts b/express-zod-api/src/logical-container.ts index 6a6c70e68a..1b965ec5cf 100644 --- a/express-zod-api/src/logical-container.ts +++ b/express-zod-api/src/logical-container.ts @@ -33,7 +33,6 @@ export const processContainers = ( containers: LogicalContainer[], maxCombinations = defaultMaxCombinations, ): Alternatives => { - if (!(maxCombinations > 0)) return []; const simples = R.filter(isSimple, containers); const ands = R.chain(R.prop("and"), R.filter(isLogicalAnd, containers)); const [simpleAnds, orsInAnds] = R.partition(isSimple, ands); @@ -46,7 +45,7 @@ export const processContainers = ( acc, R.map((opt) => (isSimple(opt) ? [opt] : opt.and), entry), R.concat, - maxCombinations, + Math.max(maxCombinations || 0, 1), ), R.reject(R.isEmpty, [persistent]), ); From 330ccb368c29f3d8c6653943cca2747c110dbf5c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 17:45:09 +0200 Subject: [PATCH 13/19] fix: replacing early return with a non-negative clamp on slicer. --- express-zod-api/src/json-schema-helpers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index ff1027b9ea..646a738559 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -96,10 +96,9 @@ export const mergeExamples = ( ) => { if (!entry.examples?.length) return; if (isOptional) { - if (!(limit > 0)) return; target.examples = R.concat(target.examples || [], entry.examples).slice( 0, - limit, + Math.max(0, limit || 0), ); } else { target.examples = combinations( From 9941ce4993df9350fed6b520fc5bb4fc03266306 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 20:47:03 +0200 Subject: [PATCH 14/19] fix(test): the limit test for Endpoint::getResponses(). --- express-zod-api/tests/endpoint.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index 2d0e16210f..1b3c3374e4 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -289,6 +289,25 @@ describe("Endpoint", () => { expect(() => (responses as any[]).push()).toThrowError(/read only/); }, ); + + test.each([0, -1, 1, 2, NaN])( + "should respect the limit=%s of examples for positive variant", + (maxExamples) => { + const factory = new EndpointsFactory(defaultResultHandler); + const endpoint = factory.build({ + output: z.object({ + something: z.number().meta({ examples: [1, 2] }), + another: z.boolean().meta({ examples: [true, false] }), + }), + handler: vi.fn(), + }); + const responses = endpoint.getResponses("positive", { maxExamples }); + expect(responses).toHaveLength(1); + expect(responses[0].schema.meta()?.examples ?? []).toHaveLength( + maxExamples > 0 ? maxExamples : 0, + ); + }, + ); }); describe(".scopes", () => { From 4ff2d0cf14277de448f2fd506c3dae328e68b610 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 21:01:47 +0200 Subject: [PATCH 15/19] fix(test): more cases for limit of processContainers. --- .../tests/logical-container.spec.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/express-zod-api/tests/logical-container.spec.ts b/express-zod-api/tests/logical-container.spec.ts index 5f4854312d..1587ebac19 100644 --- a/express-zod-api/tests/logical-container.spec.ts +++ b/express-zod-api/tests/logical-container.spec.ts @@ -197,16 +197,19 @@ describe("LogicalContainer", () => { ).toEqual([[{ type: "bearer", format: "JWT" }]]); }); - test("should control the maximum combinations", () => { - expect( - processContainers( - [ - { or: [{ and: [1, 2] }, { and: [3, 4] }] }, - { or: [{ and: [5, 6] }, { and: [7, 8] }] }, - ], - 2, - ), - ).toHaveLength(2); - }); + test.each([0, -1, 1, 2, NaN])( + "should limit the maxCombinations=%s to at least 1", + (limit) => { + expect( + processContainers( + [ + { or: [{ and: [1, 2] }, { and: [3, 4] }] }, + { or: [{ and: [5, 6] }, { and: [7, 8] }] }, + ], + limit, + ), + ).toHaveLength(limit > 1 ? limit : 1); + }, + ); }); }); From 57ca58f2c2b751c608ebbf7ceb106d87ad6bf8a1 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 20:14:17 +0000 Subject: [PATCH 16/19] docs(changelog): rewrite v27.4.0 entry for limits option --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b16d29c8d..db4ae7e91b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,10 @@ ### v27.4.0 -- Introduced `maxCombinations` setting for Documentation generator: - - Limits cartesian product when generating examples by combining each property's own examples; - - Set to `0` to disable product but keep concatenations; - - Default is `Infinity`, but may change to reasonable number in v28 to avoid too many combinations; +- Introduced `limits` option for Documentation generator with two nested settings: + - `examples` — caps the cartesian product when combining each property's own examples; + - `security` — caps the cartesian product of security schemas combinations (must be at least 1); + - Both default to `Infinity`, but will be changed to a reasonable number in v28 to avoid too many combinations; - Example: 6 props with 2 examples each → cartesian product makes 2^6 = 64 request examples: ```ts @@ -19,7 +19,7 @@ const schema = z.object({ active: z.boolean().example(true).example(false), tags: z.array(z.string()).example(["vip"]).example(["new", "promo"]), }); -// First 5: +// First 5 of 64 cartesian combinations: // { id: 1, name: "john", age: 18, role: "user", active: true, tags: ["vip"] }, // { id: 1, name: "john", age: 18, role: "user", active: true, tags: ["new", "promo"] }, // { id: 1, name: "john", age: 18, role: "user", active: false, tags: ["vip"] }, From 116e65a880e5471018ed51fa8fc676dc33cae63a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 6 May 2026 22:23:15 +0200 Subject: [PATCH 17/19] add jsdoc to limits prop. --- express-zod-api/src/documentation.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index dd796e9ffb..5bf80f5e89 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -87,15 +87,20 @@ interface DocumentationParams { * @example { users: "About users", files: { description: "About files", url: "https://example.com" } } * */ tags?: Parameters[0]; + /** + * `@desc` Caps on the number of generated entities. + * `@default` { examples: defaultMaxCombinations, security: defaultMaxCombinations } + * `@example` { examples: 20, security: 10 } + * */ limits?: { /** - * @desc Limits the number of examples + * @desc Caps the number of generated examples (request/response examples from examples of individual properties). * @default defaultMaxCombinations * @see defaultMaxCombinations * */ examples?: number; /** - * @desc Limits the number of security schemas combinations. Must be at least 1. + * @desc Caps the number of security schemas combinations. Must be at least 1. * @default defaultMaxCombinations * @see Middleware * */ From 4fd3b23579dd39b88fb7ffb0a20d3f29f34290a0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 7 May 2026 05:16:32 +0200 Subject: [PATCH 18/19] fix(jsdoc): Documentation. --- express-zod-api/src/documentation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 5bf80f5e89..d786bf37c3 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -88,9 +88,9 @@ interface DocumentationParams { * */ tags?: Parameters[0]; /** - * `@desc` Caps on the number of generated entities. - * `@default` { examples: defaultMaxCombinations, security: defaultMaxCombinations } - * `@example` { examples: 20, security: 10 } + * @desc Caps on the number of generated entities. + * @default { examples: defaultMaxCombinations, security: defaultMaxCombinations } + * @example { examples: 20, security: 10 } * */ limits?: { /** From 4b4847b4361721f5c6f9d9258cdbaf6a044ff741 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 7 May 2026 08:38:38 +0200 Subject: [PATCH 19/19] todo for security limit. --- express-zod-api/src/documentation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index d786bf37c3..762b211b76 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -102,6 +102,7 @@ interface DocumentationParams { /** * @desc Caps the number of security schemas combinations. Must be at least 1. * @default defaultMaxCombinations + * @todo decouple from defaultMaxCombinations, use higher but still fixed limit in v28 * @see Middleware * */ security?: number;