Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(openapi-sampler): able to sample pattern with nested infinite quantifiers and max-length #247

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
92 changes: 75 additions & 17 deletions packages/openapi-sampler/src/samplers/StringSampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { Sampler, OpenAPISchema } from './Sampler';
import RandExp from 'randexp';

export class StringSampler implements Sampler {
// ADHOC: 500 seems enough to protect sampler in case of patterns with infinite
// quantifiers as the complexity may reach O(n^x) for the x quantifier nesting
private readonly MAX_PATTERN_SAMPLE_LENGTH = 500;
ostridm marked this conversation as resolved.
Show resolved Hide resolved

private readonly stringFormats = {
'email': () => '[email protected]',
'idn-email': () => 'джон.сноу@таргариен.укр',
Expand Down Expand Up @@ -44,41 +48,73 @@ 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(
pattern: string | RegExp,
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) {
// ADHOC: make a probe for regex using min quantifier value
// e.g. ^[a]+[b]+$ expect 'ab', ^[a-z]*$ expect ''
if (min !== undefined) {
return this.sampleWithMinLength(randExp, min, max);
}

randExp.max = 0;
randExp.randInt = (a, _) => a;
randExp.max = max ?? randExp.max;
const result = randExp.gen();

const result = randExp.gen();
return max !== undefined &&
result.length > max &&
this.hasInfiniteQuantifier(pattern)
? this.sampleWithMaxLength(randExp, max)
: result;
}

if (result.length >= min) {
return result;
}
private hasInfiniteQuantifier(pattern: string | RegExp) {
const pat = typeof pattern === 'string' ? pattern : pattern.source;

// ADHOC: fallback for failed min quantifier probe with doubled min length
return /(\+|\*|\{\d*,\})/.test(pat);
}

randExp.max = 2 * min;
randExp.randInt = (a, b) => Math.floor((a + b) / 2);
private sampleWithMaxLength(randExp: RandExp, max: number): string {
let result = '';

return this.adjustMaxLength(randExp.gen(), max);
for (let i = 1; i <= Math.min(max, 20); i++) {
randExp.max = Math.floor(max / i);
result = randExp.gen();

if (result.length <= max) {
ostridm marked this conversation as resolved.
Show resolved Hide resolved
break;
}
}

randExp.max = max ?? randExp.max;
randExp.randInt = (a, b) => Math.floor((a + b) / 2);
return result;
}

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 ''

return randExp.gen();
const randInt = randExp.randInt;
randExp.max = min;
randExp.randInt = (a, _) => a;

let result = randExp.gen();

randExp.randInt = randInt;

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 result;
}

private checkLength(
Expand Down Expand Up @@ -107,6 +143,28 @@ 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}. Both minLength and maxLength must not exceed ${this.MAX_PATTERN_SAMPLE_LENGTH}`
);
}
}

private checkBoundary(boundary?: number) {
return boundary === undefined || 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;
Expand Down
56 changes: 56 additions & 0 deletions packages/openapi-sampler/tests/string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { sample } from '../src';

describe('StringSampler', () => {
[
{
input: {
maxLength: 55,
minLength: 0,
format: 'pattern',
pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$',
type: 'string'
},
expected: '[email protected]'
},
{
input: {
type: 'string',
Expand Down Expand Up @@ -410,6 +420,36 @@ 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. Both minLength and maxLength must not exceed 500'
},
{
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. Both minLength and maxLength must not exceed 500'
},
{
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. Both minLength and maxLength must not exceed 500'
}
].forEach(({ input, expected }) => {
const { type, ...restrictions } = input;
Expand All @@ -421,4 +461,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);
});
});
Loading