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>
<html>
<head>
<head>
<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>
<link rel="icon" href="%PAGE_FAVICON%">
<link rel="icon" href="%PAGE_FAVICON%" />
<style>
body {
margin: 0;
@ -16,12 +15,14 @@
display: block;
}
</style>
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
</head>
<link
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>
<script src="redoc.standalone.js"></script>
</body>
</body>
</html>

View File

@ -29,6 +29,7 @@ The following options are supported:
### OpenAPI definition
You will need an OpenAPI definition. For testing purposes, you can use one of the following sample OpenAPI definitions:
- OpenAPI 3.0
- [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)
@ -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)
- [Fitbit Plus OpenAPI Definition](https://raw.githubusercontent.com/TwineHealth/TwineDeveloperDocs/master/spec/swagger.yaml)
:::info OpenAPI specification
For more information on the OpenAPI specification, refer to the [Learning OpenAPI 3](https://redoc.ly/docs/resources/learning-openapi/)
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>
<title>Redoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<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">
<meta charset="utf-8" />
<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"
/>
<!--
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 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
-->
<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>
</html>
```
:::attention Running Redoc locally requires an HTTP server

View File

@ -28,16 +28,16 @@ export const StyledDropdown = styled(Dropdown)`
width: auto;
background: white;
color: #263238;
font-family: ${(props) => props.theme.typography.headings.fontFamily};
font-family: ${props => props.theme.typography.headings.fontFamily};
font-size: 0.929em;
line-height: 1.5em;
cursor: pointer;
transition: border 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
&:hover,
&:focus-within {
border: 1px solid ${(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};
border: 1px solid ${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};
}
.dropdown-selector {
display: inline-flex;
@ -48,7 +48,7 @@ export const StyledDropdown = styled(Dropdown)`
margin-bottom: 5px;
}
.dropdown-selector-value {
font-family: ${(props) => props.theme.typography.headings.fontFamily};
font-family: ${props => props.theme.typography.headings.fontFamily};
position: relative;
font-size: 0.929em;
width: 100%;
@ -63,7 +63,7 @@ export const StyledDropdown = styled(Dropdown)`
right: 3px;
top: 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-width: 0.35em 0.35em 0;
width: 0;
@ -128,8 +128,8 @@ export const SimpleDropdown = styled(StyledDropdown)`
border: none;
box-shadow: none;
.dropdown-selector-value {
color: ${(props) => props.theme.colors.primary.main};
text-shadow: 0px 0px 0px ${(props) => props.theme.colors.primary.main};
color: ${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 }>`
text-decoration: ${(props) => (props.deprecated ? 'line-through' : 'none')};
text-decoration: ${props => (props.deprecated ? 'line-through' : 'none')};
margin-right: 8px;
`;
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>
</SampleControls>
<OptionsContext.Consumer>
{(options) => (
{options => (
<PrismDiv
className={this.props.className}
// tslint:disable-next-line
ref={(node) => (this.node = node!)}
ref={node => (this.node = node!)}
dangerouslySetInnerHTML={{
__html: jsonToHTML(this.props.data, options.jsonSampleExpandLevel),
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import {
pluralizeType,
serializeParameterValue,
sortByRequired,
humanizeNumberRange,
} from '../';
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', () => {
const itemConstraintSchema = (
min?: number,

View File

@ -419,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[] = [];
@ -437,35 +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);
}