Implement expandResponses option

This commit is contained in:
Roman Hotsiy 2017-11-21 13:00:33 +02:00
parent e85718225d
commit f52a05a2b5
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
13 changed files with 132 additions and 64 deletions

View File

@ -6,6 +6,7 @@ import { AppContainer } from 'react-hot-loader';
import { Redoc, RedocProps } from '../../src/components/Redoc/Redoc'; import { Redoc, RedocProps } from '../../src/components/Redoc/Redoc';
import { AppStore } from '../../src/services/AppStore'; import { AppStore } from '../../src/services/AppStore';
import { loadAndBundleSpec } from '../../src/utils/loadAndBundleSpec'; import { loadAndBundleSpec } from '../../src/utils/loadAndBundleSpec';
import { RedocRawOptions } from '../../src/services/RedocNormalizedOptions';
const renderRoot = (Component: typeof Redoc, props: RedocProps) => const renderRoot = (Component: typeof Redoc, props: RedocProps) =>
render( 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'; const specUrl = swagger ? 'swagger.yaml' : big ? 'big-openapi.json' : 'openapi.yaml';
let store; let store;
const options = {}; const options: RedocRawOptions = { expandResponses: 'all' };
async function init() { async function init() {
const spec = await loadAndBundleSpec(specUrl); const spec = await loadAndBundleSpec(specUrl);
store = new AppStore(spec, specUrl); store = new AppStore(spec, specUrl, options);
renderRoot(Redoc, { store: store, options }); renderRoot(Redoc, { store: store });
} }
init(); init();
@ -43,7 +44,7 @@ if (module.hot) {
store = AppStore.fromJS(state); store = AppStore.fromJS(state);
} }
renderRoot(Redoc, { store: store, options }); renderRoot(Redoc, { store: store });
}; };
module.hot.accept(['../../src/components/Redoc/Redoc'], reload()); module.hot.accept(['../../src/components/Redoc/Redoc'], reload());

View File

@ -1,10 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import { RedocNormalizedOptions, RedocRawOptions } from '../services/RedocNormalizedOptions'; import { RedocNormalizedOptions } from '../services/RedocNormalizedOptions';
export interface OptionsProviderProps { export interface OptionsProviderProps {
options: RedocRawOptions; options: RedocNormalizedOptions;
} }
export class OptionsProvider extends React.Component<OptionsProviderProps> { export class OptionsProvider extends React.Component<OptionsProviderProps> {
@ -14,7 +14,7 @@ export class OptionsProvider extends React.Component<OptionsProviderProps> {
getChildContext() { getChildContext() {
return { return {
redocOptions: new RedocNormalizedOptions(this.props.options), redocOptions: this.props.options,
}; };
} }

View File

@ -11,19 +11,14 @@ import { ContentItems } from '../ContentItems/ContentItems';
import { AppStore } from '../../services'; import { AppStore } from '../../services';
import { OptionsProvider } from '../OptionsProvider'; import { OptionsProvider } from '../OptionsProvider';
import { StickySidebar } from '../StickySidebar/StickySidebar'; import { StickySidebar } from '../StickySidebar/StickySidebar';
import { RedocRawOptions } from '../../services/RedocNormalizedOptions';
import defaultTheme from '../../theme';
export interface RedocProps { export interface RedocProps {
store: AppStore; store: AppStore;
options?: RedocRawOptions;
} }
export class Redoc extends React.Component<RedocProps> { export class Redoc extends React.Component<RedocProps> {
static propTypes = { static propTypes = {
store: PropTypes.instanceOf(AppStore).isRequired, store: PropTypes.instanceOf(AppStore).isRequired,
options: PropTypes.object,
}; };
componentDidMount() { componentDidMount() {
@ -31,9 +26,9 @@ export class Redoc extends React.Component<RedocProps> {
} }
render() { render() {
const { store: { spec, menu }, options = {} } = this.props; const { store: { spec, menu, options } } = this.props;
return ( return (
<ThemeProvider theme={{ ...options.theme, ...defaultTheme }}> <ThemeProvider theme={options.theme}>
<OptionsProvider options={options}> <OptionsProvider options={options}>
<RedocWrap className="redoc-wrap"> <RedocWrap className="redoc-wrap">
<StickySidebar className="menu-content"> <StickySidebar className="menu-content">

View File

@ -1,18 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeInterface } from '../theme';
import { LoadingWrap } from './LoadingWrap/LoadingWrap'; import { LoadingWrap } from './LoadingWrap/LoadingWrap';
import { StoreProvider } from './StoreProvider'; import { StoreProvider } from './StoreProvider';
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from './ErrorBoundary';
import { Redoc } from './Redoc/Redoc'; import { Redoc } from './Redoc/Redoc';
import { RedocRawOptions } from '../services/RedocNormalizedOptions';
export interface RedocStandaloneProps { export interface RedocStandaloneProps {
spec?: object; spec?: object;
specUrl?: string; specUrl?: string;
options?: { options?: RedocRawOptions;
theme?: ThemeInterface;
};
} }
export class RedocStandalone extends React.Component<RedocStandaloneProps> { export class RedocStandalone extends React.Component<RedocStandaloneProps> {
@ -41,10 +38,10 @@ export class RedocStandalone extends React.Component<RedocStandaloneProps> {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<StoreProvider spec={spec} specUrl={specUrl}> <StoreProvider spec={spec} specUrl={specUrl} options={options}>
{({ loading, store }) => ( {({ loading, store }) => (
<LoadingWrap loading={loading}> <LoadingWrap loading={loading}>
<Redoc store={store} options={options} /> <Redoc store={store} />
</LoadingWrap> </LoadingWrap>
)} )}
</StoreProvider> </StoreProvider>

View File

@ -34,23 +34,24 @@ export class ResponseView extends React.Component<{ response: ResponseModel }> {
code={code} code={code}
opened={expanded} opened={expanded}
/> />
{expanded && ( {expanded &&
<ResponseDetailsWrap> !empty && (
<ResponseHeaders headers={headers} /> <ResponseDetailsWrap>
<MediaTypesSwitch <ResponseHeaders headers={headers} />
content={content!} <MediaTypesSwitch
renderDropdown={props => ( content={content!}
<UnderlinedHeader key="header"> renderDropdown={props => (
Response Schema: <DropdownOrLabel {...props} /> <UnderlinedHeader key="header">
</UnderlinedHeader> Response Schema: <DropdownOrLabel {...props} />
)} </UnderlinedHeader>
> )}
{({ schema }) => { >
return <Schema skipWriteOnly={true} key="schema" schema={schema} />; {({ schema }) => {
}} return <Schema skipWriteOnly={true} key="schema" schema={schema} />;
</MediaTypesSwitch> }}
</ResponseDetailsWrap> </MediaTypesSwitch>
)} </ResponseDetailsWrap>
)}
</div> </div>
); );
} }

View File

@ -11,9 +11,11 @@ const ResponsesHeader = styled.h3`
font-weight: normal; font-weight: normal;
`; `;
export class ResponsesList extends React.PureComponent<{ export interface ResponseListProps {
responses: ResponseModel[]; responses: ResponseModel[];
}> { }
export class ResponsesList extends React.PureComponent<ResponseListProps> {
render() { render() {
const { responses } = this.props; const { responses } = this.props;

View File

@ -2,12 +2,15 @@ import { Component } from 'react';
import { AppStore } from '../services/'; import { AppStore } from '../services/';
import { loadAndBundleSpec } from '../utils'; import { loadAndBundleSpec } from '../utils';
import { RedocRawOptions } from '../services/RedocNormalizedOptions';
interface SpecProps { interface SpecProps {
specUrl?: string; specUrl?: string;
spec?: object; spec?: object;
store?: AppStore; store?: AppStore;
options?: RedocRawOptions;
children?: any; children?: any;
} }
@ -33,7 +36,7 @@ export class StoreProvider extends Component<SpecProps, SpecState> {
} }
async load() { async load() {
let { specUrl, spec } = this.props; let { specUrl, spec, options } = this.props;
this.setState({ this.setState({
loading: true, loading: true,
@ -43,7 +46,7 @@ export class StoreProvider extends Component<SpecProps, SpecState> {
const resolvedSpec = await loadAndBundleSpec(spec || specUrl!); const resolvedSpec = await loadAndBundleSpec(spec || specUrl!);
this.setState({ this.setState({
loading: false, loading: false,
store: new AppStore(resolvedSpec, specUrl), store: new AppStore(resolvedSpec, specUrl, options),
}); });
} catch (e) { } catch (e) {
this.setState({ this.setState({

View File

@ -3,6 +3,7 @@ import { SpecStore } from './models';
import { MenuStore } from './MenuStore'; import { MenuStore } from './MenuStore';
import { ScrollService } from './ScrollService'; import { ScrollService } from './ScrollService';
import { loadAndBundleSpec } from '../utils/loadAndBundleSpec'; import { loadAndBundleSpec } from '../utils/loadAndBundleSpec';
import { RedocNormalizedOptions, RedocRawOptions } from './RedocNormalizedOptions';
type StoreData = { type StoreData = {
menu: { menu: {
@ -12,22 +13,31 @@ type StoreData = {
url: string; url: string;
data: any; 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); const resolvedSpec = await loadAndBundleSpec(spec || specUrl);
return new AppStore(resolvedSpec, specUrl); return new AppStore(resolvedSpec, specUrl, options);
} }
export class AppStore { export class AppStore {
menu: MenuStore; menu: MenuStore;
spec: SpecStore; spec: SpecStore;
rawOptions: RedocRawOptions;
options: RedocNormalizedOptions;
private scroll: ScrollService; 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.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); this.menu = new MenuStore(this.spec, this.scroll);
} }
@ -50,6 +60,7 @@ export class AppStore {
url: this.spec.parser.specUrl, url: this.spec.parser.specUrl,
data: this.spec.parser.spec, data: this.spec.parser.spec,
}, },
options: this.rawOptions,
}; };
} }
/** /**
@ -58,7 +69,7 @@ export class AppStore {
*/ */
// TODO: // TODO:
static fromJS(state: StoreData): AppStore { 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.activeItemIdx = state.menu.activeItemIdx || 0;
inst.menu.activate(inst.menu.flatItems[inst.menu.activeItemIdx]); inst.menu.activate(inst.menu.flatItems[inst.menu.activeItemIdx]);
return inst; return inst;

View File

@ -3,6 +3,7 @@ import { GroupModel, OperationModel } from './models';
import { JsonPointer, isOperationName } from '../utils'; import { JsonPointer, isOperationName } from '../utils';
import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types'; import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types';
import { MarkdownRenderer } from './MarkdownRenderer'; import { MarkdownRenderer } from './MarkdownRenderer';
import { RedocNormalizedOptions } from './RedocNormalizedOptions';
export type TagInfo = OpenAPITag & { export type TagInfo = OpenAPITag & {
operations: ExtendedOpenAPIOperation[]; operations: ExtendedOpenAPIOperation[];
@ -29,16 +30,21 @@ export class MenuBuilder {
/** /**
* Builds page content structure based on tags * Builds page content structure based on tags
*/ */
static buildStructure(parser: OpenAPIParser): ContentItemModel[] { static buildStructure(
parser: OpenAPIParser,
options: RedocNormalizedOptions,
): ContentItemModel[] {
const spec = parser.spec; const spec = parser.spec;
const items: ContentItemModel[] = []; const items: ContentItemModel[] = [];
const tagsMap = MenuBuilder.getTagsWithOperations(spec); const tagsMap = MenuBuilder.getTagsWithOperations(spec);
items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '')); items.push(...MenuBuilder.addMarkdownItems(spec.info.description || ''));
if (spec['x-tagGroups']) { 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 { } else {
items.push(...MenuBuilder.getTagsItems(parser, tagsMap)); items.push(...MenuBuilder.getTagsItems(parser, tagsMap, undefined, undefined, options));
} }
return items; return items;
} }
@ -62,12 +68,13 @@ export class MenuBuilder {
parent: GroupModel | undefined, parent: GroupModel | undefined,
groups: TagGroup[], groups: TagGroup[],
tags: TagsInfoMap, tags: TagsInfoMap,
options: RedocNormalizedOptions,
): GroupModel[] { ): GroupModel[] {
let res: GroupModel[] = []; let res: GroupModel[] = [];
for (let group of groups) { for (let group of groups) {
let item = new GroupModel('group', group, parent); let item = new GroupModel('group', group, parent);
item.depth = GROUP_DEPTH; item.depth = GROUP_DEPTH;
item.items = MenuBuilder.getTagsItems(parser, tags, item, group); item.items = MenuBuilder.getTagsItems(parser, tags, item, group, options);
res.push(item); res.push(item);
} }
// TODO checkAllTagsUsedInGroups // TODO checkAllTagsUsedInGroups
@ -83,8 +90,9 @@ export class MenuBuilder {
static getTagsItems( static getTagsItems(
parser: OpenAPIParser, parser: OpenAPIParser,
tagsMap: TagsInfoMap, tagsMap: TagsInfoMap,
parent?: GroupModel, parent: GroupModel | undefined,
group?: TagGroup, group: TagGroup | undefined,
options: RedocNormalizedOptions,
): ContentItemModel[] { ): ContentItemModel[] {
let tagNames; let tagNames;
@ -108,11 +116,11 @@ export class MenuBuilder {
if (!tag) continue; if (!tag) continue;
let item = new GroupModel('tag', tag, parent); let item = new GroupModel('tag', tag, parent);
item.depth = GROUP_DEPTH + 1; 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 // don't put empty tag into content, instead put its operations
if (tag.name === '') { 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); res.push(...items);
continue; continue;
} }
@ -133,6 +141,7 @@ export class MenuBuilder {
parent: GroupModel | undefined, parent: GroupModel | undefined,
tag: TagInfo, tag: TagInfo,
depth: number, depth: number,
options: RedocNormalizedOptions,
): OperationModel[] { ): OperationModel[] {
if (tag.operations.length === 0) { if (tag.operations.length === 0) {
return []; return [];
@ -140,7 +149,7 @@ export class MenuBuilder {
let res: OperationModel[] = []; let res: OperationModel[] = [];
for (let operationInfo of tag.operations) { for (let operationInfo of tag.operations) {
let operation = new OperationModel(parser, operationInfo, parent); let operation = new OperationModel(parser, operationInfo, parent, options);
operation.depth = depth; operation.depth = depth;
res.push(operation); res.push(operation);
} }

View File

@ -1,20 +1,43 @@
import { ThemeInterface } from '../theme'; import { ThemeInterface } from '../theme';
import { isNumeric } from '../utils/helpers'; import { isNumeric } from '../utils/helpers';
import defaultTheme from '../theme';
export interface RedocRawOptions { export interface RedocRawOptions {
theme?: ThemeInterface; theme?: ThemeInterface;
scrollYOffset?: number | string | Function; scrollYOffset?: number | string | Function;
hideHostname?: boolean | string; hideHostname?: boolean | string;
expandResponses?: string | 'all';
} }
export class RedocNormalizedOptions { export class RedocNormalizedOptions {
theme: ThemeInterface; theme: ThemeInterface;
scrollYOffset: () => number; scrollYOffset: () => number;
hideHostname: boolean; hideHostname: boolean;
expandResponses: { [code: string]: boolean } | 'all';
constructor(raw: RedocRawOptions) { constructor(raw: RedocRawOptions) {
this.theme = { ...(raw.theme || {}), ...defaultTheme }; // todo: merge deep
this.scrollYOffset = RedocNormalizedOptions.normalizeScrollYOffset(raw.scrollYOffset); this.scrollYOffset = RedocNormalizedOptions.normalizeScrollYOffset(raw.scrollYOffset);
this.hideHostname = RedocNormalizedOptions.normalizeHideHostname(raw.hideHostname); 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 { static normalizeHideHostname(value: RedocRawOptions['hideHostname']): boolean {

View File

@ -6,6 +6,7 @@ import { observable, computed } from 'mobx';
import { MenuBuilder } from './MenuBuilder'; import { MenuBuilder } from './MenuBuilder';
import { OpenAPIParser } from './OpenAPIParser'; import { OpenAPIParser } from './OpenAPIParser';
import { ApiInfoModel } from './models/ApiInfo'; import { ApiInfoModel } from './models/ApiInfo';
import { RedocNormalizedOptions } from './RedocNormalizedOptions';
/** /**
* Store that containts all the specification related information in the form of tree * 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 { export class SpecStore {
@observable.ref parser: OpenAPIParser; @observable.ref parser: OpenAPIParser;
constructor(spec: OpenAPISpec, specUrl?: string) { constructor(
spec: OpenAPISpec,
specUrl: string | undefined,
private options: RedocNormalizedOptions,
) {
this.parser = new OpenAPIParser(spec, specUrl); this.parser = new OpenAPIParser(spec, specUrl);
} }
@ -29,7 +34,7 @@ export class SpecStore {
@computed @computed
get operationGroups() { get operationGroups() {
return MenuBuilder.buildStructure(this.parser); return MenuBuilder.buildStructure(this.parser, this.options);
} }
@computed @computed

View File

@ -14,6 +14,7 @@ import { CodeSample } from './types';
import { OpenAPIParser } from '../OpenAPIParser'; import { OpenAPIParser } from '../OpenAPIParser';
import { ContentItemModel, ExtendedOpenAPIOperation } from '../MenuBuilder'; import { ContentItemModel, ExtendedOpenAPIOperation } from '../MenuBuilder';
import { JsonPointer, getOperationSummary, isAbsolutePath, stripTrailingSlash } from '../../utils'; import { JsonPointer, getOperationSummary, isAbsolutePath, stripTrailingSlash } from '../../utils';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
function isNumeric(n) { function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n); return !isNaN(parseFloat(n)) && isFinite(n);
@ -51,7 +52,12 @@ export class OperationModel implements IMenuItem {
servers: OpenAPIServer[]; servers: OpenAPIServer[];
codeSamples: CodeSample[]; codeSamples: CodeSample[];
constructor(parser: OpenAPIParser, operationSpec: ExtendedOpenAPIOperation, parent?: GroupModel) { constructor(
parser: OpenAPIParser,
operationSpec: ExtendedOpenAPIOperation,
parent: GroupModel | undefined,
options: RedocNormalizedOptions,
) {
this.id = operationSpec._$ref; this.id = operationSpec._$ref;
this.name = getOperationSummary(operationSpec); this.name = getOperationSummary(operationSpec);
this.description = operationSpec.description; this.description = operationSpec.description;
@ -81,9 +87,15 @@ export class OperationModel implements IMenuItem {
} }
return isNumeric(code) || code === 'default'; return isNumeric(code) || code === 'default';
}) // filter out other props (e.g. x-props) }) // filter out other props (e.g. x-props)
.map( .map(code => {
code => new ResponseModel(parser, code, hasSuccessResponses, operationSpec.responses[code]), return new ResponseModel(
); parser,
code,
hasSuccessResponses,
operationSpec.responses[code],
options,
);
});
this.servers = normalizeServers( this.servers = normalizeServers(
parser.specUrl, parser.specUrl,

View File

@ -6,9 +6,10 @@ import { FieldModel } from './Field';
import { MediaContentModel } from './MediaContent'; import { MediaContentModel } from './MediaContent';
import { OpenAPIParser } from '../OpenAPIParser'; import { OpenAPIParser } from '../OpenAPIParser';
import { getStatusCodeType } from '../../utils'; import { getStatusCodeType } from '../../utils';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
export class ResponseModel { export class ResponseModel {
@observable public expanded: boolean = false; @observable public expanded: boolean;
public content?: MediaContentModel; public content?: MediaContentModel;
public code: string; public code: string;
@ -16,7 +17,15 @@ export class ResponseModel {
public type: string; public type: string;
public headers: FieldModel[] = []; public headers: FieldModel[] = [];
constructor(parser: OpenAPIParser, code: string, defaultAsError: boolean, infoOrRef: Referenced<OpenAPIResponse>) { constructor(
parser: OpenAPIParser,
code: string,
defaultAsError: boolean,
infoOrRef: Referenced<OpenAPIResponse>,
options: RedocNormalizedOptions,
) {
this.expanded = options.expandResponses === 'all' || options.expandResponses[code];
const info = parser.deref(infoOrRef); const info = parser.deref(infoOrRef);
parser.exitRef(infoOrRef); parser.exitRef(infoOrRef);
this.code = code; this.code = code;