feat: support externalValue for examples

implements #551, related to #840
This commit is contained in:
Roman Hotsiy 2019-03-11 17:10:29 +02:00
parent 309901bd31
commit 2cdfcd25cd
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
9 changed files with 179 additions and 46 deletions

View File

@ -57,8 +57,8 @@ export default (env: { playground?: boolean; bench?: boolean } = {}, { mode }) =
env.playground env.playground
? 'playground/hmr-playground.tsx' ? 'playground/hmr-playground.tsx'
: env.bench : env.bench
? '../benchmark/index.tsx' ? '../benchmark/index.tsx'
: 'index.tsx', : 'index.tsx',
), ),
], ],
output: { output: {
@ -141,8 +141,8 @@ export default (env: { playground?: boolean; bench?: boolean } = {}, { mode }) =
template: env.playground template: env.playground
? 'demo/playground/index.html' ? 'demo/playground/index.html'
: env.bench : env.bench
? 'benchmark/index.html' ? 'benchmark/index.html'
: 'demo/index.html', : 'demo/index.html',
}), }),
new ForkTsCheckerWebpackPlugin(), new ForkTsCheckerWebpackPlugin(),
ignore(/js-yaml\/dumper\.js$/), ignore(/js-yaml\/dumper\.js$/),

View File

@ -1,4 +1,5 @@
import styled from '../styled-components'; import styled from '../styled-components';
import { PrismDiv } from './PrismDiv';
export const SampleControls = styled.div` export const SampleControls = styled.div`
opacity: 0.4; opacity: 0.4;
@ -21,3 +22,12 @@ export const SampleControlsWrap = styled.div`
opacity: 1; opacity: 1;
} }
`; `;
export const StyledPre = styled(PrismDiv.withComponent('pre'))`
font-family: ${props => props.theme.typography.code.fontFamily};
font-size: ${props => props.theme.typography.code.fontSize};
overflow-x: auto;
margin: 0;
white-space: ${({ theme }) => (theme.typography.code.wrap ? 'pre-wrap' : 'pre')};
`;

View File

@ -0,0 +1,52 @@
import * as React from 'react';
import { StyledPre } from '../../common-elements/samples';
import { ExampleModel } from '../../services/models';
import { isJsonLike, langFromMime } from '../../utils';
import { JsonViewer } from '../JsonViewer/JsonViewer';
import { SourceCodeWithCopy } from '../SourceCode/SourceCode';
import { ExampleValue } from './ExampleValue';
import { useExternalExample } from './exernalExampleHook';
export interface ExampleProps {
example: ExampleModel;
mimeType: string;
}
export function Example({ example, mimeType }: ExampleProps) {
if (example.value === undefined && example.externalValueUrl) {
return <ExternalExample example={example} mimeType={mimeType} />;
} else {
return <ExampleValue value={example.value} mimeType={mimeType} />;
}
}
export function ExternalExample({ example, mimeType }: ExampleProps) {
let value = useExternalExample(example, mimeType);
if (value === undefined) {
return <span>Loading...</span>;
}
if (value instanceof Error) {
console.log(value);
return (
<StyledPre>
Error loading external example: <br />
<a className={'token string'} href={example.externalValueUrl} target="_blank">
{example.externalValueUrl}
</a>
</StyledPre>
);
}
if (isJsonLike(mimeType)) {
return <JsonViewer data={value} />;
} else {
if (typeof value === 'object') {
// just in case example was cached as json but used as non-json
value = JSON.stringify(value, null, 2);
}
return <SourceCodeWithCopy lang={langFromMime(mimeType)} source={value} />;
}
}

View File

@ -0,0 +1,18 @@
import * as React from 'react';
import { isJsonLike, langFromMime } from '../../utils/openapi';
import { JsonViewer } from '../JsonViewer/JsonViewer';
import { SourceCodeWithCopy } from '../SourceCode/SourceCode';
export interface ExampleValueProps {
value: any;
mimeType: string;
}
export function ExampleValue({ value, mimeType }: ExampleValueProps) {
if (isJsonLike(mimeType)) {
return <JsonViewer data={value} />;
} else {
return <SourceCodeWithCopy lang={langFromMime(mimeType)} source={value} />;
}
}

View File

@ -2,11 +2,9 @@ import * as React from 'react';
import { SmallTabs, Tab, TabList, TabPanel } from '../../common-elements'; import { SmallTabs, Tab, TabList, TabPanel } from '../../common-elements';
import { MediaTypeModel } from '../../services/models'; import { MediaTypeModel } from '../../services/models';
import { JsonViewer } from '../JsonViewer/JsonViewer';
import { SourceCodeWithCopy } from '../SourceCode/SourceCode';
import { NoSampleLabel } from './styled.elements';
import { isJsonLike, langFromMime } from '../../utils'; import { Example } from './Example';
import { NoSampleLabel } from './styled.elements';
export interface PayloadSamplesProps { export interface PayloadSamplesProps {
mediaType: MediaTypeModel; mediaType: MediaTypeModel;
@ -18,13 +16,6 @@ export class MediaTypeSamples extends React.Component<PayloadSamplesProps> {
const mimeType = this.props.mediaType.name; const mimeType = this.props.mediaType.name;
const noSample = <NoSampleLabel>No sample</NoSampleLabel>; const noSample = <NoSampleLabel>No sample</NoSampleLabel>;
const sampleView = isJsonLike(mimeType)
? sample => <JsonViewer data={sample} />
: sample =>
(sample !== undefined && (
<SourceCodeWithCopy lang={langFromMime(mimeType)} source={sample} />
)) ||
noSample;
const examplesNames = Object.keys(examples); const examplesNames = Object.keys(examples);
if (examplesNames.length === 0) { if (examplesNames.length === 0) {
@ -39,13 +30,19 @@ export class MediaTypeSamples extends React.Component<PayloadSamplesProps> {
))} ))}
</TabList> </TabList>
{examplesNames.map(name => ( {examplesNames.map(name => (
<TabPanel key={name}>{sampleView(examples[name].value)}</TabPanel> <TabPanel key={name}>
<Example example={examples[name]} mimeType={mimeType} />
</TabPanel>
))} ))}
</SmallTabs> </SmallTabs>
); );
} else { } else {
const name = examplesNames[0]; const name = examplesNames[0];
return <div>{sampleView(examples[name].value)}</div>; return (
<div>
<Example example={examples[name]} mimeType={mimeType} />
</div>
);
} }
} }
} }

View File

@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import { ExampleModel } from '../../services/models/Example';
export function useExternalExample(example: ExampleModel, mimeType: string) {
const [, setIsLoading] = useState(true); // to trigger component reload
const value = useRef<any>(undefined);
const prevRef = useRef<ExampleModel | undefined>(undefined);
if (prevRef.current !== example) {
value.current = undefined;
}
prevRef.current = example;
useEffect(
() => {
const load = async () => {
setIsLoading(true);
try {
value.current = await example.getExternalValue(mimeType);
} catch (e) {
value.current = e;
}
setIsLoading(false);
};
load();
},
[example, mimeType],
);
return value.current;
}

View File

@ -1,19 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { highlight } from '../../utils'; import { highlight } from '../../utils';
import { SampleControls, SampleControlsWrap } from '../../common-elements'; import { SampleControls, SampleControlsWrap, StyledPre } from '../../common-elements';
import { CopyButtonWrapper } from '../../common-elements/CopyButtonWrapper'; import { CopyButtonWrapper } from '../../common-elements/CopyButtonWrapper';
import { PrismDiv } from '../../common-elements/PrismDiv';
import styled from '../../styled-components';
const StyledPre = styled(PrismDiv.withComponent('pre'))`
font-family: ${props => props.theme.typography.code.fontFamily};
font-size: ${props => props.theme.typography.code.fontSize};
overflow-x: auto;
margin: 0;
white-space: ${({ theme }) => (theme.typography.code.wrap ? 'pre-wrap' : 'pre')};
`;
export interface SourceCodeProps { export interface SourceCodeProps {
source: string; source: string;

View File

@ -1,14 +1,55 @@
import { resolve as urlResolve } from 'url';
import { OpenAPIExample, Referenced } from '../../types'; import { OpenAPIExample, Referenced } from '../../types';
import { isJsonLike } from '../../utils/openapi';
import { OpenAPIParser } from '../OpenAPIParser'; import { OpenAPIParser } from '../OpenAPIParser';
const externalExamplesCache: { [url: string]: Promise<any> } = {};
export class ExampleModel { export class ExampleModel {
value: any; value: any;
summary?: string; summary?: string;
description?: string; description?: string;
externalValue?: string; externalValueUrl?: string;
constructor(parser: OpenAPIParser, infoOrRef: Referenced<OpenAPIExample>) { constructor(parser: OpenAPIParser, infoOrRef: Referenced<OpenAPIExample>) {
Object.assign(this, parser.deref(infoOrRef)); const example = parser.deref(infoOrRef);
this.value = example.value;
this.summary = example.summary;
this.description = example.description;
if (example.externalValue) {
this.externalValueUrl = urlResolve(parser.specUrl || '', example.externalValue);
}
parser.exitRef(infoOrRef); parser.exitRef(infoOrRef);
} }
getExternalValue(mimeType: string): Promise<any> {
if (!this.externalValueUrl) {
return Promise.resolve(undefined);
}
if (externalExamplesCache[this.externalValueUrl]) {
return externalExamplesCache[this.externalValueUrl];
}
externalExamplesCache[this.externalValueUrl] = fetch(this.externalValueUrl).then(res => {
return res.text().then(txt => {
if (!res.ok) {
return Promise.reject(new Error(txt));
}
if (isJsonLike(mimeType)) {
try {
return JSON.parse(txt);
} catch (e) {
return txt;
}
} else {
return txt;
}
});
});
return externalExamplesCache[this.externalValueUrl];
}
} }

View File

@ -1,6 +1,6 @@
import * as Sampler from 'openapi-sampler'; import * as Sampler from 'openapi-sampler';
import { OpenAPIExample, OpenAPIMediaType } from '../../types'; import { OpenAPIMediaType } from '../../types';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
import { SchemaModel } from './Schema'; import { SchemaModel } from './Schema';
@ -9,7 +9,7 @@ import { OpenAPIParser } from '../OpenAPIParser';
import { ExampleModel } from './Example'; import { ExampleModel } from './Example';
export class MediaTypeModel { export class MediaTypeModel {
examples?: { [name: string]: OpenAPIExample }; examples?: { [name: string]: ExampleModel };
schema?: SchemaModel; schema?: SchemaModel;
name: string; name: string;
isRequestType: boolean; isRequestType: boolean;
@ -33,7 +33,7 @@ export class MediaTypeModel {
this.examples = mapValues(info.examples, example => new ExampleModel(parser, example)); this.examples = mapValues(info.examples, example => new ExampleModel(parser, example));
} else if (info.example !== undefined) { } else if (info.example !== undefined) {
this.examples = { this.examples = {
default: new ExampleModel(parser, { value: info.example }), default: new ExampleModel(parser, { value: parser.shalowDeref(info.example) }),
}; };
} else if (isJsonLike(name)) { } else if (isJsonLike(name)) {
this.generateExample(parser, info); this.generateExample(parser, info);
@ -49,28 +49,20 @@ export class MediaTypeModel {
if (this.schema && this.schema.oneOf) { if (this.schema && this.schema.oneOf) {
this.examples = {}; this.examples = {};
for (const subSchema of this.schema.oneOf) { for (const subSchema of this.schema.oneOf) {
const sample = Sampler.sample( const sample = Sampler.sample(subSchema.rawSchema, samplerOptions, parser.spec);
subSchema.rawSchema,
samplerOptions,
parser.spec,
);
if (this.schema.discriminatorProp && typeof sample === 'object' && sample) { if (this.schema.discriminatorProp && typeof sample === 'object' && sample) {
sample[this.schema.discriminatorProp] = subSchema.title; sample[this.schema.discriminatorProp] = subSchema.title;
} }
this.examples[subSchema.title] = { this.examples[subSchema.title] = new ExampleModel(parser, {
value: sample, value: sample,
}; });
} }
} else if (this.schema) { } else if (this.schema) {
this.examples = { this.examples = {
default: new ExampleModel(parser, { default: new ExampleModel(parser, {
value: Sampler.sample( value: Sampler.sample(info.schema, samplerOptions, parser.spec),
info.schema,
samplerOptions,
parser.spec,
),
}), }),
}; };
} }