From 3f9780029ba111114ec753eba9ca562dffec4686 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 13 Aug 2024 19:50:57 +0300 Subject: [PATCH] fix(openapi-sampler): address pr comments closes #246 --- .../src/samplers/StringSampler.ts | 84 ++++++++++++------- packages/openapi-sampler/tests/string.spec.ts | 56 ++++++++++++- 2 files changed, 111 insertions(+), 29 deletions(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index 24f6868f..bd6bacfc 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -2,6 +2,7 @@ import { Sampler, OpenAPISchema } from './Sampler'; import RandExp from 'randexp'; export class StringSampler implements Sampler { + private readonly MAX_PATTERN_SAMPLE_LENGTH = 500; private readonly stringFormats = { 'email': () => 'jon.snow@targaryen.com', 'idn-email': () => 'джон.сноу@таргариен.укр', @@ -44,7 +45,7 @@ export class StringSampler implements Sampler { const { minLength: min, maxLength: max } = schema; - return this.checkLength(sampler(min || 0, max, schema), format, min, max); + return this.checkLength(sampler(min, max, schema), format, min, max); } private patternSample( @@ -52,63 +53,63 @@ export class StringSampler implements Sampler { min?: number, max?: number ): string { + this.assertLength(min, max); + const randExp = new RandExp(pattern); + randExp.randInt = (a, b) => Math.floor((a + b) / 2); - if (min) { - return this.sampleMinLength(randExp, min, max); + if (min !== undefined) { + return this.sampleWithMinLength(randExp, min, max); } randExp.max = max ?? randExp.max; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); - const result = randExp.gen(); - return !!max && result.length > max && this.hasInfiniteQuantifier(pattern) - ? this.sampleInfiniteQuantifier(randExp, max) + return max !== undefined && result.length > max && this.hasInfiniteQuantifier(pattern) + ? this.sampleWithMaxLength(randExp, max) : result; } private hasInfiniteQuantifier(pattern: string | RegExp) { - const pat = pattern.toString(); + const pat = typeof pattern === 'string' ? pattern : pattern.source; - return ['+', '*', ',}'].some((q) => pat.includes(q)); + return /(\+|\*|\{\d*,\})/.test(pat); } - private sampleInfiniteQuantifier(randExp: RandExp, max: number): string { - randExp.randInt = (a, b) => Math.floor((a + b) / 2); - - for (let i = 1, lmax = max; lmax > 0; lmax = Math.floor(max / ++i)) { - randExp.max = lmax; + private sampleWithMaxLength(randExp: RandExp, max: number): string { + let result = ''; - const result = randExp.gen(); + for (let i = 1; i <= Math.min(max, 20); i++) { + randExp.max = Math.floor(max / i); + result = randExp.gen(); if (result.length <= max) { - return result; + break; } } - return ''; + return result; } - private sampleMinLength(randExp: RandExp, min: number, max: number) { + private sampleWithMinLength(randExp: RandExp, min: number, max?: number) { // ADHOC: make a probe for regex using min quantifier value // e.g. ^[a]+[b]+$ expect 'ab', ^[a-z]*$ expect '' - randExp.max = 0; + const randInt = randExp.randInt; + randExp.max = min; randExp.randInt = (a, _) => a; - const result = randExp.gen(); - - if (result.length >= min) { - return result; - } + let result = randExp.gen(); - // ADHOC: fallback for failed min quantifier probe with doubled min length + randExp.randInt = randInt; - randExp.max = 2 * min; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); + if (result.length < min) { + // ADHOC: fallback for failed min quantifier probe with doubled min length + randExp.max = 2 * min; + result = this.adjustMaxLength(randExp.gen(), max); + } - return this.adjustMaxLength(randExp.gen(), max); + return result; } private checkLength( @@ -137,6 +138,33 @@ export class StringSampler implements Sampler { return value; } + private assertLength(min?: number, max?: number) { + const pairs = [ + { key: 'minLength', value: min }, + { key: 'maxLength', value: max } + ]; + + const boundariesStr = pairs + .filter((p) => !this.checkBoundary(p.value)) + .map((p) => `${p.key}=${p.value}`) + .join(', '); + + if (boundariesStr) { + throw new Error( + `Sample string cannot be generated by boundaries: ${boundariesStr}` + ); + } + } + + private checkBoundary(boundary?: number) { + return ( + boundary === undefined || + (typeof boundary === 'number' && + boundary >= 0 && + boundary <= this.MAX_PATTERN_SAMPLE_LENGTH) + ); + } + private adjustLength(sample: string, min: number, max: number): string { const minLength = min ? min : 0; const maxLength = max ? max : sample.length; diff --git a/packages/openapi-sampler/tests/string.spec.ts b/packages/openapi-sampler/tests/string.spec.ts index 464c11b1..42c40ffa 100644 --- a/packages/openapi-sampler/tests/string.spec.ts +++ b/packages/openapi-sampler/tests/string.spec.ts @@ -10,7 +10,7 @@ describe('StringSampler', () => { pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', type: 'string' }, - expected: 'gggggg@FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.zz' + expected: 'A@a.a' }, { input: { @@ -420,6 +420,44 @@ describe('StringSampler', () => { }, expected: 'Sample string cannot be generated by boundaries: maxLength=35, format=uuid' + }, + { + input: { + maxLength: Number.MAX_SAFE_INTEGER, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: + 'Sample string cannot be generated by boundaries: maxLength=9007199254740991' + }, + { + input: { + minLength: Number.MAX_SAFE_INTEGER, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: + 'Sample string cannot be generated by boundaries: minLength=9007199254740991' + }, + { + input: { + maxLength: 501, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: 'Sample string cannot be generated by boundaries: maxLength=501' + }, + { + input: { + minLength: -1, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: 'Sample string cannot be generated by boundaries: minLength=-1' } ].forEach(({ input, expected }) => { const { type, ...restrictions } = input; @@ -431,4 +469,20 @@ describe('StringSampler', () => { expect(result).toThrowError(expected); }); }); + + it.each([10, 100, 500])(`should handle maxLength=%d gracefully`, (input) => { + // arrange + const pattern = /^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{1,4}$/; + + // act + const result = sample({ + maxLength: input, + format: 'pattern', + pattern: pattern.source, + type: 'string' + }); + + // assert + expect(result).toMatch(pattern); + }); });