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: exclusiveMin/Max shows incorect range #1799

Merged
merged 12 commits into from
Nov 24, 2021
99 changes: 84 additions & 15 deletions src/utils/__tests__/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
pluralizeType,
serializeParameterValue,
sortByRequired,
humanizeNumberRange,
} from '../';

import { FieldModel, OpenAPIParser, RedocNormalizedOptions } from '../../services';
Expand Down Expand Up @@ -103,7 +104,7 @@ describe('Utils', () => {

it('Should return pathName if no summary, operationId, description', () => {
const operation = {
pathName: '/sandbox/test'
pathName: '/sandbox/test',
};
expect(getOperationSummary(operation as any)).toBe('/sandbox/test');
});
Expand Down Expand Up @@ -141,9 +142,9 @@ describe('Utils', () => {
object: ['maxProperties', 'minProperties', 'required', 'additionalProperties', 'properties'],
};

Object.keys(tests).forEach(name => {
Object.keys(tests).forEach((name) => {
it(`Should detect ${name} if ${name} properties are present`, () => {
tests[name].forEach(propName => {
tests[name].forEach((propName) => {
expect(
detectType({
[propName]: 0,
Expand Down Expand Up @@ -174,7 +175,7 @@ describe('Utils', () => {
expect(isPrimitiveType(schema)).toEqual(false);
});

it('should return true for array contains object and schema hasn\'t properties', () => {
it("should return true for array contains object and schema hasn't properties", () => {
const schema = {
type: ['object', 'string'],
};
Expand Down Expand Up @@ -231,10 +232,10 @@ describe('Utils', () => {
const schema = {
type: 'array',
items: {
type: 'array',
items: {
type: 'string'
},
type: 'array',
items: {
type: 'string',
},
},
};
expect(isPrimitiveType(schema)).toEqual(true);
Expand Down Expand Up @@ -410,12 +411,80 @@ describe('Utils', () => {
});
});

describe('openapi humanizeNumberRange', () => {
it('should return `>=` when only minimum value present or exclusiveMinimum = false', () => {
const expected = '>= 0';
expect(humanizeNumberRange({ minimum: 0 })).toEqual(expected);
expect(humanizeNumberRange({ minimum: 0, exclusiveMinimum: false })).toEqual(expected);
});

it('should return `>` when minimum value present and exclusiveMinimum set to true', () => {
expect(humanizeNumberRange({ minimum: 0, exclusiveMinimum: true })).toEqual('> 0');
});

it('should return `<=` when only maximum value present or exclusiveMinimum = false', () => {
const expected = '<= 10';
expect(humanizeNumberRange({ maximum: 10 })).toEqual(expected);
expect(humanizeNumberRange({ maximum: 10, exclusiveMaximum: false })).toEqual(expected);
});

it('should return `<` when maximum value present and exclusiveMaximum set to true', () => {
expect(humanizeNumberRange({ maximum: 10, exclusiveMaximum: true })).toEqual('< 10');
});

it('should return correct range for minimum and maximum values and with different exclusive set', () => {
expect(humanizeNumberRange({ minimum: 0, maximum: 10 })).toEqual('[ 0 .. 10 ]');
expect(
humanizeNumberRange({
minimum: 0,
exclusiveMinimum: true,
maximum: 10,
exclusiveMaximum: true,
}),
).toEqual('( 0 .. 10 )');
expect(
humanizeNumberRange({
minimum: 0,
maximum: 10,
exclusiveMaximum: true,
}),
).toEqual('[ 0 .. 10 )');
expect(
humanizeNumberRange({
minimum: 0,
exclusiveMinimum: true,
maximum: 10,
}),
).toEqual('( 0 .. 10 ]');
});

it('should return correct range exclusive values only', () => {
expect(humanizeNumberRange({ exclusiveMinimum: 0 })).toEqual('> 0');
expect(humanizeNumberRange({ exclusiveMaximum: 10 })).toEqual('< 10');
expect(humanizeNumberRange({ exclusiveMinimum: 0, exclusiveMaximum: 10 })).toEqual(
'( 0 .. 10 )',
);
});

it('should return correct min value', () => {
expect(humanizeNumberRange({ minimum: 5, exclusiveMinimum: 10 })).toEqual('> 5');
});

it('should return correct max value', () => {
expect(humanizeNumberRange({ maximum: 10, exclusiveMaximum: 15 })).toEqual('< 15');
});

it('should return undefined', () => {
expect(humanizeNumberRange({})).toEqual(undefined);
});
});

describe('openapi humanizeConstraints', () => {
const itemConstraintSchema = (
min?: number,
max?: number,
multipleOf?: number,
uniqueItems?: boolean
uniqueItems?: boolean,
) => ({ type: 'array', minItems: min, maxItems: max, multipleOf, uniqueItems });

it('should not have a humanized constraint without schema constraints', () => {
Expand Down Expand Up @@ -455,9 +524,9 @@ describe('Utils', () => {
});

it('should have a humanized constraint when uniqueItems is set', () => {
expect(humanizeConstraints(itemConstraintSchema(undefined, undefined, undefined, true))).toContain(
'unique',
);
expect(
humanizeConstraints(itemConstraintSchema(undefined, undefined, undefined, true)),
).toContain('unique');
});
});

Expand Down Expand Up @@ -656,11 +725,11 @@ describe('Utils', () => {
},
];

testCases.forEach(locationTestGroup => {
testCases.forEach((locationTestGroup) => {
describe(locationTestGroup.description, () => {
locationTestGroup.cases.forEach(valueTypeTestGroup => {
locationTestGroup.cases.forEach((valueTypeTestGroup) => {
describe(valueTypeTestGroup.description, () => {
valueTypeTestGroup.cases.forEach(testCase => {
valueTypeTestGroup.cases.forEach((testCase) => {
it(`should serialize correctly when style is ${testCase.style} and explode is ${testCase.explode}`, () => {
const parameter: OpenAPIParameter = {
name: locationTestGroup.name,
Expand Down
97 changes: 49 additions & 48 deletions src/utils/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ export function detectType(schema: OpenAPISchema): string {
return 'any';
}

export function isPrimitiveType(schema: OpenAPISchema, type: string | string[] | undefined = schema.type) {
export function isPrimitiveType(
schema: OpenAPISchema,
type: string | string[] | undefined = schema.type,
) {
if (schema.oneOf !== undefined || schema.anyOf !== undefined) {
return false;
}
Expand All @@ -122,9 +125,10 @@ export function isPrimitiveType(schema: OpenAPISchema, type: string | string[] |
const isArray = Array.isArray(type);

if (type === 'object' || (isArray && type?.includes('object'))) {
isPrimitive = schema.properties !== undefined
? Object.keys(schema.properties).length === 0
: schema.additionalProperties === undefined;
isPrimitive =
schema.properties !== undefined
? Object.keys(schema.properties).length === 0
: schema.additionalProperties === undefined;
}

if (schema.items !== undefined && (type === 'array' || (isArray && type?.includes('array')))) {
Expand All @@ -144,10 +148,10 @@ export function isFormUrlEncoded(contentType: string): boolean {

function delimitedEncodeField(fieldVal: any, fieldName: string, delimiter: string): string {
if (Array.isArray(fieldVal)) {
return fieldVal.map(v => v.toString()).join(delimiter);
return fieldVal.map((v) => v.toString()).join(delimiter);
} else if (typeof fieldVal === 'object') {
return Object.keys(fieldVal)
.map(k => `${k}${delimiter}${fieldVal[k]}`)
.map((k) => `${k}${delimiter}${fieldVal[k]}`)
.join(delimiter);
} else {
return fieldName + '=' + fieldVal.toString();
Expand All @@ -160,7 +164,7 @@ function deepObjectEncodeField(fieldVal: any, fieldName: string): string {
return '';
} else if (typeof fieldVal === 'object') {
return Object.keys(fieldVal)
.map(k => `${fieldName}[${k}]=${fieldVal[k]}`)
.map((k) => `${fieldName}[${k}]=${fieldVal[k]}`)
.join('&');
} else {
console.warn('deepObject style cannot be used with non-object value:' + fieldVal.toString());
Expand Down Expand Up @@ -192,7 +196,7 @@ export function urlFormEncodePayload(
throw new Error('Payload must have fields: ' + payload.toString());
} else {
return Object.keys(payload)
.map(fieldName => {
.map((fieldName) => {
const fieldVal = payload[fieldName];
const { style = 'form', explode = true } = encoding[fieldName] || {};
switch (style) {
Expand Down Expand Up @@ -376,7 +380,7 @@ export function isNamedDefinition(pointer?: string): boolean {
export function getDefinitionName(pointer?: string): string | undefined {
if (!pointer) return undefined;
const match = pointer.match(/^#\/components\/(schemas|pathItems)\/([^\/]+)$/);
return match === null ? undefined : match[1]
return match === null ? undefined : match[1];
}

function humanizeMultipleOfConstraint(multipleOf: number | undefined): string | undefined {
Expand Down Expand Up @@ -415,6 +419,29 @@ function humanizeRangeConstraint(
return stringRange;
}

export function humanizeNumberRange(schema: OpenAPISchema): string | undefined {
const minimum =
typeof schema.exclusiveMinimum === 'number'
? Math.min(schema.exclusiveMinimum, schema.minimum ?? Infinity)
: schema.minimum;
const maximum =
typeof schema.exclusiveMaximum === 'number'
? Math.max(schema.exclusiveMaximum, schema.maximum ?? -Infinity)
: schema.maximum;
const exclusiveMinimum = typeof schema.exclusiveMinimum === 'number' || schema.exclusiveMinimum;
const exclusiveMaximum = typeof schema.exclusiveMaximum === 'number' || schema.exclusiveMaximum;

if (minimum !== undefined && maximum !== undefined) {
return `${exclusiveMinimum ? '( ' : '[ '}${minimum} .. ${maximum}${
exclusiveMaximum ? ' )' : ' ]'
}`;
} else if (maximum !== undefined) {
return `${exclusiveMaximum ? '< ' : '<= '}${maximum}`;
} else if (minimum !== undefined) {
return `${exclusiveMinimum ? '> ' : '>= '}${minimum}`;
}
}

export function humanizeConstraints(schema: OpenAPISchema): string[] {
const res: string[] = [];

Expand All @@ -433,33 +460,7 @@ export function humanizeConstraints(schema: OpenAPISchema): string[] {
res.push(multipleOfConstraint);
}

let numberRange;
if (schema.minimum !== undefined && schema.maximum !== undefined) {
numberRange = schema.exclusiveMinimum ? '( ' : '[ ';
numberRange += schema.minimum;
numberRange += ' .. ';
numberRange += schema.maximum;
numberRange += schema.exclusiveMaximum ? ' )' : ' ]';
} else if (schema.maximum !== undefined) {
numberRange = schema.exclusiveMaximum ? '< ' : '<= ';
numberRange += schema.maximum;
} else if (schema.minimum !== undefined) {
numberRange = schema.exclusiveMinimum ? '> ' : '>= ';
numberRange += schema.minimum;
}

if (typeof schema.exclusiveMinimum === 'number' || typeof schema.exclusiveMaximum === 'number') {
let minimum = 0;
let maximum = 0;
if (schema.minimum) minimum = schema.minimum;
if (typeof schema.exclusiveMinimum === 'number') minimum = minimum <= schema.exclusiveMinimum ? minimum : schema.exclusiveMinimum;

if (schema.maximum) maximum = schema.maximum;
if (typeof schema.exclusiveMaximum === 'number') maximum = maximum > schema.exclusiveMaximum ? maximum : schema.exclusiveMaximum;

numberRange = `[${minimum} .. ${maximum}]`
}

const numberRange = humanizeNumberRange(schema);
if (numberRange !== undefined) {
res.push(numberRange);
}
Expand All @@ -476,7 +477,7 @@ export function sortByRequired(fields: FieldModel[], order: string[] = []) {
const orderedFields: FieldModel[] = [];
const unorderedFields: FieldModel[] = [];

fields.forEach(field => {
fields.forEach((field) => {
if (field.required) {
order.includes(field.name) ? orderedFields.push(field) : unorderedFields.push(field);
} else {
Expand Down Expand Up @@ -504,13 +505,13 @@ export function mergeParams(
operationParams: Array<Referenced<OpenAPIParameter>> = [],
): Array<Referenced<OpenAPIParameter>> {
const operationParamNames = {};
operationParams.forEach(param => {
operationParams.forEach((param) => {
param = parser.shallowDeref(param);
operationParamNames[param.name + '_' + param.in] = true;
});

// filter out path params overridden by operation ones with the same name
pathParams = pathParams.filter(param => {
pathParams = pathParams.filter((param) => {
param = parser.shallowDeref(param);
return !operationParamNames[param.name + '_' + param.in];
});
Expand All @@ -522,7 +523,7 @@ export function mergeSimilarMediaTypes(
types: Record<string, OpenAPIMediaType>,
): Record<string, OpenAPIMediaType> {
const mergedTypes = {};
Object.keys(types).forEach(name => {
Object.keys(types).forEach((name) => {
const mime = types[name];
// ignore content type parameters (e.g. charset) and merge
const normalizedMimeName = name.split(';')[0].trim();
Expand Down Expand Up @@ -570,7 +571,7 @@ export function normalizeServers(
return resolveUrl(baseUrl, url);
}

return servers.map(server => {
return servers.map((server) => {
return {
...server,
url: normalizeUrl(server.url),
Expand All @@ -588,11 +589,11 @@ export function setSecuritySchemePrefix(prefix: string) {
SECURITY_SCHEMES_SECTION_PREFIX = prefix;
}

export const shortenHTTPVerb = verb =>
({
delete: 'del',
options: 'opts',
}[verb] || verb);
export const shortenHTTPVerb = (verb) =>
({
delete: 'del',
options: 'opts',
}[verb] || verb);

export function isRedocExtension(key: string): boolean {
const redocExtensions = {
Expand All @@ -619,7 +620,7 @@ export function extractExtensions(
showExtensions: string[] | true,
): Record<string, any> {
return Object.keys(obj)
.filter(key => {
.filter((key) => {
if (showExtensions === true) {
return key.startsWith('x-') && !isRedocExtension(key);
}
Expand All @@ -634,6 +635,6 @@ export function extractExtensions(
export function pluralizeType(displayType: string): string {
return displayType
.split(' or ')
.map(type => type.replace(/^(string|object|number|integer|array|boolean)s?( ?.*)/, '$1s$2'))
.map((type) => type.replace(/^(string|object|number|integer|array|boolean)s?( ?.*)/, '$1s$2'))
.join(' or ');
}