fix: exclusiveMin/Max shows incorect range (#1799)

* fix: exclusiveMin/Max shows incorect range

* cover all number range cases & add unit tests

* add more tests

* fix maximum value

* simplify humanizeNumberRange function

* simplify exclusive checks

* Update src/utils/openapi.ts

Co-authored-by: Roman Hotsiy <gotsijroman@gmail.com>

* update test coverage

* linting

* revert weird prettier changes

* add md files to prettier ignore

Co-authored-by: Roman Hotsiy <gotsijroman@gmail.com>
This commit is contained in:
Oprysk Vyacheslav 2021-11-24 16:47:24 +02:00 committed by GitHub
parent 4fb5f914fa
commit b604bd8da8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 164 additions and 94 deletions

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
*.md

View File

@ -1,11 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>%PAGE_TITLE%</title> <title>%PAGE_TITLE%</title>
<link rel="icon" href="%PAGE_FAVICON%"> <link rel="icon" href="%PAGE_FAVICON%" />
<style> <style>
body { body {
margin: 0; margin: 0;
@ -16,12 +15,14 @@
display: block; display: block;
} }
</style> </style>
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet"> <link
</head> href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
rel="stylesheet"
/>
</head>
<body> <body>
<redoc spec-url="%SPEC_URL%" %REDOC_OPTIONS%></redoc> <redoc spec-url="%SPEC_URL%" %REDOC_OPTIONS%></redoc>
<script src="redoc.standalone.js"></script> <script src="redoc.standalone.js"></script>
</body> </body>
</html> </html>

View File

@ -29,6 +29,7 @@ The following options are supported:
### OpenAPI definition ### OpenAPI definition
You will need an OpenAPI definition. For testing purposes, you can use one of the following sample OpenAPI definitions: You will need an OpenAPI definition. For testing purposes, you can use one of the following sample OpenAPI definitions:
- OpenAPI 3.0 - OpenAPI 3.0
- [Rebilly Users OpenAPI Definition](https://raw.githubusercontent.com/Rebilly/api-definitions/main/openapi/users.yaml) - [Rebilly Users OpenAPI Definition](https://raw.githubusercontent.com/Rebilly/api-definitions/main/openapi/users.yaml)
- [Swagger Petstore Sample OpenAPI Definition](https://petstore3.swagger.io/api/v3/openapi.json) - [Swagger Petstore Sample OpenAPI Definition](https://petstore3.swagger.io/api/v3/openapi.json)
@ -36,7 +37,6 @@ You will need an OpenAPI definition. For testing purposes, you can use one of th
- [Thingful OpenAPI Definition](https://raw.githubusercontent.com/thingful/openapi-spec/master/spec/swagger.yaml) - [Thingful OpenAPI Definition](https://raw.githubusercontent.com/thingful/openapi-spec/master/spec/swagger.yaml)
- [Fitbit Plus OpenAPI Definition](https://raw.githubusercontent.com/TwineHealth/TwineDeveloperDocs/master/spec/swagger.yaml) - [Fitbit Plus OpenAPI Definition](https://raw.githubusercontent.com/TwineHealth/TwineDeveloperDocs/master/spec/swagger.yaml)
:::info OpenAPI specification :::info OpenAPI specification
For more information on the OpenAPI specification, refer to the [Learning OpenAPI 3](https://redoc.ly/docs/resources/learning-openapi/) For more information on the OpenAPI specification, refer to the [Learning OpenAPI 3](https://redoc.ly/docs/resources/learning-openapi/)
section in the documentation. section in the documentation.

View File

@ -13,9 +13,12 @@ replace the `spec-url` attribute with the URL or local file address to your defi
<head> <head>
<title>Redoc</title> <title>Redoc</title>
<!-- needed for adaptive design --> <!-- needed for adaptive design -->
<meta charset="utf-8"/> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
rel="stylesheet"
/>
<!-- <!--
Redoc doesn't change outer page styles Redoc doesn't change outer page styles
@ -31,14 +34,13 @@ replace the `spec-url` attribute with the URL or local file address to your defi
<!-- <!--
Redoc element with link to your OpenAPI definition Redoc element with link to your OpenAPI definition
--> -->
<redoc spec-url='http://petstore.swagger.io/v2/swagger.json'></redoc> <redoc spec-url="http://petstore.swagger.io/v2/swagger.json"></redoc>
<!-- <!--
Link to Redoc JavaScript on CDN for rendering standalone element Link to Redoc JavaScript on CDN for rendering standalone element
--> -->
<script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"> </script> <script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"></script>
</body> </body>
</html> </html>
``` ```
:::attention Running Redoc locally requires an HTTP server :::attention Running Redoc locally requires an HTTP server

View File

@ -28,16 +28,16 @@ export const StyledDropdown = styled(Dropdown)`
width: auto; width: auto;
background: white; background: white;
color: #263238; color: #263238;
font-family: ${(props) => props.theme.typography.headings.fontFamily}; font-family: ${props => props.theme.typography.headings.fontFamily};
font-size: 0.929em; font-size: 0.929em;
line-height: 1.5em; line-height: 1.5em;
cursor: pointer; cursor: pointer;
transition: border 0.25s ease, color 0.25s ease, box-shadow 0.25s ease; transition: border 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
&:hover, &:hover,
&:focus-within { &:focus-within {
border: 1px solid ${(props) => props.theme.colors.primary.main}; border: 1px solid ${props => props.theme.colors.primary.main};
color: ${(props) => props.theme.colors.primary.main}; color: ${props => props.theme.colors.primary.main};
box-shadow: 0px 0px 0px 1px ${(props) => props.theme.colors.primary.main}; box-shadow: 0px 0px 0px 1px ${props => props.theme.colors.primary.main};
} }
.dropdown-selector { .dropdown-selector {
display: inline-flex; display: inline-flex;
@ -48,7 +48,7 @@ export const StyledDropdown = styled(Dropdown)`
margin-bottom: 5px; margin-bottom: 5px;
} }
.dropdown-selector-value { .dropdown-selector-value {
font-family: ${(props) => props.theme.typography.headings.fontFamily}; font-family: ${props => props.theme.typography.headings.fontFamily};
position: relative; position: relative;
font-size: 0.929em; font-size: 0.929em;
width: 100%; width: 100%;
@ -63,7 +63,7 @@ export const StyledDropdown = styled(Dropdown)`
right: 3px; right: 3px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
border-color: ${(props) => props.theme.colors.primary.main} transparent transparent; border-color: ${props => props.theme.colors.primary.main} transparent transparent;
border-style: solid; border-style: solid;
border-width: 0.35em 0.35em 0; border-width: 0.35em 0.35em 0;
width: 0; width: 0;
@ -128,8 +128,8 @@ export const SimpleDropdown = styled(StyledDropdown)`
border: none; border: none;
box-shadow: none; box-shadow: none;
.dropdown-selector-value { .dropdown-selector-value {
color: ${(props) => props.theme.colors.primary.main}; color: ${props => props.theme.colors.primary.main};
text-shadow: 0px 0px 0px ${(props) => props.theme.colors.primary.main}; text-shadow: 0px 0px 0px ${props => props.theme.colors.primary.main};
} }
} }
} }

View File

@ -48,10 +48,10 @@ const CallbackTitleWrapper = styled.button`
`; `;
const CallbackName = styled.span<{ deprecated?: boolean }>` const CallbackName = styled.span<{ deprecated?: boolean }>`
text-decoration: ${(props) => (props.deprecated ? 'line-through' : 'none')}; text-decoration: ${props => (props.deprecated ? 'line-through' : 'none')};
margin-right: 8px; margin-right: 8px;
`; `;
const OperationBadgeStyled = styled(OperationBadge)` const OperationBadgeStyled = styled(OperationBadge)`
margin: 0px 5px 0px 0px; margin: 0 5px 0 0;
`; `;

View File

@ -34,11 +34,11 @@ class Json extends React.PureComponent<JsonProps> {
<button onClick={this.collapseAll}> Collapse all </button> <button onClick={this.collapseAll}> Collapse all </button>
</SampleControls> </SampleControls>
<OptionsContext.Consumer> <OptionsContext.Consumer>
{(options) => ( {options => (
<PrismDiv <PrismDiv
className={this.props.className} className={this.props.className}
// tslint:disable-next-line // tslint:disable-next-line
ref={(node) => (this.node = node!)} ref={node => (this.node = node!)}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: jsonToHTML(this.props.data, options.jsonSampleExpandLevel), __html: jsonToHTML(this.props.data, options.jsonSampleExpandLevel),
}} }}

View File

@ -6,8 +6,8 @@ export const jsonStyles = css`
pointer-events: none; pointer-events: none;
} }
font-family: ${(props) => props.theme.typography.code.fontFamily}; font-family: ${props => props.theme.typography.code.fontFamily};
font-size: ${(props) => props.theme.typography.code.fontSize}; font-size: ${props => props.theme.typography.code.fontSize};
white-space: ${({ theme }) => (theme.typography.code.wrap ? 'pre-wrap' : 'pre')}; white-space: ${({ theme }) => (theme.typography.code.wrap ? 'pre-wrap' : 'pre')};
contain: content; contain: content;
@ -51,8 +51,8 @@ export const jsonStyles = css`
background-color: transparent; background-color: transparent;
border: 0; border: 0;
color: #fff; color: #fff;
font-family: ${(props) => props.theme.typography.code.fontFamily}; font-family: ${props => props.theme.typography.code.fontFamily};
font-size: ${(props) => props.theme.typography.code.fontSize}; font-size: ${props => props.theme.typography.code.fontSize};
padding-right: 6px; padding-right: 6px;
padding-left: 6px; padding-left: 6px;
padding-top: 0; padding-top: 0;

View File

@ -42,7 +42,7 @@ export class Operation extends React.Component<OperationProps> {
return ( return (
<OptionsContext.Consumer> <OptionsContext.Consumer>
{(options) => ( {options => (
<OperationRow> <OperationRow>
<MiddlePanel> <MiddlePanel>
<H2> <H2>

View File

@ -14,13 +14,13 @@ export const StyledResponseTitle = styled(ResponseTitle)`
background-color: #f2f2f2; background-color: #f2f2f2;
cursor: pointer; cursor: pointer;
color: ${(props) => props.theme.colors.responses[props.type].color}; color: ${props => props.theme.colors.responses[props.type].color};
background-color: ${(props) => props.theme.colors.responses[props.type].backgroundColor}; background-color: ${props => props.theme.colors.responses[props.type].backgroundColor};
&:focus { &:focus {
outline: auto; outline: auto;
outline-color: ${(props) => props.theme.colors.responses[props.type].color}; outline-color: ${props => props.theme.colors.responses[props.type].color};
} }
${(props) => ${props =>
(props.empty && (props.empty &&
` `
cursor: default; cursor: default;

View File

@ -7,8 +7,8 @@ import { SecurityRequirementModel } from '../../services/models/SecurityRequirem
import { linksCss } from '../Markdown/styled.elements'; import { linksCss } from '../Markdown/styled.elements';
const ScopeName = styled.code` const ScopeName = styled.code`
font-size: ${(props) => props.theme.typography.code.fontSize}; font-size: ${props => props.theme.typography.code.fontSize};
font-family: ${(props) => props.theme.typography.code.fontFamily}; font-family: ${props => props.theme.typography.code.fontFamily};
border: 1px solid ${({ theme }) => theme.colors.border.dark}; border: 1px solid ${({ theme }) => theme.colors.border.dark};
margin: 0 3px; margin: 0 3px;
padding: 0.2em; padding: 0.2em;
@ -67,12 +67,12 @@ export class SecurityRequirement extends React.PureComponent<SecurityRequirement
return ( return (
<SecurityRequirementOrWrap> <SecurityRequirementOrWrap>
{security.schemes.length ? ( {security.schemes.length ? (
security.schemes.map((scheme) => { security.schemes.map(scheme => {
return ( return (
<SecurityRequirementAndWrap key={scheme.id}> <SecurityRequirementAndWrap key={scheme.id}>
<Link to={scheme.sectionId}>{scheme.id}</Link> <Link to={scheme.sectionId}>{scheme.id}</Link>
{scheme.scopes.length > 0 && ' ('} {scheme.scopes.length > 0 && ' ('}
{scheme.scopes.map((scope) => ( {scheme.scopes.map(scope => (
<ScopeName key={scope}>{scope}</ScopeName> <ScopeName key={scope}>{scope}</ScopeName>
))} ))}
{scheme.scopes.length > 0 && ') '} {scheme.scopes.length > 0 && ') '}
@ -92,7 +92,7 @@ const AuthHeaderColumn = styled.div`
`; `;
const SecuritiesColumn = styled.div` const SecuritiesColumn = styled.div`
width: ${(props) => props.theme.schema.defaultDetailsWidth}; width: ${props => props.theme.schema.defaultDetailsWidth};
${media.lessThan('small')` ${media.lessThan('small')`
margin-top: 10px; margin-top: 10px;
`} `}

View File

@ -72,7 +72,7 @@ export class RedocNormalizedOptions {
} }
if (typeof value === 'string') { if (typeof value === 'string') {
const res = {}; const res = {};
value.split(',').forEach((code) => { value.split(',').forEach(code => {
res[code.trim()] = true; res[code.trim()] = true;
}); });
return res; return res;
@ -138,7 +138,7 @@ export class RedocNormalizedOptions {
case 'false': case 'false':
return false; return false;
default: default:
return value.split(',').map((ext) => ext.trim()); return value.split(',').map(ext => ext.trim());
} }
} }
@ -266,7 +266,7 @@ export class RedocNormalizedOptions {
this.maxDisplayedEnumValues = argValueToNumber(raw.maxDisplayedEnumValues); this.maxDisplayedEnumValues = argValueToNumber(raw.maxDisplayedEnumValues);
const ignoreNamedSchemas = Array.isArray(raw.ignoreNamedSchemas) const ignoreNamedSchemas = Array.isArray(raw.ignoreNamedSchemas)
? raw.ignoreNamedSchemas ? raw.ignoreNamedSchemas
: raw.ignoreNamedSchemas?.split(',').map((s) => s.trim()); : raw.ignoreNamedSchemas?.split(',').map(s => s.trim());
this.ignoreNamedSchemas = new Set(ignoreNamedSchemas); this.ignoreNamedSchemas = new Set(ignoreNamedSchemas);
this.hideSchemaPattern = argValueToBoolean(raw.hideSchemaPattern); this.hideSchemaPattern = argValueToBoolean(raw.hideSchemaPattern);
this.generatedPayloadSamplesMaxDepth = this.generatedPayloadSamplesMaxDepth =

View File

@ -10,6 +10,7 @@ import {
pluralizeType, pluralizeType,
serializeParameterValue, serializeParameterValue,
sortByRequired, sortByRequired,
humanizeNumberRange,
} from '../'; } from '../';
import { FieldModel, OpenAPIParser, RedocNormalizedOptions } from '../../services'; import { FieldModel, OpenAPIParser, RedocNormalizedOptions } from '../../services';
@ -410,6 +411,76 @@ 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');
expect(humanizeNumberRange({ minimum: -5, exclusiveMinimum: -10 })).toEqual('> -10');
});
it('should return correct max value', () => {
expect(humanizeNumberRange({ maximum: 10, exclusiveMaximum: 15 })).toEqual('< 15');
expect(humanizeNumberRange({ maximum: -10, exclusiveMaximum: -15 })).toEqual('< -10');
});
it('should return undefined', () => {
expect(humanizeNumberRange({})).toEqual(undefined);
});
});
describe('openapi humanizeConstraints', () => { describe('openapi humanizeConstraints', () => {
const itemConstraintSchema = ( const itemConstraintSchema = (
min?: number, min?: number,

View File

@ -419,6 +419,29 @@ function humanizeRangeConstraint(
return stringRange; 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[] { export function humanizeConstraints(schema: OpenAPISchema): string[] {
const res: string[] = []; const res: string[] = [];
@ -437,35 +460,7 @@ export function humanizeConstraints(schema: OpenAPISchema): string[] {
res.push(multipleOfConstraint); res.push(multipleOfConstraint);
} }
let numberRange; const numberRange = humanizeNumberRange(schema);
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}]`;
}
if (numberRange !== undefined) { if (numberRange !== undefined) {
res.push(numberRange); res.push(numberRange);
} }