Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
25b4bed
feat(combinations): optional limit arg and implementation.
RobinTail Apr 24, 2026
ac7d133
rm redundant pair, add test for limit.
RobinTail Apr 24, 2026
120fa0d
jsdoc.
RobinTail Apr 24, 2026
3972cb2
test that limit does not apply to empty arrays case.
RobinTail Apr 24, 2026
a0adbb9
rm wrapping fn in mergeExamples.
RobinTail Apr 24, 2026
21f8b8b
rm fn wrapper in processContainers.
RobinTail Apr 24, 2026
7e08495
fix: rm fn wrappers from pullResponseExamples and pullResponseExamples.
RobinTail Apr 24, 2026
d07cefb
feat(processContainers): Add maxCombinations optional argument.
RobinTail Apr 24, 2026
6cf2449
feat: maxCombinations in CommonConfig with usage by Documentation in …
RobinTail Apr 24, 2026
30d4e2c
Merge branch 'master' into limit-combinations
RobinTail Apr 24, 2026
9b05848
feat: limit option for pullResponseExamples.
RobinTail Apr 24, 2026
1bef48a
add limit option to Endpoint::ensureOutputExamples().
RobinTail Apr 24, 2026
5acceb9
Add config argument to Endpoint::getResponses().
RobinTail Apr 24, 2026
b686082
add limit option to pullRequestExamples.
RobinTail Apr 24, 2026
b55f73e
Add maxCombinations option to flattenIO and OpenAPI context.
RobinTail Apr 24, 2026
e95c5b5
reusing maxCombinations by mergeExamples helper of flattenIO.
RobinTail Apr 24, 2026
f40c9a5
fix: disabling combinations for Integration.
RobinTail Apr 24, 2026
568ee1c
fix: moving maxCombinations from CommonConfig to DocumentationParams.
RobinTail Apr 24, 2026
81f1b76
jsdoc.
RobinTail Apr 24, 2026
ce3303d
add missing forwarding of maxCombinations into OpenAPIContext.
RobinTail Apr 24, 2026
3371815
fix: rm R.once() from Endpoint::ensureOutputExamples since it's alrea…
RobinTail Apr 24, 2026
35a75f1
fix: applying the limit to single arrays and handling edge cases.
RobinTail Apr 24, 2026
876e45d
Add jsdoc example for maxCombinations in Documentation.
RobinTail Apr 24, 2026
5b0ae8d
jsdoc clarification for combinations().
RobinTail Apr 24, 2026
8d305ad
fix(diagnostics): readability.
RobinTail Apr 24, 2026
aa645d9
fix(flattenIO): renaming MergeMode to isStrict.
RobinTail Apr 24, 2026
9527763
fix(processAllOf): named options.
RobinTail Apr 24, 2026
60ee257
fix(processContainers): early exit for zero limit.
RobinTail Apr 24, 2026
60cbf12
fix(combinations): shorter condition for NaN.
RobinTail Apr 24, 2026
87aea97
fix(mergeExamples): simmetrical limit and default value.
RobinTail Apr 24, 2026
497ad4f
fix(mergeExamples): early exit for zero limit.
RobinTail Apr 24, 2026
951a11d
tests for edge cases of processContainers and mergeExamples.
RobinTail Apr 24, 2026
3a02b85
fix: rm redundant coercion.
RobinTail Apr 24, 2026
3ff885f
Early exits for pullRequestExamples and pullResponseExamples.
RobinTail Apr 25, 2026
f70c318
REF: take headers from endpoint.security instead of security scheme c…
RobinTail Apr 25, 2026
204ac1a
REV: rm early exists, only applying this option to combinations.
RobinTail Apr 25, 2026
22f6563
fix(test): rm redundant placeholder
RobinTail Apr 25, 2026
18e622d
REF: pickHeaders rearrangement, simple case goes last, fixes coverage.
RobinTail Apr 25, 2026
c969f22
Changelog: proposed for 27.3.0.
RobinTail Apr 25, 2026
1101b8b
Merge branch 'master' into limit-combinations
RobinTail Apr 25, 2026
bac11b9
mv getSecurityHeaders into security.ts.
RobinTail Apr 25, 2026
eb4f21c
tests for getSecurityHeaders.
RobinTail Apr 25, 2026
01d7d38
Merge branch 'master' into limit-combinations
RobinTail Apr 26, 2026
61ef57a
Merge branch 'master' into limit-combinations
RobinTail Apr 29, 2026
7b0fb4e
Merge branch 'master' into limit-combinations
RobinTail Apr 29, 2026
540716d
fix: rm default value from higher order functions calling combination…
RobinTail Apr 29, 2026
415e0b8
fix(test): simpler assertion for the limit test of pullRequestExamples.
RobinTail Apr 29, 2026
ba2f6dd
fix(test): simpler length assertion for the limit test of mergeExamples.
RobinTail Apr 29, 2026
9251a67
Merge branch 'master' into limit-combinations
RobinTail May 3, 2026
1606f02
Merge branch 'master' into limit-combinations
RobinTail May 5, 2026
2973102
Merge branch 'master' into limit-combinations
RobinTail May 6, 2026
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions express-zod-api/src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,23 @@ export const isSchema = <T extends z.core.$ZodType = z.core.$ZodType>(
"_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 = <T>(
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-array short-circuit intentionally ignores limit, which is fine as a local contract for combinations but leaks surprising behavior into every caller that reduces over a list (pullRequestExamples, pullResponseExamples, mergeExamples via flattenIO, processContainers). At limit=0, a reducer alternates between "ignore the limit and copy right wholesale" on empty-left iterations and "return []" on non-empty iterations, producing parity-dependent output — e.g. a 3-property request schema keeps only the last property's examples while a 2-property one returns none. The CHANGELOG description "Set to 0 to disable product but keep concatenations" doesn't match this behavior for multi-property schemas. Either make limit=0 short-circuit to [] here, or restructure the reducers so limit=0 skips the product step entirely rather than piggy-backing on combinations.

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();
Expand Down
4 changes: 3 additions & 1 deletion express-zod-api/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This { maxCombinations: 0 } is used as a sentinel for "don't compute examples", but getResponses("positive", ...) still calls #ensureOutputExamples(0), which still calls pullResponseExamples(schema, 0), which still writes to globalRegistry whenever the reduce ends on the empty-array branch. For output schemas with an odd number of example-bearing properties the registry ends up with a single-property example (the last one), and for even counts it stays empty. Because Diagnostics runs unconditionally in non-production during initRouting (routing.ts:70), every subsequent Documentation generation then hits the globalRegistry.get(...)?.examples?.length short-circuit at endpoint.ts:95 and silently reuses the degenerate examples — regardless of the user's configured maxCombinations. Either gate the registry write by limit !== 0, or give Diagnostics a path that doesn't mutate shared state.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pullfrog wait a second, there is if (!examples.length) return; statement on line 98 that prevents writing into registry an empty array of examples.

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed back on the reviewer's question with evidence that my original concern stands. Verified locally: pullResponseExamples(z.object({ a, b, c }), 0) returns [{ c: 3 }] (non-empty) because combinations' empty-side shortcut bypasses the limit check, so the reduce "resets" to each property's examples whenever acc is empty. The if (!examples.length) return; guard only catches the even-count case. No code changes made; left the thread open for the author.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if (!examples.length) return; guard does catch the even-count case, but it misses the odd-count case. With limit=0, pullResponseExamples reduces with combinations(acc, propExamples, R.mergeRight, 0) — and combinations shortcircuits via if (!left.length || !right.length) return left.concat(right); before the limit is ever consulted. So when acc is empty and the next property has examples, the reduce "resets" to that property's examples (ignoring the limit), then the next non-empty property flips it back to [], and so on.

Confirmed with a direct call in this branch: pullResponseExamples(z.object({ a, b, c }), 0) (where each prop has one example) returns [{ c: 3 }] — non-empty, bypassing the guard and writing the degenerate single-property example into globalRegistry. An even count ({ a, b }) correctly ends at [] and is caught by the guard.

Two possible fixes: early-return from pullResponseExamples when limit === 0, or tighten combinations so the empty-side shortcut also respects a 0 limit.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got your point on odd/even number of properties and how it may affect the result.

for (const { mimeTypes, schema } of responses) {
if (!mimeTypes?.includes(contentTypes.json)) continue;
const reason = findJsonIncompatible(schema, "output");
if (reason) {
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 15 additions & 14 deletions express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface ReqResCommons {
) => ReferenceObject;
path: string;
method: ClientMethod;
maxCombinations?: number;
}

export interface OpenAPIContext extends ReqResCommons {
Expand Down Expand Up @@ -126,9 +127,9 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => {
};

export const depictIntersection = R.tryCatch<Depicter>(
({ jsonSchema }) => {
({ jsonSchema }, { maxCombinations }) => {
if (!jsonSchema.allOf) throw "no allOf";
return flattenIO(jsonSchema, "throw");
return flattenIO(jsonSchema, { isStrict: true, maxCombinations });
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(_err, { jsonSchema }) => jsonSchema,
);
Expand Down Expand Up @@ -267,9 +268,9 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>

export const defaultIsHeader = (
name: string,
familiar?: string[],
familiar?: Set<string>,
): name is `x-${string}` =>
familiar?.includes(name) ||
familiar?.has(name) ||
name.startsWith("x-") ||
getWellKnownHeaders().has(name);

Expand All @@ -281,25 +282,22 @@ export const depictRequestParams = ({
makeRef,
composition,
isHeader,
security,
securityHeaders,
maxCombinations,
description = `${method.toUpperCase()} ${path} Parameter`,
}: ReqResCommons & {
composition: "inline" | "components";
description?: string;
request: z.core.JSONSchema.BaseSchema;
inputSources: InputSource[];
isHeader?: IsHeader;
security?: Alternatives<Security>;
securityHeaders?: Set<string>;
}) => {
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";
Expand Down Expand Up @@ -458,6 +456,7 @@ export const depictResponse = ({
hasMultipleStatusCodes,
statusCode,
brandHandling,
maxCombinations,
description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
hasMultipleStatusCodes ? statusCode : ""
}`.trim(),
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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 = ({
Expand All @@ -609,6 +609,7 @@ export const depictBody = ({
makeRef,
composition,
paramNames,
maxCombinations,
description = `${method.toUpperCase()} ${path} Request body`,
}: ReqResCommons & {
schema: IOSchema;
Expand All @@ -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),
)
Expand Down
24 changes: 20 additions & 4 deletions express-zod-api/src/documentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -86,6 +87,14 @@ interface DocumentationParams {
* @example { users: "About users", files: { description: "About files", url: "https://example.com" } }
* */
tags?: Parameters<typeof depictTags>[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 {
Expand Down Expand Up @@ -158,6 +167,7 @@ export class Documentation extends OpenApiBuilder {
brandHandling,
tags,
isHeader,
maxCombinations,
hasSummaryFromDescription = true,
hasHeadMethod = true,
composition = "inline",
Expand All @@ -173,6 +183,7 @@ export class Documentation extends OpenApiBuilder {
endpoint,
composition,
brandHandling,
maxCombinations,
makeRef: this.#makeRef.bind(this),
};
const { description, shortDescription, scopes, inputSchema } = endpoint;
Expand All @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -243,7 +256,10 @@ export class Documentation extends OpenApiBuilder {
: undefined;

const securityRefs = depictSecurityRefs(
depictSecurity(security, inputSources),
depictSecurity(
processContainers(endpoint.security, maxCombinations),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Threading maxCombinations into processContainers for the security tree conflates "example combinations" with "required-auth alternatives". For { or: [A,B] }, { or: [C,D] } security at maxCombinations: 0, the reduce inside processContainers produces [] (the second step hits the non-empty-×-non-empty branch with limit=0), and depictSecurity then emits security: [] on the OpenAPI operation — i.e. no auth documented for an endpoint that actually requires one of several. Security alternatives should not be subject to the example-combinations limit; call processContainers(endpoint.security) without the limit here.

inputSources,
),
scopes,
(securitySchema) => {
const name = this.#ensureUniqSecuritySchemaName(securitySchema);
Expand Down
14 changes: 9 additions & 5 deletions express-zod-api/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export abstract class AbstractEndpoint {
/** @internal */
public abstract getResponses(
variant: ResponseVariant,
params: { maxCombinations?: number },
): ReadonlyArray<NormalizedResponse>;
/** @internal */
public abstract getOperationId(method: ClientMethod): string | undefined;
Expand Down Expand Up @@ -90,16 +91,16 @@ export class Endpoint<
readonly #def: ConstructorParameters<typeof Endpoint<IN, OUT, CTX>>[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<z.core.$ZodObject>(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;
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With R.once gone, idempotency now relies on globalRegistry.get(...)?.examples?.length at line 95. That makes this method first-caller-wins: whichever caller (Diagnostics, Integration, Documentation) hits a given outputSchema first pins the examples for every subsequent caller on the same process. In the current call graph, Diagnostics (maxCombinations: 0) runs first in non-production — see the related comment on diagnostics.ts:50. Worth pinning this contract with a test that calls getResponses twice with different maxCombinations and asserts the intended behavior, and adding at least one test covering getResponses("positive", { maxCombinations: <positive> }) which no existing spec exercises.

return Object.freeze(
variant === "negative"
? this.#def.resultHandler.getNegativeResponse()
Expand Down
4 changes: 3 additions & 1 deletion express-zod-api/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading