diff --git a/README.md b/README.md index 6155e48258..afae07a6e4 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ You can use all of the following options with standalone version on tag * **selector**: selector of the element to be used for specifying the offset. The distance from the top of the page to the element's bottom will be used as offset. * **function**: A getter function. Must return a number representing the offset (in pixels). * `showExtensions` - show vendor extensions ("x-" fields). Extensions used by ReDoc are ignored. Can be boolean or an array of `string` with names of extensions to display. +* `showSchemas` - Show all top-level Schemas under group in menu, default `false`. * `sortPropsAlphabetically` - sort properties alphabetically. * `payloadSampleIdx` - if set, payload sample will be inserted at this index or last. Indexes start from 0. * `theme` - ReDoc theme. For details check [theme docs](#redoc-theme-object). diff --git a/e2e/integration/menu.e2e.ts b/e2e/integration/menu.e2e.ts index e1b053d125..26203aea8f 100644 --- a/e2e/integration/menu.e2e.ts +++ b/e2e/integration/menu.e2e.ts @@ -42,4 +42,18 @@ describe('Menu', () => { cy.contains('[role=menuitem].-depth1', 'store').click({ force: true }); petItem().should('not.have.class', 'active'); }); + + it('should omit Schemas group when showSchemas == false, which is default', () => { + cy.get('.menu-content').should('exist'); + cy.get('[data-item-id="section/Schemas"]').should('not.exist'); + cy.get('[type=section] [title=HoneyBee]').should('not.exist'); + }); + + it('should include Schemas group and sections when showSchemas == true', () => { + cy.visit('e2e/showSchemas.html'); + + cy.get('.menu-content').should('exist'); + cy.get('[data-item-id="section/Schemas"]').should('exist'); + cy.get('[type=section] [title=HoneyBee]').should('exist'); + }); }); diff --git a/e2e/showSchemas.html b/e2e/showSchemas.html new file mode 100644 index 0000000000..62e162426e --- /dev/null +++ b/e2e/showSchemas.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/Responses/Response.tsx b/src/components/Responses/Response.tsx index 58e0ae32e2..bc6ece8bd4 100644 --- a/src/components/Responses/Response.tsx +++ b/src/components/Responses/Response.tsx @@ -12,11 +12,12 @@ export class ResponseView extends React.Component<{ response: ResponseModel }> { }; render() { - const { headers, type, summary, description, code, expanded, content } = this.props.response; + const { extensions, headers, type, summary, description, code, expanded, content } = this.props.response; const mimes = content === undefined ? [] : content.mediaTypes.filter(mime => mime.schema !== undefined); - const empty = headers.length === 0 && mimes.length === 0 && !description; + const empty = (!extensions || Object.keys(extensions).length === 0) && + headers.length === 0 && mimes.length === 0 && !description; return (
diff --git a/src/components/Responses/ResponseDetails.tsx b/src/components/Responses/ResponseDetails.tsx index 821fc2da99..3794eba1da 100644 --- a/src/components/Responses/ResponseDetails.tsx +++ b/src/components/Responses/ResponseDetails.tsx @@ -7,15 +7,17 @@ import { DropdownOrLabel } from '../DropdownOrLabel/DropdownOrLabel'; import { MediaTypesSwitch } from '../MediaTypeSwitch/MediaTypesSwitch'; import { Schema } from '../Schema'; +import { Extensions } from '../Fields/Extensions'; import { Markdown } from '../Markdown/Markdown'; import { ResponseHeaders } from './ResponseHeaders'; export class ResponseDetails extends React.PureComponent<{ response: ResponseModel }> { render() { - const { description, headers, content } = this.props.response; + const { description, extensions, headers, content } = this.props.response; return ( <> {description && } + {({ schema }) => { diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 339ba6cf18..44e9e57468 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -62,6 +62,22 @@ export class MenuBuilder { } else { items.push(...MenuBuilder.getTagsItems(parser, tagsMap, undefined, undefined, options)); } + + if (options.showSchemas && spec.components?.schemas) { + // Ignore entries that are side-effects of Swagger 2 conversion + const titles = Object.keys(spec.components?.schemas).filter((title) => { + return !/^schema[0-9]*$/.test(title); + }); + + let markdown = '# Schemas\n\n'; + titles.sort(); + for (const title of titles) { + markdown += `## ${title}\n`; + markdown += `\n\n`; + } + items.push(...MenuBuilder.addMarkdownItems(markdown, undefined, 1, options)); + } + return items; } diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 58d4b8b068..0f41a1a33a 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -41,6 +41,7 @@ export interface RedocRawOptions { expandDefaultServerVariables?: boolean; maxDisplayedEnumValues?: number; ignoreNamedSchemas?: string[] | string; + showSchemas?: boolean; hideSchemaPattern?: boolean; } @@ -87,6 +88,10 @@ export class RedocNormalizedOptions { return !!value; } + static normalizeShowSchemas(value: RedocRawOptions['showSchemas']): boolean { + return !!value; + } + static normalizeScrollYOffset(value: RedocRawOptions['scrollYOffset']): () => number { // just number is not valid selector and leads to crash so checking if isNumeric here if (typeof value === 'string' && !isNumeric(value)) { @@ -195,6 +200,7 @@ export class RedocNormalizedOptions { maxDisplayedEnumValues?: number; ignoreNamedSchemas: Set; + showSchemas: boolean; hideSchemaPattern: boolean; constructor(raw: RedocRawOptions, defaults: RedocRawOptions = {}) { @@ -256,6 +262,7 @@ export class RedocNormalizedOptions { ? raw.ignoreNamedSchemas : raw.ignoreNamedSchemas?.split(',').map((s) => s.trim()); this.ignoreNamedSchemas = new Set(ignoreNamedSchemas); + this.showSchemas = RedocNormalizedOptions.normalizeShowSchemas(raw.showSchemas); this.hideSchemaPattern = argValueToBoolean(raw.hideSchemaPattern); } } diff --git a/src/services/__tests__/MenuBuilder.test.ts b/src/services/__tests__/MenuBuilder.test.ts new file mode 100644 index 0000000000..7a4a749cb6 --- /dev/null +++ b/src/services/__tests__/MenuBuilder.test.ts @@ -0,0 +1,25 @@ +import { OpenAPIParser } from '../OpenAPIParser'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import { MenuBuilder } from '../MenuBuilder'; + +describe('MenuBuilder', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const spec = require('./fixtures/showSchemas.json'); + + test('should omit Schemas group when showSchemas != true', () => { + const opts = new RedocNormalizedOptions({}); + const parser = new OpenAPIParser(spec, undefined, opts); + const menuItems = MenuBuilder.buildStructure(parser, opts); + expect(menuItems.length).toEqual(1); + }); + + test('should build Schemas group when showSchemas == true', () => { + const opts = new RedocNormalizedOptions({ showSchemas: true }); + const parser = new OpenAPIParser(spec, undefined, opts); + const menuItems = MenuBuilder.buildStructure(parser, opts); + expect(menuItems.length).toEqual(2); + expect(menuItems[1].name).toEqual('Schemas'); + expect(menuItems[1].items.length).toEqual(1); + expect(menuItems[1].items[0].name).toEqual('Foo'); + }); +}); diff --git a/src/services/__tests__/fixtures/showSchemas.json b/src/services/__tests__/fixtures/showSchemas.json new file mode 100644 index 0000000000..aebcb9796c --- /dev/null +++ b/src/services/__tests__/fixtures/showSchemas.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0", + "title": "Foo" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Foo": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + } + } + } +} diff --git a/src/services/__tests__/models/Response.test.ts b/src/services/__tests__/models/Response.test.ts index 3fb220f2df..33cabb2c7e 100644 --- a/src/services/__tests__/models/Response.test.ts +++ b/src/services/__tests__/models/Response.test.ts @@ -31,5 +31,12 @@ describe('Models', () => { const resp = new ResponseModel(parser, 'default', true, {}, opts); expect(resp.type).toEqual('error'); }); + + test('ensure extensions are shown if showExtensions is true', () => { + const options = new RedocNormalizedOptions({ showExtensions: true }); + const resp = new ResponseModel(parser, 'default', true, { 'x-example': {a: 1} } as any, options); + expect(Object.keys(resp.extensions).length).toEqual(1); + expect(resp.extensions['x-example']).toEqual({a: 1}); + }); }); }); diff --git a/src/services/models/Response.ts b/src/services/models/Response.ts index f50ee0f1ca..629748ea54 100644 --- a/src/services/models/Response.ts +++ b/src/services/models/Response.ts @@ -2,7 +2,10 @@ import { action, observable, makeObservable } from 'mobx'; import { OpenAPIResponse, Referenced } from '../../types'; -import { getStatusCodeType } from '../../utils'; +import { + extractExtensions, + getStatusCodeType, +} from '../../utils'; import { OpenAPIParser } from '../OpenAPIParser'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { FieldModel } from './Field'; @@ -19,6 +22,8 @@ export class ResponseModel { type: string; headers: FieldModel[] = []; + extensions: Record; + constructor( parser: OpenAPIParser, code: string, @@ -54,6 +59,10 @@ export class ResponseModel { return new FieldModel(parser, { ...header, name }, '', options); }); } + + if (options.showExtensions) { + this.extensions = extractExtensions(info, options.showExtensions); + } } @action diff --git a/src/types/open-api.d.ts b/src/types/open-api.d.ts index 7134890ea0..db67699f7c 100644 --- a/src/types/open-api.d.ts +++ b/src/types/open-api.d.ts @@ -182,7 +182,7 @@ export interface OpenAPIRequestBody { } export interface OpenAPIResponses { - [code: string]: OpenAPIResponse; + [code: string]: Referenced; } export interface OpenAPIResponse {