diff --git a/demo/playground/hmr-playground.tsx b/demo/playground/hmr-playground.tsx index d9726c9f..1fedff07 100644 --- a/demo/playground/hmr-playground.tsx +++ b/demo/playground/hmr-playground.tsx @@ -6,6 +6,7 @@ import { AppContainer } from 'react-hot-loader'; import { Redoc, RedocProps } from '../../src/components/Redoc/Redoc'; import { AppStore } from '../../src/services/AppStore'; import { loadAndBundleSpec } from '../../src/utils/loadAndBundleSpec'; +import { RedocRawOptions } from '../../src/services/RedocNormalizedOptions'; const renderRoot = (Component: typeof Redoc, props: RedocProps) => render( @@ -23,12 +24,12 @@ const swagger = window.location.search.indexOf('swagger') > -1; //compatibility const specUrl = swagger ? 'swagger.yaml' : big ? 'big-openapi.json' : 'openapi.yaml'; let store; -const options = {}; +const options: RedocRawOptions = { expandResponses: 'all' }; async function init() { const spec = await loadAndBundleSpec(specUrl); - store = new AppStore(spec, specUrl); - renderRoot(Redoc, { store: store, options }); + store = new AppStore(spec, specUrl, options); + renderRoot(Redoc, { store: store }); } init(); @@ -43,7 +44,7 @@ if (module.hot) { store = AppStore.fromJS(state); } - renderRoot(Redoc, { store: store, options }); + renderRoot(Redoc, { store: store }); }; module.hot.accept(['../../src/components/Redoc/Redoc'], reload()); diff --git a/src/components/OptionsProvider.ts b/src/components/OptionsProvider.ts index 9d332b77..c86ce252 100644 --- a/src/components/OptionsProvider.ts +++ b/src/components/OptionsProvider.ts @@ -1,10 +1,10 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; -import { RedocNormalizedOptions, RedocRawOptions } from '../services/RedocNormalizedOptions'; +import { RedocNormalizedOptions } from '../services/RedocNormalizedOptions'; export interface OptionsProviderProps { - options: RedocRawOptions; + options: RedocNormalizedOptions; } export class OptionsProvider extends React.Component { @@ -14,7 +14,7 @@ export class OptionsProvider extends React.Component { getChildContext() { return { - redocOptions: new RedocNormalizedOptions(this.props.options), + redocOptions: this.props.options, }; } diff --git a/src/components/Redoc/Redoc.tsx b/src/components/Redoc/Redoc.tsx index 1d767d84..3473cc3f 100644 --- a/src/components/Redoc/Redoc.tsx +++ b/src/components/Redoc/Redoc.tsx @@ -11,19 +11,14 @@ import { ContentItems } from '../ContentItems/ContentItems'; import { AppStore } from '../../services'; import { OptionsProvider } from '../OptionsProvider'; import { StickySidebar } from '../StickySidebar/StickySidebar'; -import { RedocRawOptions } from '../../services/RedocNormalizedOptions'; - -import defaultTheme from '../../theme'; export interface RedocProps { store: AppStore; - options?: RedocRawOptions; } export class Redoc extends React.Component { static propTypes = { store: PropTypes.instanceOf(AppStore).isRequired, - options: PropTypes.object, }; componentDidMount() { @@ -31,9 +26,9 @@ export class Redoc extends React.Component { } render() { - const { store: { spec, menu }, options = {} } = this.props; + const { store: { spec, menu, options } } = this.props; return ( - + diff --git a/src/components/RedocStandalone.tsx b/src/components/RedocStandalone.tsx index fb241e39..2c805e7f 100644 --- a/src/components/RedocStandalone.tsx +++ b/src/components/RedocStandalone.tsx @@ -1,18 +1,15 @@ import * as React from 'react'; -import { ThemeInterface } from '../theme'; - import { LoadingWrap } from './LoadingWrap/LoadingWrap'; import { StoreProvider } from './StoreProvider'; import { ErrorBoundary } from './ErrorBoundary'; import { Redoc } from './Redoc/Redoc'; +import { RedocRawOptions } from '../services/RedocNormalizedOptions'; export interface RedocStandaloneProps { spec?: object; specUrl?: string; - options?: { - theme?: ThemeInterface; - }; + options?: RedocRawOptions; } export class RedocStandalone extends React.Component { @@ -41,10 +38,10 @@ export class RedocStandalone extends React.Component { return ( - + {({ loading, store }) => ( - + )} diff --git a/src/components/Responses/Response.tsx b/src/components/Responses/Response.tsx index ccfbb419..81c99bc9 100644 --- a/src/components/Responses/Response.tsx +++ b/src/components/Responses/Response.tsx @@ -34,23 +34,24 @@ export class ResponseView extends React.Component<{ response: ResponseModel }> { code={code} opened={expanded} /> - {expanded && ( - - - ( - - Response Schema: - - )} - > - {({ schema }) => { - return ; - }} - - - )} + {expanded && + !empty && ( + + + ( + + Response Schema: + + )} + > + {({ schema }) => { + return ; + }} + + + )} ); } diff --git a/src/components/Responses/ResponsesList.tsx b/src/components/Responses/ResponsesList.tsx index 70a6a8f9..c12f29c5 100644 --- a/src/components/Responses/ResponsesList.tsx +++ b/src/components/Responses/ResponsesList.tsx @@ -11,9 +11,11 @@ const ResponsesHeader = styled.h3` font-weight: normal; `; -export class ResponsesList extends React.PureComponent<{ +export interface ResponseListProps { responses: ResponseModel[]; -}> { +} + +export class ResponsesList extends React.PureComponent { render() { const { responses } = this.props; diff --git a/src/components/StoreProvider.ts b/src/components/StoreProvider.ts index 784fda23..103c8192 100644 --- a/src/components/StoreProvider.ts +++ b/src/components/StoreProvider.ts @@ -2,12 +2,15 @@ import { Component } from 'react'; import { AppStore } from '../services/'; import { loadAndBundleSpec } from '../utils'; +import { RedocRawOptions } from '../services/RedocNormalizedOptions'; interface SpecProps { specUrl?: string; spec?: object; store?: AppStore; + options?: RedocRawOptions; + children?: any; } @@ -33,7 +36,7 @@ export class StoreProvider extends Component { } async load() { - let { specUrl, spec } = this.props; + let { specUrl, spec, options } = this.props; this.setState({ loading: true, @@ -43,7 +46,7 @@ export class StoreProvider extends Component { const resolvedSpec = await loadAndBundleSpec(spec || specUrl!); this.setState({ loading: false, - store: new AppStore(resolvedSpec, specUrl), + store: new AppStore(resolvedSpec, specUrl, options), }); } catch (e) { this.setState({ diff --git a/src/services/AppStore.ts b/src/services/AppStore.ts index bb1d8936..9cc10e0d 100644 --- a/src/services/AppStore.ts +++ b/src/services/AppStore.ts @@ -3,6 +3,7 @@ import { SpecStore } from './models'; import { MenuStore } from './MenuStore'; import { ScrollService } from './ScrollService'; import { loadAndBundleSpec } from '../utils/loadAndBundleSpec'; +import { RedocNormalizedOptions, RedocRawOptions } from './RedocNormalizedOptions'; type StoreData = { menu: { @@ -12,22 +13,31 @@ type StoreData = { url: string; data: any; }; + options: RedocRawOptions; }; -export async function createStore(spec: object, specUrl: string) { +export async function createStore( + spec: object, + specUrl: string | undefined, + options: RedocRawOptions = {}, +) { const resolvedSpec = await loadAndBundleSpec(spec || specUrl); - return new AppStore(resolvedSpec, specUrl); + return new AppStore(resolvedSpec, specUrl, options); } export class AppStore { menu: MenuStore; spec: SpecStore; + rawOptions: RedocRawOptions; + options: RedocNormalizedOptions; private scroll: ScrollService; - constructor(spec: OpenAPISpec, specUrl?: string) { + constructor(spec: OpenAPISpec, specUrl?: string, options: RedocRawOptions = {}) { + this.rawOptions = options; + this.options = new RedocNormalizedOptions(options); this.scroll = new ScrollService(); - this.spec = new SpecStore(spec, specUrl); + this.spec = new SpecStore(spec, specUrl, this.options); this.menu = new MenuStore(this.spec, this.scroll); } @@ -50,6 +60,7 @@ export class AppStore { url: this.spec.parser.specUrl, data: this.spec.parser.spec, }, + options: this.rawOptions, }; } /** @@ -58,7 +69,7 @@ export class AppStore { */ // TODO: static fromJS(state: StoreData): AppStore { - const inst = new AppStore(state.spec.data, state.spec.url); + const inst = new AppStore(state.spec.data, state.spec.url, state.options); inst.menu.activeItemIdx = state.menu.activeItemIdx || 0; inst.menu.activate(inst.menu.flatItems[inst.menu.activeItemIdx]); return inst; diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 35892b76..8387de2f 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -3,6 +3,7 @@ import { GroupModel, OperationModel } from './models'; import { JsonPointer, isOperationName } from '../utils'; import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types'; import { MarkdownRenderer } from './MarkdownRenderer'; +import { RedocNormalizedOptions } from './RedocNormalizedOptions'; export type TagInfo = OpenAPITag & { operations: ExtendedOpenAPIOperation[]; @@ -29,16 +30,21 @@ export class MenuBuilder { /** * Builds page content structure based on tags */ - static buildStructure(parser: OpenAPIParser): ContentItemModel[] { + static buildStructure( + parser: OpenAPIParser, + options: RedocNormalizedOptions, + ): ContentItemModel[] { const spec = parser.spec; const items: ContentItemModel[] = []; const tagsMap = MenuBuilder.getTagsWithOperations(spec); items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '')); if (spec['x-tagGroups']) { - items.push(...MenuBuilder.getTagGroupsItems(parser, undefined, spec['x-tagGroups'], tagsMap)); + items.push( + ...MenuBuilder.getTagGroupsItems(parser, undefined, spec['x-tagGroups'], tagsMap, options), + ); } else { - items.push(...MenuBuilder.getTagsItems(parser, tagsMap)); + items.push(...MenuBuilder.getTagsItems(parser, tagsMap, undefined, undefined, options)); } return items; } @@ -62,12 +68,13 @@ export class MenuBuilder { parent: GroupModel | undefined, groups: TagGroup[], tags: TagsInfoMap, + options: RedocNormalizedOptions, ): GroupModel[] { let res: GroupModel[] = []; for (let group of groups) { let item = new GroupModel('group', group, parent); item.depth = GROUP_DEPTH; - item.items = MenuBuilder.getTagsItems(parser, tags, item, group); + item.items = MenuBuilder.getTagsItems(parser, tags, item, group, options); res.push(item); } // TODO checkAllTagsUsedInGroups @@ -83,8 +90,9 @@ export class MenuBuilder { static getTagsItems( parser: OpenAPIParser, tagsMap: TagsInfoMap, - parent?: GroupModel, - group?: TagGroup, + parent: GroupModel | undefined, + group: TagGroup | undefined, + options: RedocNormalizedOptions, ): ContentItemModel[] { let tagNames; @@ -108,11 +116,11 @@ export class MenuBuilder { if (!tag) continue; let item = new GroupModel('tag', tag, parent); item.depth = GROUP_DEPTH + 1; - item.items = this.getOperationsItems(parser, item, tag, item.depth + 1); + item.items = this.getOperationsItems(parser, item, tag, item.depth + 1, options); // don't put empty tag into content, instead put its operations if (tag.name === '') { - let items = this.getOperationsItems(parser, undefined, tag, item.depth); + let items = this.getOperationsItems(parser, undefined, tag, item.depth, options); res.push(...items); continue; } @@ -133,6 +141,7 @@ export class MenuBuilder { parent: GroupModel | undefined, tag: TagInfo, depth: number, + options: RedocNormalizedOptions, ): OperationModel[] { if (tag.operations.length === 0) { return []; @@ -140,7 +149,7 @@ export class MenuBuilder { let res: OperationModel[] = []; for (let operationInfo of tag.operations) { - let operation = new OperationModel(parser, operationInfo, parent); + let operation = new OperationModel(parser, operationInfo, parent, options); operation.depth = depth; res.push(operation); } diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index ce729df7..9f86fd0e 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -1,20 +1,43 @@ import { ThemeInterface } from '../theme'; import { isNumeric } from '../utils/helpers'; +import defaultTheme from '../theme'; export interface RedocRawOptions { theme?: ThemeInterface; scrollYOffset?: number | string | Function; hideHostname?: boolean | string; + expandResponses?: string | 'all'; } export class RedocNormalizedOptions { theme: ThemeInterface; scrollYOffset: () => number; hideHostname: boolean; + expandResponses: { [code: string]: boolean } | 'all'; constructor(raw: RedocRawOptions) { + this.theme = { ...(raw.theme || {}), ...defaultTheme }; // todo: merge deep this.scrollYOffset = RedocNormalizedOptions.normalizeScrollYOffset(raw.scrollYOffset); this.hideHostname = RedocNormalizedOptions.normalizeHideHostname(raw.hideHostname); + this.expandResponses = RedocNormalizedOptions.normalizeExpandResponses(raw.expandResponses); + } + + static normalizeExpandResponses(value: RedocRawOptions['expandResponses']) { + if (value === 'all') { + return 'all'; + } + if (typeof value === 'string') { + const res = {}; + value.split(',').forEach(code => { + res[code.trim()] = true; + }); + return res; + } else { + console.warn( + `expandResponses must be a string but received value "${value}" of type ${typeof value}`, + ); + return {}; + } } static normalizeHideHostname(value: RedocRawOptions['hideHostname']): boolean { diff --git a/src/services/SpecStore.ts b/src/services/SpecStore.ts index 2ff040f8..69a8ee22 100644 --- a/src/services/SpecStore.ts +++ b/src/services/SpecStore.ts @@ -6,6 +6,7 @@ import { observable, computed } from 'mobx'; import { MenuBuilder } from './MenuBuilder'; import { OpenAPIParser } from './OpenAPIParser'; import { ApiInfoModel } from './models/ApiInfo'; +import { RedocNormalizedOptions } from './RedocNormalizedOptions'; /** * Store that containts all the specification related information in the form of tree @@ -13,7 +14,11 @@ import { ApiInfoModel } from './models/ApiInfo'; export class SpecStore { @observable.ref parser: OpenAPIParser; - constructor(spec: OpenAPISpec, specUrl?: string) { + constructor( + spec: OpenAPISpec, + specUrl: string | undefined, + private options: RedocNormalizedOptions, + ) { this.parser = new OpenAPIParser(spec, specUrl); } @@ -29,7 +34,7 @@ export class SpecStore { @computed get operationGroups() { - return MenuBuilder.buildStructure(this.parser); + return MenuBuilder.buildStructure(this.parser, this.options); } @computed diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts index cab0600c..1c806158 100644 --- a/src/services/models/Operation.ts +++ b/src/services/models/Operation.ts @@ -14,6 +14,7 @@ import { CodeSample } from './types'; import { OpenAPIParser } from '../OpenAPIParser'; import { ContentItemModel, ExtendedOpenAPIOperation } from '../MenuBuilder'; import { JsonPointer, getOperationSummary, isAbsolutePath, stripTrailingSlash } from '../../utils'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; function isNumeric(n) { return !isNaN(parseFloat(n)) && isFinite(n); @@ -51,7 +52,12 @@ export class OperationModel implements IMenuItem { servers: OpenAPIServer[]; codeSamples: CodeSample[]; - constructor(parser: OpenAPIParser, operationSpec: ExtendedOpenAPIOperation, parent?: GroupModel) { + constructor( + parser: OpenAPIParser, + operationSpec: ExtendedOpenAPIOperation, + parent: GroupModel | undefined, + options: RedocNormalizedOptions, + ) { this.id = operationSpec._$ref; this.name = getOperationSummary(operationSpec); this.description = operationSpec.description; @@ -81,9 +87,15 @@ export class OperationModel implements IMenuItem { } return isNumeric(code) || code === 'default'; }) // filter out other props (e.g. x-props) - .map( - code => new ResponseModel(parser, code, hasSuccessResponses, operationSpec.responses[code]), - ); + .map(code => { + return new ResponseModel( + parser, + code, + hasSuccessResponses, + operationSpec.responses[code], + options, + ); + }); this.servers = normalizeServers( parser.specUrl, diff --git a/src/services/models/Response.ts b/src/services/models/Response.ts index 93b15e4f..7691ee23 100644 --- a/src/services/models/Response.ts +++ b/src/services/models/Response.ts @@ -6,9 +6,10 @@ import { FieldModel } from './Field'; import { MediaContentModel } from './MediaContent'; import { OpenAPIParser } from '../OpenAPIParser'; import { getStatusCodeType } from '../../utils'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; export class ResponseModel { - @observable public expanded: boolean = false; + @observable public expanded: boolean; public content?: MediaContentModel; public code: string; @@ -16,7 +17,15 @@ export class ResponseModel { public type: string; public headers: FieldModel[] = []; - constructor(parser: OpenAPIParser, code: string, defaultAsError: boolean, infoOrRef: Referenced) { + constructor( + parser: OpenAPIParser, + code: string, + defaultAsError: boolean, + infoOrRef: Referenced, + options: RedocNormalizedOptions, + ) { + this.expanded = options.expandResponses === 'all' || options.expandResponses[code]; + const info = parser.deref(infoOrRef); parser.exitRef(infoOrRef); this.code = code;