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 ComboBox from './ComboBox';
const DEFAULT_SPEC = 'openapi.yaml';
const NEW_VERSION_SPEC = 'openapi-3-1.yaml';
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/googleapis.com/calendar/v3/openapi.yaml',
@ -16,8 +20,6 @@ const demos = [
{ value: 'https://docs.graphhopper.com/openapi.json', label: 'GraphHopper' },
];
const DEFAULT_SPEC = 'openapi.yaml';
class DemoApp extends React.Component<
{},
{ specUrl: string; dropdownOpen: boolean; cors: boolean }
@ -45,6 +47,9 @@ class DemoApp extends React.Component<
}
handleChange = (url: string) => {
if (url === NEW_VERSION_SPEC) {
this.setState({ cors: false })
}
this.setState({
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.1 mode', 'e2e/standalone-3-1.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": {
"version": "1.0.0-beta.44",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.0.0-beta.44.tgz",
"integrity": "sha512-9HNnh1MzvMsLK1liuidFBqWiAsZ2Yg3RY58fcEsy0QruSMdDbn7SoeI1qnXe6O+BkBS+vAP4oVzZDMHCMKGsOQ==",
"version": "1.0.0-beta.48",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.0.0-beta.48.tgz",
"integrity": "sha512-rlus9qQC4Pkzz2Ljcv+jQjFdKOYSWnsYXWN6zNik9iiiQtMmGEdszsERCbSAYw/CZ5DRCAHMeKrh8f4LBCpx1A==",
"requires": {
"@redocly/ajv": "^6.12.3",
"@types/node": "^14.11.8",
@ -1238,9 +1238,9 @@
},
"dependencies": {
"@types/node": {
"version": "14.14.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz",
"integrity": "sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw=="
"version": "14.17.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.1.tgz",
"integrity": "sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw=="
}
}
},
@ -1459,8 +1459,7 @@
"@types/json-schema": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==",
"dev": true
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA=="
},
"@types/json5": {
"version": "0.0.29",
@ -11693,11 +11692,12 @@
"dev": true
},
"openapi-sampler": {
"version": "1.0.0-beta.18",
"resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.0.0-beta.18.tgz",
"integrity": "sha512-nG/0kvvSY5FbrU5A+Dbp1xTQN++7pKIh87/atryZlxrzDuok5Y6TCbpxO1jYqpUKLycE4ReKGHCywezngG6xtQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.0.0.tgz",
"integrity": "sha512-HysKj4ZuLk0RpZkopao5SIupUX8LMOTsEDTw9dSzcRv6BBW6Ep1IdbKwYsCrYM9tnw4VZtebR/N5sJHY6qqRew==",
"requires": {
"json-pointer": "^0.6.0"
"@types/json-schema": "^7.0.7",
"json-pointer": "^0.6.1"
}
},
"opn": {

View File

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

View File

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

View File

@ -38,7 +38,7 @@ export class ApiInfo extends React.Component<ApiInfoProps> {
const license =
(info.license && (
<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>
)) ||
null;
@ -100,6 +100,7 @@ export class ApiInfo extends React.Component<ApiInfoProps> {
)) ||
null}
</StyledMarkdownBlock>
<Markdown source={store.spec.info.summary} data-role="redoc-summary"/>
<Markdown source={store.spec.info.description} data-role="redoc-description"/>
{externalDocs && <ExternalDocumentation externalDocs={externalDocs} />}
</MiddlePanel>

View File

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

View File

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

View File

@ -39,7 +39,7 @@ export class Redoc extends React.Component<RedocProps> {
const store = this.props.store;
return (
<ThemeProvider theme={options.theme}>
<StoreProvider value={this.props.store}>
<StoreProvider value={store}>
<OptionsProvider value={options}>
<RedocWrap className="redoc-wrap">
<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} />;
}
if (type && Array.isArray(type)) {
return <ArraySchema {...(this.props as any)} />;
}
switch (type) {
case 'object':
if (schema.fields?.length) {

View File

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

View File

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

View File

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

View File

@ -53,7 +53,7 @@ export class MenuBuilder {
const spec = parser.spec;
const items: ContentItemModel[] = [];
const tagsMap = MenuBuilder.getTagsWithOperations(spec);
const tagsMap = MenuBuilder.getTagsWithOperations(parser, spec);
items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '', undefined, 1, options));
if (spec['x-tagGroups'] && spec['x-tagGroups'].length > 0) {
items.push(
@ -215,24 +215,33 @@ export class MenuBuilder {
/**
* 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 webhooks = spec['x-webhooks'] || spec.webhooks;
for (const tag of spec.tags || []) {
tags[tag.name] = { ...tag, operations: [] };
}
getTags(spec.paths);
if (spec['x-webhooks']) {
getTags(spec['x-webhooks'], true);
if (webhooks) {
getTags(parser, 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)) {
const path = paths[pathName];
const operations = Object.keys(path).filter(isOperationName);
for (const operationName of operations) {
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) {
// 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 { ApiInfoModel } from './models/ApiInfo';
@ -28,6 +28,7 @@ export class SpecStore {
this.externalDocs = this.parser.spec.externalDocs;
this.contentItems = MenuBuilder.buildStructure(this.parser, this.options);
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);
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;
description: string;
summary: string;
termsOfService?: string;
contact?: OpenAPIContact;
license?: OpenAPILicense;
@ -17,6 +18,7 @@ export class ApiInfoModel implements OpenAPIInfo {
constructor(private parser: OpenAPIParser) {
Object.assign(this, parser.spec.info);
this.description = parser.spec.info.description || '';
this.summary = parser.spec.info.summary || '';
const firstHeadingLinePos = this.description.search(/^##?\s+/m);
if (firstHeadingLinePos > -1) {

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,12 @@ describe('#loadAndBundleSpec', () => {
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 () => {
const spec = yaml.load(readFileSync(resolve(__dirname, '../../../demo/swagger.yaml')));
const bundledSpec = await loadAndBundleSpec(spec);

View File

@ -174,6 +174,79 @@ describe('Utils', () => {
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', () => {
const schema = {
type: 'array',

View File

@ -56,6 +56,7 @@ const operationNames = {
patch: true,
delete: true,
options: true,
$ref: true,
};
export function isOperationName(key: string): boolean {
@ -96,7 +97,7 @@ const schemaKeywordTypes = {
};
export function detectType(schema: OpenAPISchema): string {
if (schema.type !== undefined) {
if (schema.type !== undefined && !Array.isArray(schema.type)) {
return schema.type;
}
const keywords = Object.keys(schemaKeywordTypes);
@ -110,25 +111,25 @@ export function detectType(schema: OpenAPISchema): string {
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) {
return false;
}
if (type === 'object') {
return schema.properties !== undefined
let isPrimitive = true;
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;
}
if (type === 'array') {
if (schema.items === undefined) {
return true;
}
return false;
if (schema.items !== undefined && (type === 'array' || (isArray && type?.includes('array')))) {
isPrimitive = isPrimitiveType(schema.items, schema.items.type);
}
return true;
return isPrimitive;
}
export function isJsonLike(contentType: string): boolean {
@ -367,12 +368,12 @@ export function langFromMime(contentType: string): string {
}
export function isNamedDefinition(pointer?: string): boolean {
return /^#\/components\/schemas\/[^\/]+$/.test(pointer || '');
return /^#\/components\/(schemas|pathItems)\/[^\/]+$/.test(pointer || '');
}
export function getDefinitionName(pointer?: string): string | undefined {
if (!pointer) return undefined;
const match = pointer.match(/^#\/components\/schemas\/([^\/]+)$/);
const match = pointer.match(/^#\/components\/(schemas|pathItems)\/([^\/]+)$/);
return match === null ? undefined : match[1]
}
@ -445,6 +446,18 @@ export function humanizeConstraints(schema: OpenAPISchema): string[] {
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) {
res.push(numberRange);
}