Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dd5bc6a
feat: squashed version of #3344, limit-combinations branch.
RobinTail May 6, 2026
e4f0441
fix(combinations): also limit the concatenation result.
RobinTail May 6, 2026
83e0642
fix(combinations): early exit for non-positive limit.
RobinTail May 6, 2026
0e55963
mv(mergeExamples): renaming maxCombinations to limit and applying it …
RobinTail May 6, 2026
ac657e0
mv(flattenIO): renaming option to maxExamples.
RobinTail May 6, 2026
07c81c0
mv(getResponses): renaming parametere to maxExamples.
RobinTail May 6, 2026
9003613
mv: making Documentation param limits with nestes examples and security.
RobinTail May 6, 2026
13f4df4
ref: extracting defaultMaxCombinations.
RobinTail May 6, 2026
a81e031
ref: early return for processContainers.
RobinTail May 6, 2026
72fdf78
ref: defaults and early returns for pulling helpers.
RobinTail May 6, 2026
a9eacbb
fix: only run Endpoint::ensureOutputExamples() when maxExamples is po…
RobinTail May 6, 2026
4eb5891
fix(processContainers): prohibit zero security schemas.
RobinTail May 6, 2026
330ccb3
fix: replacing early return with a non-negative clamp on slicer.
RobinTail May 6, 2026
ebec410
Merge branch 'master' into limit-combinations-2
RobinTail May 6, 2026
9941ce4
fix(test): the limit test for Endpoint::getResponses().
RobinTail May 6, 2026
4ff2d0c
fix(test): more cases for limit of processContainers.
RobinTail May 6, 2026
3efbec7
Merge branch 'master' into limit-combinations-2
RobinTail May 6, 2026
57ca58f
docs(changelog): rewrite v27.4.0 entry for limits option
pullfrog[bot] May 6, 2026
116e65a
add jsdoc to limits prop.
RobinTail May 6, 2026
4fd3b23
fix(jsdoc): Documentation.
RobinTail May 7, 2026
4b4847b
todo for security limit.
RobinTail May 7, 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 `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
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 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"] },
// { 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
24 changes: 20 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,27 @@ export const isSchema = <T extends z.core.$ZodType = z.core.$ZodType>(
"_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;
Comment on lines +112 to +113
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.

Shipping the limit feature with Infinity as the default means v27.4.0 users still hit the combinatorial explosion the CHANGELOG warns about unless they discover and configure limits. The @todo already concedes the right default is lower — consider picking it now (e.g. 20) and treating the cap-by-default as the breaking change it effectively is in v28, rather than landing a disarmed guardrail.


/** 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 */
limit = defaultMaxCombinations,
): 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++) {
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, { maxExamples: 0 });
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",
}),
{ maxExamples: 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;
maxExamples?: 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 }, { maxExamples }) => {
if (!jsonSchema.allOf) throw "no allOf";
return flattenIO(jsonSchema, "throw");
return flattenIO(jsonSchema, { isStrict: true, maxExamples });
},
(_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,
maxExamples,
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, { maxExamples });
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,
maxExamples,
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, maxExamples },
}),
);
const examples = [];
Expand Down Expand Up @@ -591,13 +590,14 @@ export const depictRequest = ({
makeRef,
path,
method,
maxExamples,
}: ReqResCommons & {
schema: IOSchema;
brandHandling?: BrandHandling;
}) =>
depict(schema, {
rules: { ...brandHandling, ...depicters },
ctx: { isResponse: false, makeRef, path, method },
ctx: { isResponse: false, makeRef, path, method, maxExamples },
});

export const depictBody = ({
Expand All @@ -609,6 +609,7 @@ export const depictBody = ({
makeRef,
composition,
paramNames,
maxExamples,
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, { maxExamples })
.examples?.filter(
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
)
Expand Down
34 changes: 30 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,26 @@ interface DocumentationParams {
* @example { users: "About users", files: { description: "About files", url: "https://example.com" } }
* */
tags?: Parameters<typeof depictTags>[0];
/**
* @desc Caps on the number of generated entities.
* @default { examples: defaultMaxCombinations, security: defaultMaxCombinations }
* @example { examples: 20, security: 10 }
* */
limits?: {
/**
* @desc Caps the number of generated examples (request/response examples from examples of individual properties).
* @default defaultMaxCombinations
* @see defaultMaxCombinations
* */
examples?: number;
/**
* @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;
Comment on lines +96 to +108
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.

Two issues on the nested properties: (1) a @todo directive is landing in public-API JSDoc, meaning the open design question ships to users; decide now whether security shares defaultMaxCombinations or gets its own constant. (2) AGENTS.md requires @example on all optional public-entity properties — both examples and security are missing it (the parent limits has one). CodeRabbit already flagged #2 upthread.

Suggested change
/**
* @desc Caps the number of generated examples (request/response examples from examples of individual properties).
* @default defaultMaxCombinations
* @see defaultMaxCombinations
* */
examples?: number;
/**
* @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;
/**
* @desc Caps the number of generated examples (request/response examples from examples of individual properties).
* @default defaultMaxCombinations
* @see defaultMaxCombinations
* @example 20
* */
examples?: number;
/**
* @desc Caps the number of security schemas combinations. Values below 1 are clamped to 1.
* @default defaultMaxCombinations
* @see Middleware
* @example 10
* */
security?: number;

};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export class Documentation extends OpenApiBuilder {
Expand Down Expand Up @@ -161,6 +182,7 @@ export class Documentation extends OpenApiBuilder {
hasSummaryFromDescription = true,
hasHeadMethod = true,
composition = "inline",
limits: { examples: maxExamples, security: maxSecurity } = {},
}: DocumentationParams) {
super();
this.addInfo({ title, version });
Expand All @@ -173,6 +195,7 @@ export class Documentation extends OpenApiBuilder {
endpoint,
composition,
brandHandling,
maxExamples,
makeRef: this.#makeRef.bind(this),
};
const { description, shortDescription, scopes, inputSchema } = endpoint;
Expand All @@ -189,12 +212,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,
Comment thread
RobinTail marked this conversation as resolved.
request,
description: descriptions?.requestParameter?.call(null, {
method,
Expand All @@ -205,7 +228,7 @@ export class Documentation extends OpenApiBuilder {

const responses: ResponsesObject = {};
for (const variant of responseVariants) {
const apiResponses = endpoint.getResponses(variant);
const apiResponses = endpoint.getResponses(variant, { maxExamples });
for (const { mimeTypes, schema, statusCodes } of apiResponses) {
for (const statusCode of statusCodes) {
responses[statusCode] = depictResponse({
Expand Down Expand Up @@ -243,7 +266,10 @@ export class Documentation extends OpenApiBuilder {
: undefined;

const securityRefs = depictSecurityRefs(
depictSecurity(security, inputSources),
depictSecurity(
processContainers(endpoint.security, maxSecurity),
inputSources,
),
scopes,
(securitySchema) => {
const name = this.#ensureUniqSecuritySchemaName(securitySchema);
Expand Down
16 changes: 11 additions & 5 deletions express-zod-api/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getInput,
ensureError,
isSchema,
defaultMaxCombinations,
} from "./common-helpers";
import { CommonConfig } from "./config-type";
import {
Expand Down Expand Up @@ -57,6 +58,7 @@ export abstract class AbstractEndpoint {
/** @internal */
public abstract getResponses(
variant: ResponseVariant,
params: { maxExamples?: number },
): ReadonlyArray<NormalizedResponse>;
Comment on lines 59 to 62
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 options bag is required but its only field is optional, so every caller that doesn't want to cap must still write getResponses(variant, {}) or { maxExamples: N }. Four existing sites now carry that boilerplate. Making the bag itself optional would let indifferent callers drop it entirely.

Suggested change
public abstract getResponses(
variant: ResponseVariant,
params: { maxExamples?: number },
): ReadonlyArray<NormalizedResponse>;
public abstract getResponses(
variant: ResponseVariant,
params?: { maxExamples?: number },
): ReadonlyArray<NormalizedResponse>;

/** @internal */
public abstract getOperationId(method: ClientMethod): string | undefined;
Expand Down Expand Up @@ -90,16 +92,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 });
});
}
Comment on lines 94 to +104
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.

Dropping R.once here traded closure-memoization for a sticky globalRegistry cache (the examples?.length check at line 96 short-circuits subsequent calls). The effect: the first limit value passed wins permanently for a given endpoint instance. If a consumer creates two Documentation generators with different limits.examples sharing endpoints, the second silently reuses the first's cap. The new test at endpoint.spec.ts:292-309 only exercises one call per factory. Consider re-adding instance-scoped memoization keyed on limit, or stop mutating globalRegistry and return the truncated examples to the caller so each consumer gets independent results.


constructor(def: {
deprecated?: boolean;
Expand Down Expand Up @@ -172,8 +174,12 @@ export class Endpoint<
}

/** @internal */
public override getResponses(variant: ResponseVariant) {
if (variant === "positive") this.#ensureOutputExamples();
public override getResponses(
variant: ResponseVariant,
{ maxExamples = defaultMaxCombinations }: { maxExamples?: number },
) {
if (variant === "positive" && maxExamples > 0)
this.#ensureOutputExamples(maxExamples);
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, {
maxExamples: 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
Loading