diff --git a/CHANGELOG.md b/CHANGELOG.md index d582b9c6f..db4ae7e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index d727e4cc0..3e89381a5 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -109,11 +109,27 @@ 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 = ( - 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(); diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index c21b602fe..35e433575 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, { maxExamples: 0 }); + 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", }), + { 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 d50aee951..bf76bf666 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; + maxExamples?: number; } export interface OpenAPIContext extends ReqResCommons { @@ -126,9 +127,9 @@ export const depictUnion: Depicter = ({ zodSchema, jsonSchema }) => { }; export const depictIntersection = R.tryCatch( - ({ jsonSchema }) => { + ({ jsonSchema }, { maxExamples }) => { if (!jsonSchema.allOf) throw "no allOf"; - return flattenIO(jsonSchema, "throw"); + return flattenIO(jsonSchema, { isStrict: true, maxExamples }); }, (_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, + maxExamples, 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, { 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"; @@ -458,6 +456,7 @@ export const depictResponse = ({ hasMultipleStatusCodes, statusCode, brandHandling, + maxExamples, 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, maxExamples }, }), ); const examples = []; @@ -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 = ({ @@ -609,6 +609,7 @@ export const depictBody = ({ makeRef, composition, paramNames, + maxExamples, 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, { 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 173930cbe..762b211b7 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,26 @@ 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 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; + }; } export class Documentation extends OpenApiBuilder { @@ -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 }); @@ -173,6 +195,7 @@ export class Documentation extends OpenApiBuilder { endpoint, composition, brandHandling, + maxExamples, makeRef: this.#makeRef.bind(this), }; const { description, shortDescription, scopes, inputSchema } = endpoint; @@ -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, request, description: descriptions?.requestParameter?.call(null, { method, @@ -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({ @@ -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); diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index eaf2f4ce4..4a1e925db 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 { @@ -57,6 +58,7 @@ export abstract class AbstractEndpoint { /** @internal */ public abstract getResponses( variant: ResponseVariant, + params: { maxExamples?: number }, ): ReadonlyArray; /** @internal */ public abstract getOperationId(method: ClientMethod): string | undefined; @@ -90,16 +92,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 +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() diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 46f50438c..5d3910350 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, { + maxExamples: 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 3eceb01a1..646a73855 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -1,9 +1,13 @@ 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"; -type MergeMode = "coerce" | "throw"; type FlattenObjectSchema = z.core.JSONSchema.ObjectSchema & Required>; @@ -42,12 +46,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,23 +89,37 @@ export const processPropertyNames = ( export const mergeExamples = ( target: FlattenObjectSchema, entry: z.core.JSONSchema.BaseSchema, - isOptional: boolean, + { + isOptional, + limit = defaultMaxCombinations, + }: { isOptional: boolean; limit?: number }, ) => { if (!entry.examples?.length) return; if (isOptional) { - target.examples = R.concat(target.examples || [], entry.examples); + target.examples = R.concat(target.examples || [], entry.examples).slice( + 0, + Math.max(0, limit || 0), + ); } else { target.examples = combinations( target.examples?.filter(isObject) || [], entry.examples.filter(isObject), - ([a, b]) => R.mergeDeepRight(a, b), + R.mergeDeepRight, + limit, ); } }; export const flattenIO = ( jsonSchema: z.core.JSONSchema.BaseSchema, - mode: MergeMode = "coerce", + { + isStrict = false, + maxExamples, + }: { + /** @default false */ + isStrict?: boolean; + maxExamples?: number; + } = {}, ) => { const stack: Stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] const flat: FlattenObjectSchema = { type: "object", properties: {} }; @@ -110,13 +127,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, limit: maxExamples }); if (!isJsonObjectSchema(entry)) continue; - stack.push([isOptional, { examples: pullRequestExamples(entry) }]); + stack.push([ + isOptional, + { examples: pullRequestExamples(entry, maxExamples) }, + ]); if (entry.properties) { - flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( + flat.properties = (isStrict ? propsMerger : R.mergeDeepRight)( flat.properties, entry.properties, ); @@ -129,14 +149,16 @@ export const flattenIO = ( }; /** @see pullResponseExamples */ -export const pullRequestExamples = (subject: z.core.JSONSchema.ObjectSchema) => - Object.entries(subject.properties || {}).reduce( +export const pullRequestExamples = ( + subject: z.core.JSONSchema.ObjectSchema, + 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)), ([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 ffb92ee03..1b965ec5c 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,6 +31,7 @@ export type Alternatives = Array>; export const processContainers = ( containers: LogicalContainer[], + maxCombinations = defaultMaxCombinations, ): Alternatives => { const simples = R.filter(isSimple, containers); const ands = R.chain(R.prop("and"), R.filter(isLogicalAnd, containers)); @@ -36,10 +41,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, + Math.max(maxCombinations || 0, 1), ), R.reject(R.isEmpty, [persistent]), ); diff --git a/express-zod-api/src/result-helpers.ts b/express-zod-api/src/result-helpers.ts index 0f7cd1162..db124e1f6 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, @@ -88,14 +89,16 @@ export const getPublicErrorMessage = (error: HttpError): string => : error.message; /** @see pullRequestExamples */ -export const pullResponseExamples = (subject: T) => - Object.entries(subject._zod.def.shape).reduce( +export const pullResponseExamples = ( + subject: T, + 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)), ([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 6bb64e549..c60c9e275 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 96b24cc2d..89ddb7855 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -270,15 +270,26 @@ 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 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) => { + 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 9f5d45d37..c03845e48 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 83584bc4f..1b3c3374e 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -282,11 +282,32 @@ describe("Endpoint", () => { output: z.object({ something: z.number() }), handler: vi.fn(), }); - const responses = endpoint.getResponses(variant); + const responses = endpoint.getResponses(variant, { + maxExamples: 0, + }); expect(responses).toMatchSnapshot(); 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", () => { diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts index 8cb2f67ff..1dc1332da 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,44 @@ 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, 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); + }); }); describe("pullRequestExamples()", () => { @@ -281,6 +313,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 bddcdbf8a..1587ebac1 100644 --- a/express-zod-api/tests/logical-container.spec.ts +++ b/express-zod-api/tests/logical-container.spec.ts @@ -196,5 +196,20 @@ describe("LogicalContainer", () => { processContainers([{ type: "bearer", format: "JWT" }, { and: [] }]), ).toEqual([[{ type: "bearer", format: "JWT" }]]); }); + + 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); + }, + ); }); }); diff --git a/express-zod-api/tests/result-helpers.spec.ts b/express-zod-api/tests/result-helpers.spec.ts index 50526c067..b78028f52 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 000000000..6a8244a6f --- /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"]); + }); +});