Skip to content

Commit 47a02c2

Browse files
committed
FEAT: add limit property to the combinations fn itself to avoid dragging maxCombinations through the call chain.
1 parent 9251a67 commit 47a02c2

14 files changed

Lines changed: 53 additions & 128 deletions

express-zod-api/src/common-helpers.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,25 @@ export const isSchema = <T extends z.core.$ZodType = z.core.$ZodType>(
110110
(type ? R.path(["_zod", "def", "type"], subject) === type : true);
111111

112112
/** Configurable replacement for R.xprod(), but it also handles empty arrays */
113-
export const combinations = <T>(
114-
left: T[],
115-
right: T[],
116-
/** @desc The function that combines elements */
117-
merge: (a: T, b: T) => T,
118-
/** @desc Maximum number of combinations (only applies to Cartesian product of non-empty arrays) */
119-
limit = Infinity,
120-
): T[] => {
121-
if (!left.length || !right.length) return left.concat(right);
122-
const result: T[] = [];
123-
for (let idxL = 0; idxL < left.length && result.length < limit; idxL++) {
124-
for (let idxR = 0; idxR < right.length && result.length < limit; idxR++)
125-
result.push(merge(left[idxL], right[idxR]));
126-
}
127-
return result;
128-
};
113+
export const combinations = Object.assign(
114+
<T>(
115+
left: T[],
116+
right: T[],
117+
/** @desc The function that combines elements */
118+
merge: (a: T, b: T) => T,
119+
/** @desc Maximum number of combinations (only applies to Cartesian product of non-empty arrays) */
120+
limit = combinations.limit ?? Infinity,
121+
): T[] => {
122+
if (!left.length || !right.length) return left.concat(right);
123+
const result: T[] = [];
124+
for (let idxL = 0; idxL < left.length && result.length < limit; idxL++) {
125+
for (let idxR = 0; idxR < right.length && result.length < limit; idxR++)
126+
result.push(merge(left[idxL], right[idxR]));
127+
}
128+
return result;
129+
},
130+
{ limit: Infinity as number | undefined },
131+
);
129132

130133
export const ucFirst = (subject: string) =>
131134
subject.charAt(0).toUpperCase() + subject.slice(1).toLowerCase();

express-zod-api/src/diagnostics.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22
import { responseVariants } from "./api-response";
3-
import { FlatObject, getRoutePathParams } from "./common-helpers";
3+
import { combinations, FlatObject, getRoutePathParams } from "./common-helpers";
44
import { contentTypes } from "./content-type";
55
import { findJsonIncompatible } from "./deep-checks";
66
import { AbstractEndpoint } from "./endpoint";
@@ -46,8 +46,9 @@ export class Diagnostics {
4646
);
4747
}
4848
}
49+
combinations.limit = 0; // no examples
4950
for (const variant of responseVariants) {
50-
const responses = endpoint.getResponses(variant, { maxCombinations: 0 }); // no examples
51+
const responses = endpoint.getResponses(variant);
5152
for (const { mimeTypes, schema } of responses) {
5253
if (!mimeTypes?.includes(contentTypes.json)) continue;
5354
const reason = findJsonIncompatible(schema, "output");
@@ -71,12 +72,12 @@ export class Diagnostics {
7172
if (ref.paths.has(path)) return;
7273
const params = getRoutePathParams(path);
7374
if (params.length === 0) return; // next statement can be expensive
75+
combinations.limit = 0; // not required for this check
7476
ref.flat ??= flattenIO(
7577
z.toJSONSchema(endpoint.inputSchema, {
7678
unrepresentable: "any",
7779
io: "input",
7880
}),
79-
{ maxCombinations: 0 }, // not required for this check
8081
);
8182
for (const param of params) {
8283
if (param in ref.flat.properties) continue;

express-zod-api/src/documentation-helpers.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ interface ReqResCommons {
5555
) => ReferenceObject;
5656
path: string;
5757
method: ClientMethod;
58-
maxCombinations?: number;
5958
}
6059

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

129128
export const depictIntersection = R.tryCatch<Depicter>(
130-
({ jsonSchema }, { maxCombinations }) => {
129+
({ jsonSchema }) => {
131130
if (!jsonSchema.allOf) throw "no allOf";
132-
return flattenIO(jsonSchema, { isStrict: true, maxCombinations });
131+
return flattenIO(jsonSchema, { isStrict: true });
133132
},
134133
(_err, { jsonSchema }) => jsonSchema,
135134
);
@@ -283,7 +282,6 @@ export const depictRequestParams = ({
283282
composition,
284283
isHeader,
285284
securityHeaders,
286-
maxCombinations,
287285
description = `${method.toUpperCase()} ${path} Parameter`,
288286
}: ReqResCommons & {
289287
composition: "inline" | "components";
@@ -293,7 +291,7 @@ export const depictRequestParams = ({
293291
isHeader?: IsHeader;
294292
securityHeaders?: Set<string>;
295293
}) => {
296-
const flat = flattenIO(request, { maxCombinations });
294+
const flat = flattenIO(request);
297295
const pathParams = getRoutePathParams(path);
298296
const isQueryEnabled = inputSources.includes("query");
299297
const areParamsEnabled = inputSources.includes("params");
@@ -456,7 +454,6 @@ export const depictResponse = ({
456454
hasMultipleStatusCodes,
457455
statusCode,
458456
brandHandling,
459-
maxCombinations,
460457
description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
461458
hasMultipleStatusCodes ? statusCode : ""
462459
}`.trim(),
@@ -474,7 +471,7 @@ export const depictResponse = ({
474471
const response = asOAS(
475472
depict(schema, {
476473
rules: { ...brandHandling, ...depicters },
477-
ctx: { isResponse: true, makeRef, path, method, maxCombinations },
474+
ctx: { isResponse: true, makeRef, path, method },
478475
}),
479476
);
480477
const examples = [];
@@ -590,14 +587,13 @@ export const depictRequest = ({
590587
makeRef,
591588
path,
592589
method,
593-
maxCombinations,
594590
}: ReqResCommons & {
595591
schema: IOSchema;
596592
brandHandling?: BrandHandling;
597593
}) =>
598594
depict(schema, {
599595
rules: { ...brandHandling, ...depicters },
600-
ctx: { isResponse: false, makeRef, path, method, maxCombinations },
596+
ctx: { isResponse: false, makeRef, path, method },
601597
});
602598

603599
export const depictBody = ({
@@ -609,7 +605,6 @@ export const depictBody = ({
609605
makeRef,
610606
composition,
611607
paramNames,
612-
maxCombinations,
613608
description = `${method.toUpperCase()} ${path} Request body`,
614609
}: ReqResCommons & {
615610
schema: IOSchema;
@@ -636,7 +631,7 @@ export const depictBody = ({
636631
examples: enumerateExamples(
637632
examples.length
638633
? examples
639-
: flattenIO(request, { maxCombinations })
634+
: flattenIO(request)
640635
.examples?.filter(
641636
(one): one is FlatObject => isObject(one) && !Array.isArray(one),
642637
)

express-zod-api/src/documentation.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as R from "ramda";
1111
import { responseVariants } from "./api-response";
1212
import { contentTypes } from "./content-type";
1313
import { DocumentationError } from "./errors";
14-
import { getInputSources, makeCleanId } from "./common-helpers";
14+
import { getInputSources, combinations, makeCleanId } from "./common-helpers";
1515
import { CommonConfig } from "./config-type";
1616
import { getSecurityHeaders } from "./security";
1717
import { processContainers } from "./logical-container";
@@ -173,6 +173,7 @@ export class Documentation extends OpenApiBuilder {
173173
composition = "inline",
174174
}: DocumentationParams) {
175175
super();
176+
combinations.limit = maxCombinations;
176177
this.addInfo({ title, version });
177178
for (const url of typeof serverUrl === "string" ? [serverUrl] : serverUrl)
178179
this.addServer({ url });
@@ -183,7 +184,6 @@ export class Documentation extends OpenApiBuilder {
183184
endpoint,
184185
composition,
185186
brandHandling,
186-
maxCombinations,
187187
makeRef: this.#makeRef.bind(this),
188188
};
189189
const { description, shortDescription, scopes, inputSchema } = endpoint;
@@ -216,9 +216,7 @@ export class Documentation extends OpenApiBuilder {
216216

217217
const responses: ResponsesObject = {};
218218
for (const variant of responseVariants) {
219-
const apiResponses = endpoint.getResponses(variant, {
220-
maxCombinations,
221-
});
219+
const apiResponses = endpoint.getResponses(variant);
222220
for (const { mimeTypes, schema, statusCodes } of apiResponses) {
223221
for (const statusCode of statusCodes) {
224222
responses[statusCode] = depictResponse({
@@ -256,10 +254,7 @@ export class Documentation extends OpenApiBuilder {
256254
: undefined;
257255

258256
const securityRefs = depictSecurityRefs(
259-
depictSecurity(
260-
processContainers(endpoint.security, maxCombinations),
261-
inputSources,
262-
),
257+
depictSecurity(processContainers(endpoint.security), inputSources),
263258
scopes,
264259
(securitySchema) => {
265260
const name = this.#ensureUniqSecuritySchemaName(securitySchema);

express-zod-api/src/endpoint.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export abstract class AbstractEndpoint {
5757
/** @internal */
5858
public abstract getResponses(
5959
variant: ResponseVariant,
60-
params: { maxCombinations?: number },
6160
): ReadonlyArray<NormalizedResponse>;
6261
/** @internal */
6362
public abstract getOperationId(method: ClientMethod): string | undefined;
@@ -91,10 +90,10 @@ export class Endpoint<
9190
readonly #def: ConstructorParameters<typeof Endpoint<IN, OUT, CTX>>[0];
9291

9392
/** considered expensive operation, only required for generators */
94-
#ensureOutputExamples(limit?: number) {
93+
#ensureOutputExamples() {
9594
if (globalRegistry.get(this.#def.outputSchema)?.examples?.length) return; // examples on output schema, or pull up:
9695
if (!isSchema<z.core.$ZodObject>(this.#def.outputSchema, "object")) return;
97-
const examples = pullResponseExamples(this.#def.outputSchema, limit);
96+
const examples = pullResponseExamples(this.#def.outputSchema);
9897
if (!examples.length) return;
9998
const current = this.#def.outputSchema.meta();
10099
globalRegistry
@@ -173,11 +172,8 @@ export class Endpoint<
173172
}
174173

175174
/** @internal */
176-
public override getResponses(
177-
variant: ResponseVariant,
178-
{ maxCombinations }: { maxCombinations?: number },
179-
) {
180-
if (variant === "positive") this.#ensureOutputExamples(maxCombinations);
175+
public override getResponses(variant: ResponseVariant) {
176+
if (variant === "positive") this.#ensureOutputExamples();
181177
return Object.freeze(
182178
variant === "negative"
183179
? this.#def.resultHandler.getNegativeResponse()

express-zod-api/src/integration.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type ts from "typescript";
33
import { z } from "zod";
44
import { ResponseVariant, responseVariants } from "./api-response";
55
import { IntegrationBase } from "./integration-base";
6-
import { shouldHaveContent, makeCleanId } from "./common-helpers";
6+
import { combinations, shouldHaveContent, makeCleanId } from "./common-helpers";
77
import { loadPeer } from "./peer-helpers";
88
import { Routing } from "./routing";
99
import { OnEndpoint, walkRouting, withHead } from "./routing-walker";
@@ -92,6 +92,7 @@ export class Integration extends IntegrationBase {
9292
hasHeadMethod = true,
9393
}: IntegrationParams) {
9494
super(typescript, serverUrl);
95+
combinations.limit = 0; // not using examples yet
9596
const commons = { makeAlias: this.#makeAlias.bind(this), api: this.api };
9697
const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } };
9798
const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } };
@@ -107,9 +108,7 @@ export class Integration extends IntegrationBase {
107108
this.#program.push(input);
108109
const dictionaries = responseVariants.reduce(
109110
(agg, responseVariant) => {
110-
const responses = endpoint.getResponses(responseVariant, {
111-
maxCombinations: 0, // not using examples yet
112-
});
111+
const responses = endpoint.getResponses(responseVariant);
113112
const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => {
114113
const hasContent = shouldHaveContent(method, mimeTypes);
115114
const variantType = this.api.makeType(

express-zod-api/src/json-schema-helpers.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,7 @@ export const processPropertyNames = (
8484
export const mergeExamples = (
8585
target: FlattenObjectSchema,
8686
entry: z.core.JSONSchema.BaseSchema,
87-
{
88-
isOptional,
89-
maxCombinations,
90-
}: { isOptional: boolean; maxCombinations?: number },
87+
{ isOptional }: { isOptional: boolean },
9188
) => {
9289
if (!entry.examples?.length) return;
9390
if (isOptional) {
@@ -97,7 +94,6 @@ export const mergeExamples = (
9794
target.examples?.filter(isObject) || [],
9895
entry.examples.filter(isObject),
9996
R.mergeDeepRight,
100-
maxCombinations,
10197
);
10298
}
10399
};
@@ -106,11 +102,9 @@ export const flattenIO = (
106102
jsonSchema: z.core.JSONSchema.BaseSchema,
107103
{
108104
isStrict = false,
109-
maxCombinations,
110105
}: {
111106
/** @default false */
112107
isStrict?: boolean;
113-
maxCombinations?: number;
114108
} = {},
115109
) => {
116110
const stack: Stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema]
@@ -121,12 +115,9 @@ export const flattenIO = (
121115
if (entry.description) flat.description ??= entry.description;
122116
stack.push(...processAllOf(entry, { isStrict, isOptional }));
123117
stack.push(...processVariants(entry));
124-
mergeExamples(flat, entry, { isOptional, maxCombinations });
118+
mergeExamples(flat, entry, { isOptional });
125119
if (!isJsonObjectSchema(entry)) continue;
126-
stack.push([
127-
isOptional,
128-
{ examples: pullRequestExamples(entry, maxCombinations) },
129-
]);
120+
stack.push([isOptional, { examples: pullRequestExamples(entry) }]);
130121
if (entry.properties) {
131122
flat.properties = (isStrict ? propsMerger : R.mergeDeepRight)(
132123
flat.properties,
@@ -141,14 +132,11 @@ export const flattenIO = (
141132
};
142133

143134
/** @see pullResponseExamples */
144-
export const pullRequestExamples = (
145-
subject: z.core.JSONSchema.ObjectSchema,
146-
limit?: number,
147-
) =>
135+
export const pullRequestExamples = (subject: z.core.JSONSchema.ObjectSchema) =>
148136
Object.entries(subject.properties || {}).reduce<FlatObject[]>(
149137
(acc, [key, prop]) => {
150138
const { examples = [] } = isObject(prop) ? prop : {};
151-
return combinations(acc, examples.map(R.objOf(key)), R.mergeRight, limit);
139+
return combinations(acc, examples.map(R.objOf(key)), R.mergeRight);
152140
},
153141
[],
154142
);

express-zod-api/src/logical-container.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ export type Alternatives<T> = Array<Combination<T>>;
2727

2828
export const processContainers = <T>(
2929
containers: LogicalContainer<T>[],
30-
maxCombinations?: number,
3130
): Alternatives<T> => {
3231
const simples = R.filter(isSimple, containers);
3332
const ands = R.chain(R.prop("and"), R.filter(isLogicalAnd, containers));
@@ -41,7 +40,6 @@ export const processContainers = <T>(
4140
acc,
4241
R.map((opt) => (isSimple(opt) ? [opt] : opt.and), entry),
4342
R.concat,
44-
maxCombinations,
4543
),
4644
R.reject(R.isEmpty, [persistent]),
4745
);

express-zod-api/src/result-helpers.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,11 @@ export const getPublicErrorMessage = (error: HttpError): string =>
8888
: error.message;
8989

9090
/** @see pullRequestExamples */
91-
export const pullResponseExamples = <T extends z.core.$ZodObject>(
92-
subject: T,
93-
limit?: number,
94-
) =>
91+
export const pullResponseExamples = <T extends z.core.$ZodObject>(subject: T) =>
9592
Object.entries(subject._zod.def.shape).reduce<FlatObject[]>(
9693
(acc, [key, schema]) => {
9794
const { examples = [] } = globalRegistry.get(schema) || {};
98-
return combinations(acc, examples.map(R.objOf(key)), R.mergeRight, limit);
95+
return combinations(acc, examples.map(R.objOf(key)), R.mergeRight);
9996
},
10097
[],
10198
);

express-zod-api/tests/common-helpers.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ describe("Common Helpers", () => {
291291
expect(combinations([1, 2], [4, 5, 6], (a, b) => a + b, 4)).toEqual([
292292
5, 6, 7, 6,
293293
]);
294+
combinations.limit = 3;
295+
expect(combinations([1, 2], [4, 5, 6], (a, b) => a + b)).toEqual([
296+
5, 6, 7,
297+
]);
298+
combinations.limit = Infinity;
294299
});
295300

296301
test.each([0, -1, NaN])("should return empty for limit=%s", (limit) => {

0 commit comments

Comments
 (0)