feat: add basic support openApi 3.1 (#1622)

This commit is contained in:
AlexVarchuk 2021-05-31 19:23:05 +03:00 committed by GitHub
parent 4b072be8d1
commit 823be24b31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 3455 additions and 66 deletions

View File

@ -5,7 +5,11 @@ import { resolve as urlResolve } from 'url';
import { RedocStandalone } from '../src'; import { RedocStandalone } from '../src';
import ComboBox from './ComboBox'; import ComboBox from './ComboBox';
const DEFAULT_SPEC = 'openapi.yaml';
const NEW_VERSION_SPEC = 'openapi-3-1.yaml';
const demos = [ const demos = [
{ value: NEW_VERSION_SPEC, label: 'Petstore OpenAPI 3.1' },
{ value: 'https://api.apis.guru/v2/specs/instagram.com/1.0.0/swagger.yaml', label: 'Instagram' }, { value: 'https://api.apis.guru/v2/specs/instagram.com/1.0.0/swagger.yaml', label: 'Instagram' },
{ {
value: 'https://api.apis.guru/v2/specs/googleapis.com/calendar/v3/openapi.yaml', value: 'https://api.apis.guru/v2/specs/googleapis.com/calendar/v3/openapi.yaml',
@ -16,8 +20,6 @@ const demos = [
{ value: 'https://docs.graphhopper.com/openapi.json', label: 'GraphHopper' }, { value: 'https://docs.graphhopper.com/openapi.json', label: 'GraphHopper' },
]; ];
const DEFAULT_SPEC = 'openapi.yaml';
class DemoApp extends React.Component< class DemoApp extends React.Component<
{}, {},
{ specUrl: string; dropdownOpen: boolean; cors: boolean } { specUrl: string; dropdownOpen: boolean; cors: boolean }
@ -45,6 +47,9 @@ class DemoApp extends React.Component<
} }
handleChange = (url: string) => { handleChange = (url: string) => {
if (url === NEW_VERSION_SPEC) {
this.setState({ cors: false })
}
this.setState({ this.setState({
specUrl: url, specUrl: url,
}); });

1249
demo/openapi-3-1.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -16,5 +16,6 @@ describe('Standalone bundle test', () => {
} }
baseCheck('OAS3 mode', 'e2e/standalone.html'); baseCheck('OAS3 mode', 'e2e/standalone.html');
baseCheck('OAS3.1 mode', 'e2e/standalone-3-1.html');
baseCheck('OAS2 compatibility mode', 'e2e/standalone-compatibility.html'); baseCheck('OAS2 compatibility mode', 'e2e/standalone-compatibility.html');
}); });

8
e2e/standalone-3-1.html Normal file
View File

@ -0,0 +1,8 @@
<html>
<body>
<redoc spec-url="../demo/openapi-3-1.yaml" native-scrollbars></redoc>
<script src="../bundles/redoc.standalone.js"></script>
</body>
</html>

24
package-lock.json generated
View File

@ -1222,9 +1222,9 @@
} }
}, },
"@redocly/openapi-core": { "@redocly/openapi-core": {
"version": "1.0.0-beta.44", "version": "1.0.0-beta.48",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.0.0-beta.44.tgz", "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.0.0-beta.48.tgz",
"integrity": "sha512-9HNnh1MzvMsLK1liuidFBqWiAsZ2Yg3RY58fcEsy0QruSMdDbn7SoeI1qnXe6O+BkBS+vAP4oVzZDMHCMKGsOQ==", "integrity": "sha512-rlus9qQC4Pkzz2Ljcv+jQjFdKOYSWnsYXWN6zNik9iiiQtMmGEdszsERCbSAYw/CZ5DRCAHMeKrh8f4LBCpx1A==",
"requires": { "requires": {
"@redocly/ajv": "^6.12.3", "@redocly/ajv": "^6.12.3",
"@types/node": "^14.11.8", "@types/node": "^14.11.8",
@ -1238,9 +1238,9 @@
}, },
"dependencies": { "dependencies": {
"@types/node": { "@types/node": {
"version": "14.14.37", "version": "14.17.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.1.tgz",
"integrity": "sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==" "integrity": "sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw=="
} }
} }
}, },
@ -1459,8 +1459,7 @@
"@types/json-schema": { "@types/json-schema": {
"version": "7.0.7", "version": "7.0.7",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA=="
"dev": true
}, },
"@types/json5": { "@types/json5": {
"version": "0.0.29", "version": "0.0.29",
@ -11693,11 +11692,12 @@
"dev": true "dev": true
}, },
"openapi-sampler": { "openapi-sampler": {
"version": "1.0.0-beta.18", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.0.0-beta.18.tgz", "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.0.0.tgz",
"integrity": "sha512-nG/0kvvSY5FbrU5A+Dbp1xTQN++7pKIh87/atryZlxrzDuok5Y6TCbpxO1jYqpUKLycE4ReKGHCywezngG6xtQ==", "integrity": "sha512-HysKj4ZuLk0RpZkopao5SIupUX8LMOTsEDTw9dSzcRv6BBW6Ep1IdbKwYsCrYM9tnw4VZtebR/N5sJHY6qqRew==",
"requires": { "requires": {
"json-pointer": "^0.6.0" "@types/json-schema": "^7.0.7",
"json-pointer": "^0.6.1"
} }
}, },
"opn": { "opn": {

View File

@ -134,7 +134,7 @@
"styled-components": "^4.1.1 || ^5.1.1" "styled-components": "^4.1.1 || ^5.1.1"
}, },
"dependencies": { "dependencies": {
"@redocly/openapi-core": "^1.0.0-beta.44", "@redocly/openapi-core": "^1.0.0-beta.48",
"@redocly/react-dropdown-aria": "^2.0.11", "@redocly/react-dropdown-aria": "^2.0.11",
"@types/node": "^13.11.1", "@types/node": "^13.11.1",
"classnames": "^2.2.6", "classnames": "^2.2.6",
@ -147,7 +147,7 @@
"marked": "^0.7.0", "marked": "^0.7.0",
"memoize-one": "~5.1.1", "memoize-one": "~5.1.1",
"mobx-react": "^7.0.5", "mobx-react": "^7.0.5",
"openapi-sampler": "^1.0.0-beta.18", "openapi-sampler": "^1.0.0",
"perfect-scrollbar": "^1.4.0", "perfect-scrollbar": "^1.4.0",
"polished": "^3.6.5", "polished": "^3.6.5",
"prismjs": "^1.22.0", "prismjs": "^1.22.0",

View File

@ -61,11 +61,6 @@ export const RecursiveLabel = styled(FieldLabel)`
font-size: 13px; font-size: 13px;
`; `;
export const NullableLabel = styled(FieldLabel)`
color: #0e7c86;
font-size: 13px;
`;
export const PatternLabel = styled(FieldLabel)` export const PatternLabel = styled(FieldLabel)`
color: #0e7c86; color: #0e7c86;
&::before, &::before,

View File

@ -38,7 +38,7 @@ export class ApiInfo extends React.Component<ApiInfoProps> {
const license = const license =
(info.license && ( (info.license && (
<InfoSpan> <InfoSpan>
License: <a href={info.license.url}>{info.license.name}</a> License: {info.license.identifier ? info.license.identifier : (<a href={info.license.url}>{info.license.name}</a>)}
</InfoSpan> </InfoSpan>
)) || )) ||
null; null;
@ -100,7 +100,8 @@ export class ApiInfo extends React.Component<ApiInfoProps> {
)) || )) ||
null} null}
</StyledMarkdownBlock> </StyledMarkdownBlock>
<Markdown source={store.spec.info.description} data-role="redoc-description" /> <Markdown source={store.spec.info.summary} data-role="redoc-summary"/>
<Markdown source={store.spec.info.description} data-role="redoc-description"/>
{externalDocs && <ExternalDocumentation externalDocs={externalDocs} />} {externalDocs && <ExternalDocumentation externalDocs={externalDocs} />}
</MiddlePanel> </MiddlePanel>
</Row> </Row>

View File

@ -8,7 +8,7 @@ import { RedocRawOptions } from '../../services/RedocNormalizedOptions';
export interface EnumValuesProps { export interface EnumValuesProps {
values: string[]; values: string[];
type: string; type: string | string[];
} }
export interface EnumValuesState { export interface EnumValuesState {

View File

@ -1,7 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { import {
NullableLabel,
PatternLabel, PatternLabel,
RecursiveLabel, RecursiveLabel,
TypeFormat, TypeFormat,
@ -79,7 +78,6 @@ export class FieldDetails extends React.PureComponent<FieldProps, { patternShown
)} )}
{schema.title && !hideSchemaTitles && <TypeTitle> ({schema.title}) </TypeTitle>} {schema.title && !hideSchemaTitles && <TypeTitle> ({schema.title}) </TypeTitle>}
<ConstraintsView constraints={schema.constraints} /> <ConstraintsView constraints={schema.constraints} />
{schema.nullable && <NullableLabel> {l('nullable')} </NullableLabel>}
{schema.pattern && !hideSchemaPattern && ( {schema.pattern && !hideSchemaPattern && (
<> <>
<PatternLabel> <PatternLabel>
@ -112,6 +110,7 @@ export class FieldDetails extends React.PureComponent<FieldProps, { patternShown
<ExternalDocumentation externalDocs={schema.externalDocs} compact={true} /> <ExternalDocumentation externalDocs={schema.externalDocs} compact={true} />
)} )}
{(renderDiscriminatorSwitch && renderDiscriminatorSwitch(this.props)) || null} {(renderDiscriminatorSwitch && renderDiscriminatorSwitch(this.props)) || null}
{field.const && (<FieldDetail label={l('const') + ':'} value={field.const}/>) || null}
</div> </div>
); );
} }

View File

@ -39,7 +39,7 @@ export class Redoc extends React.Component<RedocProps> {
const store = this.props.store; const store = this.props.store;
return ( return (
<ThemeProvider theme={options.theme}> <ThemeProvider theme={options.theme}>
<StoreProvider value={this.props.store}> <StoreProvider value={store}>
<OptionsProvider value={options}> <OptionsProvider value={options}>
<RedocWrap className="redoc-wrap"> <RedocWrap className="redoc-wrap">
<StickyResponsiveSidebar menu={menu} className="menu-content"> <StickyResponsiveSidebar menu={menu} className="menu-content">

View File

@ -63,6 +63,10 @@ export class Schema extends React.Component<Partial<SchemaProps>> {
return <OneOfSchema schema={schema} {...this.props} />; return <OneOfSchema schema={schema} {...this.props} />;
} }
if (type && Array.isArray(type)) {
return <ArraySchema {...(this.props as any)} />;
}
switch (type) { switch (type) {
case 'object': case 'object':
if (schema.fields?.length) { if (schema.fields?.length) {

View File

@ -6,6 +6,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
<Field <Field
field={ field={
FieldModel { FieldModel {
"const": "",
"deprecated": false, "deprecated": false,
"description": "", "description": "",
"example": undefined, "example": undefined,
@ -17,6 +18,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"required": false, "required": false,
"schema": SchemaModel { "schema": SchemaModel {
"activeOneOf": 0, "activeOneOf": 0,
"const": "",
"constraints": Array [], "constraints": Array [],
"default": undefined, "default": undefined,
"deprecated": false, "deprecated": false,
@ -29,7 +31,6 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"format": undefined, "format": undefined,
"isCircular": undefined, "isCircular": undefined,
"isPrimitive": true, "isPrimitive": true,
"nullable": false,
"options": "<<<filtered>>>", "options": "<<<filtered>>>",
"pattern": undefined, "pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/packSize", "pointer": "#/components/schemas/Dog/properties/packSize",
@ -56,6 +57,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
<Field <Field
field={ field={
FieldModel { FieldModel {
"const": "",
"deprecated": false, "deprecated": false,
"description": "", "description": "",
"example": undefined, "example": undefined,
@ -67,6 +69,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"required": true, "required": true,
"schema": SchemaModel { "schema": SchemaModel {
"activeOneOf": 0, "activeOneOf": 0,
"const": "",
"constraints": Array [], "constraints": Array [],
"default": undefined, "default": undefined,
"deprecated": false, "deprecated": false,
@ -79,7 +82,6 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"format": undefined, "format": undefined,
"isCircular": undefined, "isCircular": undefined,
"isPrimitive": true, "isPrimitive": true,
"nullable": false,
"options": "<<<filtered>>>", "options": "<<<filtered>>>",
"pattern": undefined, "pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/type", "pointer": "#/components/schemas/Dog/properties/type",

View File

@ -145,7 +145,10 @@ export class AppStore {
if (idx === -1 && IS_BROWSER) { if (idx === -1 && IS_BROWSER) {
const $description = document.querySelector('[data-role="redoc-description"]'); const $description = document.querySelector('[data-role="redoc-description"]');
const $summary = document.querySelector('[data-role="redoc-summary"]');
if ($description) elements.push($description); if ($description) elements.push($description);
if ($summary) elements.push($summary);
} }
this.marker.addOnly(elements); this.marker.addOnly(elements);

View File

@ -6,10 +6,10 @@ export interface LabelsConfig {
deprecated: string; deprecated: string;
example: string; example: string;
examples: string; examples: string;
nullable: string;
recursive: string; recursive: string;
arrayOf: string; arrayOf: string;
webhook: string; webhook: string;
const: string;
} }
export type LabelsConfigRaw = Partial<LabelsConfig>; export type LabelsConfigRaw = Partial<LabelsConfig>;
@ -22,10 +22,10 @@ const labels: LabelsConfig = {
deprecated: 'Deprecated', deprecated: 'Deprecated',
example: 'Example', example: 'Example',
examples: 'Examples', examples: 'Examples',
nullable: 'Nullable',
recursive: 'Recursive', recursive: 'Recursive',
arrayOf: 'Array of ', arrayOf: 'Array of ',
webhook: 'Event', webhook: 'Event',
const: 'Value',
}; };
export function setRedocLabels(_labels?: LabelsConfigRaw) { export function setRedocLabels(_labels?: LabelsConfigRaw) {

View File

@ -53,7 +53,7 @@ export class MenuBuilder {
const spec = parser.spec; const spec = parser.spec;
const items: ContentItemModel[] = []; const items: ContentItemModel[] = [];
const tagsMap = MenuBuilder.getTagsWithOperations(spec); const tagsMap = MenuBuilder.getTagsWithOperations(parser, spec);
items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '', undefined, 1, options)); items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '', undefined, 1, options));
if (spec['x-tagGroups'] && spec['x-tagGroups'].length > 0) { if (spec['x-tagGroups'] && spec['x-tagGroups'].length > 0) {
items.push( items.push(
@ -215,24 +215,33 @@ export class MenuBuilder {
/** /**
* collects tags and maps each tag to list of operations belonging to this tag * collects tags and maps each tag to list of operations belonging to this tag
*/ */
static getTagsWithOperations(spec: OpenAPISpec): TagsInfoMap { static getTagsWithOperations(parser: OpenAPIParser, spec: OpenAPISpec): TagsInfoMap {
const tags: TagsInfoMap = {}; const tags: TagsInfoMap = {};
const webhooks = spec['x-webhooks'] || spec.webhooks;
for (const tag of spec.tags || []) { for (const tag of spec.tags || []) {
tags[tag.name] = { ...tag, operations: [] }; tags[tag.name] = { ...tag, operations: [] };
} }
getTags(spec.paths); if (webhooks) {
if (spec['x-webhooks']) { getTags(parser, webhooks, true);
getTags(spec['x-webhooks'], true);
} }
function getTags(paths: OpenAPIPaths, isWebhook?: boolean) { if (spec.paths){
getTags(parser, spec.paths);
}
function getTags(parser: OpenAPIParser, paths: OpenAPIPaths, isWebhook?: boolean) {
for (const pathName of Object.keys(paths)) { for (const pathName of Object.keys(paths)) {
const path = paths[pathName]; const path = paths[pathName];
const operations = Object.keys(path).filter(isOperationName); const operations = Object.keys(path).filter(isOperationName);
for (const operationName of operations) { for (const operationName of operations) {
const operationInfo = path[operationName]; const operationInfo = path[operationName];
let operationTags = operationInfo.tags; if (path.$ref) {
const resolvedPaths = parser.deref<OpenAPIPaths>(path as OpenAPIPaths);
getTags(parser, { [pathName]: resolvedPaths }, isWebhook);
continue;
}
let operationTags = operationInfo?.tags;
if (!operationTags || !operationTags.length) { if (!operationTags || !operationTags.length) {
// empty tag // empty tag

View File

@ -1,4 +1,4 @@
import { OpenAPIExternalDocumentation, OpenAPISpec } from '../types'; import { OpenAPIExternalDocumentation, OpenAPIPath, OpenAPISpec, Referenced } from '../types';
import { ContentItemModel, MenuBuilder } from './MenuBuilder'; import { ContentItemModel, MenuBuilder } from './MenuBuilder';
import { ApiInfoModel } from './models/ApiInfo'; import { ApiInfoModel } from './models/ApiInfo';
@ -28,6 +28,7 @@ export class SpecStore {
this.externalDocs = this.parser.spec.externalDocs; this.externalDocs = this.parser.spec.externalDocs;
this.contentItems = MenuBuilder.buildStructure(this.parser, this.options); this.contentItems = MenuBuilder.buildStructure(this.parser, this.options);
this.securitySchemes = new SecuritySchemesModel(this.parser); this.securitySchemes = new SecuritySchemesModel(this.parser);
this.webhooks = new WebhookModel(this.parser, options, this.parser.spec['x-webhooks']); const webhookPath: Referenced<OpenAPIPath> = {...this.parser?.spec?.['x-webhooks'], ...this.parser?.spec.webhooks};
this.webhooks = new WebhookModel(this.parser, options, webhookPath);
} }
} }

View File

@ -0,0 +1,66 @@
{
"openapi": "3.1.0",
"info": {
"version": "1.0.0",
"title": "Swagger Petstore"
},
"webhooks": {
"myWebhook": {
"$ref": "#/components/pathItems/catsWebhook",
"description": "Overriding description",
"summary": "Overriding summary"
}
},
"components": {
"pathItems": {
"catsWebhook": {
"put": {
"summary": "Get a cat details after update",
"description": "Get a cat details after update",
"operationId": "updatedCat",
"tags": [
"pet"
],
"requestBody": {
"description": "Information about cat in the system",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Pet"
}
}
}
},
"responses": {
"200": {
"description": "update Cat details"
}
}
},
"post": {
"summary": "Create new cat",
"description": "Info about new cat",
"operationId": "createdCat",
"tags": [
"pet"
],
"requestBody": {
"description": "Information about cat in the system",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Pet"
}
}
}
},
"responses": {
"200": {
"description": "create Cat details"
}
}
}
}
}
}
}

View File

@ -34,5 +34,33 @@ describe('Models', () => {
const info = new ApiInfoModel(parser); const info = new ApiInfoModel(parser);
expect(info.description).toEqual('Test description\nsome text\n'); expect(info.description).toEqual('Test description\nsome text\n');
}); });
test('should correctly populate summary up to the first md heading', () => {
parser.spec = {
openapi: '3.1.0',
info: {
summary: 'Test summary\nsome text\n## Heading\n test',
},
} as any;
const info = new ApiInfoModel(parser);
expect(info.summary).toEqual('Test summary\nsome text\n## Heading\n test');
});
test('should correctly populate license identifier', () => {
parser.spec = {
openapi: '3.1.0',
info: {
license: {
name: 'MIT',
identifier: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
} as any;
const { license = { identifier: null } } = new ApiInfoModel(parser);
expect(license.identifier).toEqual('MIT');
});
}); });
}); });

View File

@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { MenuBuilder } from '../../MenuBuilder';
import { OpenAPIParser } from '../../OpenAPIParser';
import { RedocNormalizedOptions } from '../../RedocNormalizedOptions';
const opts = new RedocNormalizedOptions({});
describe('Models', () => {
describe('MenuBuilder', () => {
let parser;
test('should resolve pathItems', () => {
const spec = require('../fixtures/3.1/pathItems.json');
parser = new OpenAPIParser(spec, undefined, opts);
const contentItems = MenuBuilder.buildStructure(parser, opts);
expect(contentItems).toHaveLength(1);
expect(contentItems[0].items).toHaveLength(2);
expect(contentItems[0].id).toEqual('tag/pet');
expect(contentItems[0].name).toEqual('pet');
expect(contentItems[0].type).toEqual('tag');
});
});
});

View File

@ -7,6 +7,7 @@ export class ApiInfoModel implements OpenAPIInfo {
version: string; version: string;
description: string; description: string;
summary: string;
termsOfService?: string; termsOfService?: string;
contact?: OpenAPIContact; contact?: OpenAPIContact;
license?: OpenAPILicense; license?: OpenAPILicense;
@ -17,6 +18,7 @@ export class ApiInfoModel implements OpenAPIInfo {
constructor(private parser: OpenAPIParser) { constructor(private parser: OpenAPIParser) {
Object.assign(this, parser.spec.info); Object.assign(this, parser.spec.info);
this.description = parser.spec.info.description || ''; this.description = parser.spec.info.description || '';
this.summary = parser.spec.info.summary || '';
const firstHeadingLinePos = this.description.search(/^##?\s+/m); const firstHeadingLinePos = this.description.search(/^##?\s+/m);
if (firstHeadingLinePos > -1) { if (firstHeadingLinePos > -1) {

View File

@ -55,6 +55,7 @@ export class FieldModel {
extensions?: Record<string, any>; extensions?: Record<string, any>;
explode: boolean; explode: boolean;
style?: OpenAPIParameterStyle; style?: OpenAPIParameterStyle;
const?: any;
serializationMime?: string; serializationMime?: string;
@ -111,6 +112,8 @@ export class FieldModel {
if (options.showExtensions) { if (options.showExtensions) {
this.extensions = extractExtensions(info, options.showExtensions); this.extensions = extractExtensions(info, options.showExtensions);
} }
this.const = this.schema?.const || info?.const || '';
} }
@action @action

View File

@ -25,7 +25,7 @@ import { l } from '../Labels';
export class SchemaModel { export class SchemaModel {
pointer: string; pointer: string;
type: string; type: string | string[];
displayType: string; displayType: string;
typePrefix: string = ''; typePrefix: string = '';
title: string; title: string;
@ -60,6 +60,7 @@ export class SchemaModel {
rawSchema: OpenAPISchema; rawSchema: OpenAPISchema;
schema: MergedOpenAPISchema; schema: MergedOpenAPISchema;
extensions?: Record<string, any>; extensions?: Record<string, any>;
const: any;
/** /**
* @param isChild if schema discriminator Child * @param isChild if schema discriminator Child
@ -106,7 +107,6 @@ export class SchemaModel {
this.description = schema.description || ''; this.description = schema.description || '';
this.type = schema.type || detectType(schema); this.type = schema.type || detectType(schema);
this.format = schema.format; this.format = schema.format;
this.nullable = !!schema.nullable;
this.enum = schema.enum || []; this.enum = schema.enum || [];
this.example = schema.example; this.example = schema.example;
this.deprecated = !!schema.deprecated; this.deprecated = !!schema.deprecated;
@ -114,12 +114,22 @@ export class SchemaModel {
this.externalDocs = schema.externalDocs; this.externalDocs = schema.externalDocs;
this.constraints = humanizeConstraints(schema); this.constraints = humanizeConstraints(schema);
this.displayType = this.type;
this.displayFormat = this.format; this.displayFormat = this.format;
this.isPrimitive = isPrimitiveType(schema, this.type); this.isPrimitive = isPrimitiveType(schema, this.type);
this.default = schema.default; this.default = schema.default;
this.readOnly = !!schema.readOnly; this.readOnly = !!schema.readOnly;
this.writeOnly = !!schema.writeOnly; this.writeOnly = !!schema.writeOnly;
this.const = schema.const || '';
if (!!schema.nullable) {
if (Array.isArray(this.type) && !this.type.includes('null')) {
this.type = [...this.type, 'null'];
}
}
this.displayType = Array.isArray(this.type)
? this.type.map(item => item === null ? 'null' : item).join(' or ')
: this.type;
if (this.isCircular) { if (this.isCircular) {
return; return;
@ -156,7 +166,7 @@ export class SchemaModel {
if (this.type === 'object') { if (this.type === 'object') {
this.fields = buildFields(parser, schema, this.pointer, this.options); this.fields = buildFields(parser, schema, this.pointer, this.options);
} else if (this.type === 'array' && schema.items) { } else if ((this.type === 'array' || Array.isArray(this.type)) && schema.items) {
this.items = new SchemaModel(parser, schema.items, this.pointer + '/items', this.options); this.items = new SchemaModel(parser, schema.items, this.pointer + '/items', this.options);
this.displayType = pluralizeType(this.items.displayType); this.displayType = pluralizeType(this.items.displayType);
this.displayFormat = this.items.format; this.displayFormat = this.items.format;
@ -169,6 +179,11 @@ export class SchemaModel {
if (this.items.isPrimitive) { if (this.items.isPrimitive) {
this.enum = this.items.enum; this.enum = this.items.enum;
} }
if (Array.isArray(this.type)) {
const filteredType = this.type.filter(item => item !== 'array');
if (filteredType.length)
this.displayType += ` or ${filteredType.join(' or ')}`;
}
} }
if (this.enum.length && this.options.sortEnumValuesAlphabetically) { if (this.enum.length && this.options.sortEnumValuesAlphabetically) {
@ -186,7 +201,7 @@ export class SchemaModel {
const title = const title =
isNamedDefinition(variant.$ref) && !merged.title isNamedDefinition(variant.$ref) && !merged.title
? JsonPointer.baseName(variant.$ref) ? JsonPointer.baseName(variant.$ref)
: merged.title; : `${(merged.title || '')}${(merged.const && JSON.stringify(merged.const)) || ''}`;
const schema = new SchemaModel( const schema = new SchemaModel(
parser, parser,

View File

@ -1,8 +1,8 @@
import { OpenAPIPath, Referenced } from '../../types'; import { OpenAPIPath, Referenced } from '../../types';
import { OpenAPIParser } from '../OpenAPIParser'; import { OpenAPIParser } from '../OpenAPIParser';
import { OperationModel } from './Operation'; import { OperationModel } from './Operation';
import { isOperationName } from '../..';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
import { isOperationName } from '../..';
export class WebhookModel { export class WebhookModel {
operations: OperationModel[] = []; operations: OperationModel[] = [];
@ -14,12 +14,21 @@ export class WebhookModel {
) { ) {
const webhooks = parser.deref<OpenAPIPath>(infoOrRef || {}); const webhooks = parser.deref<OpenAPIPath>(infoOrRef || {});
parser.exitRef(infoOrRef); parser.exitRef(infoOrRef);
this.initWebhooks(parser, webhooks, options);
}
initWebhooks(parser: OpenAPIParser, webhooks: OpenAPIPath, options: RedocNormalizedOptions) {
for (const webhookName of Object.keys(webhooks)) { for (const webhookName of Object.keys(webhooks)) {
const webhook = webhooks[webhookName]; const webhook = webhooks[webhookName];
const operations = Object.keys(webhook).filter(isOperationName); const operations = Object.keys(webhook).filter(isOperationName);
for (const operationName of operations) { for (const operationName of operations) {
const operationInfo = webhook[operationName]; const operationInfo = webhook[operationName];
if (webhook.$ref) {
const resolvedWebhook = parser.deref<OpenAPIPath>(webhook || {});
this.initWebhooks(parser, { [operationName]: resolvedWebhook }, options);
}
if (!operationInfo) continue;
const operation = new OperationModel( const operation = new OperationModel(
parser, parser,
{ {

View File

@ -10,6 +10,7 @@ export interface OpenAPISpec {
tags?: OpenAPITag[]; tags?: OpenAPITag[];
externalDocs?: OpenAPIExternalDocumentation; externalDocs?: OpenAPIExternalDocumentation;
'x-webhooks'?: OpenAPIPaths; 'x-webhooks'?: OpenAPIPaths;
webhooks?: OpenAPIPaths;
} }
export interface OpenAPIInfo { export interface OpenAPIInfo {
@ -17,6 +18,7 @@ export interface OpenAPIInfo {
version: string; version: string;
description?: string; description?: string;
summary?: string;
termsOfService?: string; termsOfService?: string;
contact?: OpenAPIContact; contact?: OpenAPIContact;
license?: OpenAPILicense; license?: OpenAPILicense;
@ -56,6 +58,7 @@ export interface OpenAPIPath {
trace?: OpenAPIOperation; trace?: OpenAPIOperation;
servers?: OpenAPIServer[]; servers?: OpenAPIServer[];
parameters?: Array<Referenced<OpenAPIParameter>>; parameters?: Array<Referenced<OpenAPIParameter>>;
$ref?: string;
} }
export interface OpenAPIXCodeSample { export interface OpenAPIXCodeSample {
@ -96,6 +99,7 @@ export interface OpenAPIParameter {
examples?: { [media: string]: Referenced<OpenAPIExample> }; examples?: { [media: string]: Referenced<OpenAPIExample> };
content?: { [media: string]: OpenAPIMediaType }; content?: { [media: string]: OpenAPIMediaType };
encoding?: Record<string, OpenAPIEncoding>; encoding?: Record<string, OpenAPIEncoding>;
const?: any;
} }
export interface OpenAPIExample { export interface OpenAPIExample {
@ -107,7 +111,7 @@ export interface OpenAPIExample {
export interface OpenAPISchema { export interface OpenAPISchema {
$ref?: string; $ref?: string;
type?: string; type?: string | string[];
properties?: { [name: string]: OpenAPISchema }; properties?: { [name: string]: OpenAPISchema };
additionalProperties?: boolean | OpenAPISchema; additionalProperties?: boolean | OpenAPISchema;
description?: string; description?: string;
@ -129,9 +133,9 @@ export interface OpenAPISchema {
title?: string; title?: string;
multipleOf?: number; multipleOf?: number;
maximum?: number; maximum?: number;
exclusiveMaximum?: boolean; exclusiveMaximum?: boolean | number;
minimum?: number; minimum?: number;
exclusiveMinimum?: boolean; exclusiveMinimum?: boolean | number;
maxLength?: number; maxLength?: number;
minLength?: number; minLength?: number;
pattern?: string; pattern?: string;
@ -142,6 +146,7 @@ export interface OpenAPISchema {
minProperties?: number; minProperties?: number;
enum?: any[]; enum?: any[];
example?: any; example?: any;
const?: string;
} }
export interface OpenAPIDiscriminator { export interface OpenAPIDiscriminator {
@ -271,4 +276,5 @@ export interface OpenAPIContact {
export interface OpenAPILicense { export interface OpenAPILicense {
name: string; name: string;
url?: string; url?: string;
identifier?: string;
} }

View File

@ -10,6 +10,12 @@ describe('#loadAndBundleSpec', () => {
expect(bundledSpec).toMatchSnapshot(); expect(bundledSpec).toMatchSnapshot();
}); });
it('should load And Bundle Spec demo/openapi-3-1.yaml', async () => {
const spec = yaml.load(readFileSync(resolve(__dirname, '../../../demo/openapi-3-1.yaml')));
const bundledSpec = await loadAndBundleSpec(spec);
expect(bundledSpec).toMatchSnapshot();
});
it('should load And Bundle Spec demo/swagger.yaml', async () => { it('should load And Bundle Spec demo/swagger.yaml', async () => {
const spec = yaml.load(readFileSync(resolve(__dirname, '../../../demo/swagger.yaml'))); const spec = yaml.load(readFileSync(resolve(__dirname, '../../../demo/swagger.yaml')));
const bundledSpec = await loadAndBundleSpec(spec); const bundledSpec = await loadAndBundleSpec(spec);

View File

@ -174,6 +174,79 @@ describe('Utils', () => {
expect(isPrimitiveType(schema)).toEqual(false); expect(isPrimitiveType(schema)).toEqual(false);
}); });
it('should return true for array contains object and schema hasn\'t properties', () => {
const schema = {
type: ['object', 'string'],
};
expect(isPrimitiveType(schema)).toEqual(true);
});
it('should return false for array contains object and schema has properties', () => {
const schema = {
type: ['object', 'string'],
properties: {
a: {
type: 'string',
},
},
};
expect(isPrimitiveType(schema)).toEqual(false);
});
it('should return false for array contains array type and schema has items', () => {
const schema = {
type: ['array'],
items: {
type: 'object',
additionalProperties: true,
},
};
expect(isPrimitiveType(schema)).toEqual(false);
});
it('should return false for array contains object and array types and schema has items', () => {
const schema = {
type: ['array', 'object'],
items: {
type: 'object',
additionalProperties: true,
},
};
expect(isPrimitiveType(schema)).toEqual(false);
});
it('should return false for array contains object and array types and schema has properties', () => {
const schema = {
type: ['array', 'object'],
properties: {
a: {
type: 'string',
},
},
};
expect(isPrimitiveType(schema)).toEqual(false);
});
it('should return true for array contains array of strings', () => {
const schema = {
type: 'array',
items: {
type: 'array',
items: {
type: 'string'
},
},
};
expect(isPrimitiveType(schema)).toEqual(true);
});
it('Should return false for array of string which include the null value', () => {
const schema = {
type: ['object', 'string', 'null'],
};
expect(isPrimitiveType(schema)).toEqual(true);
});
it('Should return false for array with non-empty objects', () => { it('Should return false for array with non-empty objects', () => {
const schema = { const schema = {
type: 'array', type: 'array',

View File

@ -56,6 +56,7 @@ const operationNames = {
patch: true, patch: true,
delete: true, delete: true,
options: true, options: true,
$ref: true,
}; };
export function isOperationName(key: string): boolean { export function isOperationName(key: string): boolean {
@ -68,7 +69,7 @@ export function getOperationSummary(operation: ExtendedOpenAPIOperation): string
operation.operationId || operation.operationId ||
(operation.description && operation.description.substring(0, 50)) || (operation.description && operation.description.substring(0, 50)) ||
operation.pathName || operation.pathName ||
'<no summary>' '<no summary>'
); );
} }
@ -96,7 +97,7 @@ const schemaKeywordTypes = {
}; };
export function detectType(schema: OpenAPISchema): string { export function detectType(schema: OpenAPISchema): string {
if (schema.type !== undefined) { if (schema.type !== undefined && !Array.isArray(schema.type)) {
return schema.type; return schema.type;
} }
const keywords = Object.keys(schemaKeywordTypes); const keywords = Object.keys(schemaKeywordTypes);
@ -110,25 +111,25 @@ export function detectType(schema: OpenAPISchema): string {
return 'any'; return 'any';
} }
export function isPrimitiveType(schema: OpenAPISchema, type: string | undefined = schema.type) { export function isPrimitiveType(schema: OpenAPISchema, type: string | string[] | undefined = schema.type) {
if (schema.oneOf !== undefined || schema.anyOf !== undefined) { if (schema.oneOf !== undefined || schema.anyOf !== undefined) {
return false; return false;
} }
if (type === 'object') { let isPrimitive = true;
return schema.properties !== undefined const isArray = Array.isArray(type);
if (type === 'object' || (isArray && type?.includes('object'))) {
isPrimitive = schema.properties !== undefined
? Object.keys(schema.properties).length === 0 ? Object.keys(schema.properties).length === 0
: schema.additionalProperties === undefined; : schema.additionalProperties === undefined;
} }
if (type === 'array') { if (schema.items !== undefined && (type === 'array' || (isArray && type?.includes('array')))) {
if (schema.items === undefined) { isPrimitive = isPrimitiveType(schema.items, schema.items.type);
return true;
}
return false;
} }
return true; return isPrimitive;
} }
export function isJsonLike(contentType: string): boolean { export function isJsonLike(contentType: string): boolean {
@ -367,12 +368,12 @@ export function langFromMime(contentType: string): string {
} }
export function isNamedDefinition(pointer?: string): boolean { export function isNamedDefinition(pointer?: string): boolean {
return /^#\/components\/schemas\/[^\/]+$/.test(pointer || ''); return /^#\/components\/(schemas|pathItems)\/[^\/]+$/.test(pointer || '');
} }
export function getDefinitionName(pointer?: string): string | undefined { export function getDefinitionName(pointer?: string): string | undefined {
if (!pointer) return undefined; if (!pointer) return undefined;
const match = pointer.match(/^#\/components\/schemas\/([^\/]+)$/); const match = pointer.match(/^#\/components\/(schemas|pathItems)\/([^\/]+)$/);
return match === null ? undefined : match[1] return match === null ? undefined : match[1]
} }
@ -445,6 +446,18 @@ export function humanizeConstraints(schema: OpenAPISchema): string[] {
numberRange += schema.minimum; 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);
} }
@ -574,10 +587,10 @@ export function setSecuritySchemePrefix(prefix: string) {
} }
export const shortenHTTPVerb = verb => export const shortenHTTPVerb = verb =>
({ ({
delete: 'del', delete: 'del',
options: 'opts', options: 'opts',
}[verb] || verb); }[verb] || verb);
export function isRedocExtension(key: string): boolean { export function isRedocExtension(key: string): boolean {
const redocExtensions = { const redocExtensions = {