mirror of
https://github.com/Redocly/redoc.git
synced 2024-11-23 09:03:44 +03:00
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:
parent
4fb5f914fa
commit
b604bd8da8
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
@ -0,0 +1 @@
|
|||
*.md
|
|
@ -1,27 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>%PAGE_TITLE%</title>
|
||||
<link rel="icon" href="%PAGE_FAVICON%" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%PAGE_TITLE%</title>
|
||||
<link rel="icon" href="%PAGE_FAVICON%">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
redoc {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<redoc spec-url="%SPEC_URL%" %REDOC_OPTIONS%></redoc>
|
||||
<script src="redoc.standalone.js"></script>
|
||||
</body>
|
||||
redoc {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<redoc spec-url="%SPEC_URL%" %REDOC_OPTIONS%></redoc>
|
||||
<script src="redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -29,13 +29,13 @@ 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)
|
||||
- OpenAPI 2.0
|
||||
- [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)
|
||||
|
||||
- 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)
|
||||
- OpenAPI 2.0
|
||||
- [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/)
|
||||
|
|
|
@ -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
|
||||
|
@ -28,17 +31,16 @@ replace the `spec-url` attribute with the URL or local file address to your defi
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
<!--
|
||||
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
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
@ -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),
|
||||
}}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -42,7 +42,7 @@ export class Operation extends React.Component<OperationProps> {
|
|||
|
||||
return (
|
||||
<OptionsContext.Consumer>
|
||||
{(options) => (
|
||||
{options => (
|
||||
<OperationRow>
|
||||
<MiddlePanel>
|
||||
<H2>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
`}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user