Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): make API component resolve inline and remote refs #575

Merged
merged 13 commits into from
Sep 29, 2020
3 changes: 1 addition & 2 deletions packages/elements/src/__stories__/components/Api.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { text, withKnobs } from '@storybook/addon-knobs';
import { boolean } from '@storybook/addon-knobs/react';
import { boolean, text, withKnobs } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import cn from 'classnames';
import * as React from 'react';
Expand Down
31 changes: 18 additions & 13 deletions packages/elements/src/containers/API.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { SidebarLayout } from '../components/API/SidebarLayout';
import { StackedLayout } from '../components/API/StackedLayout';
import { DocsSkeleton } from '../components/Docs/Skeleton';
import { withRouter } from '../hoc/withRouter';
import { useBundleRefsIntoDocument } from '../hooks/useBundleRefsIntoDocument';
import { useParsedValue } from '../hooks/useParsedValue';
import { withStyles } from '../styled';
import { LinkComponentType, RoutingProps } from '../types';
import { computeNodeData, isOas2, isOas3, IUriMap } from '../utils/oas';
import { computeOas2UriMap } from '../utils/oas/oas2';
import { computeOas3UriMap } from '../utils/oas/oas3';
import { InlineRefResolverProvider } from './Provider';

const fetcher = (url: string) => axios.get(url).then(res => res.data);

Expand All @@ -36,20 +38,21 @@ const APIImpl = withRouter<APIProps>(function API({ apiDescriptionUrl, linkCompo
}, [error]);

const document = useParsedValue(data);
const bundledDocument = useBundleRefsIntoDocument(document, { baseUrl: apiDescriptionUrl });

const uriMap = React.useMemo(() => {
let map: IUriMap = {};
if (document) {
if (isOas3(document)) {
map = computeOas3UriMap(document);
} else if (isOas2(document)) {
map = computeOas2UriMap(document);
if (bundledDocument) {
if (isOas3(bundledDocument)) {
map = computeOas3UriMap(bundledDocument);
} else if (isOas2(bundledDocument)) {
map = computeOas2UriMap(bundledDocument);
} else {
console.warn('Document type is unknown');
}
}
return map;
}, [document]);
}, [bundledDocument]);

const nodes = computeNodeData(uriMap);
const tree = generateToC(nodes);
Expand All @@ -72,13 +75,15 @@ const APIImpl = withRouter<APIProps>(function API({ apiDescriptionUrl, linkCompo
}

return (
<div className="APIComponent flex flex-row">
{layout === 'stacked' ? (
<StackedLayout uriMap={uriMap} tree={tree} />
) : (
<SidebarLayout pathname={pathname} uriMap={uriMap} tree={tree} linkComponent={linkComponent} />
)}
</div>
<InlineRefResolverProvider document={document}>
<div className="APIComponent flex flex-row">
{layout === 'stacked' ? (
<StackedLayout uriMap={uriMap} tree={tree} />
) : (
<SidebarLayout pathname={pathname} tree={tree} uriMap={uriMap} linkComponent={linkComponent} />
)}
</div>
</InlineRefResolverProvider>
);
});

Expand Down
14 changes: 3 additions & 11 deletions packages/elements/src/containers/Docs.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { pointerToPath } from '@stoplight/json';
import { SchemaTreeRefDereferenceFn } from '@stoplight/json-schema-viewer';
import { NodeType } from '@stoplight/types';
import { FAIcon, NonIdealState } from '@stoplight/ui-kit';
import { get, isObject } from 'lodash';
import * as React from 'react';
import { useQuery } from 'urql';

import { DocsSkeleton, ParsedDocs } from '../components/Docs';
import { bundledBranchNode } from '../graphql/BranchNodeBySlug';
import { useParsedData } from '../hooks/useParsedData';
import { ActiveInfoContext, InlineRefResolverContext, IProvider, Provider } from './Provider';
import { ActiveInfoContext, InlineRefResolverProvider, IProvider, Provider } from './Provider';

export interface IDocsProps {
className?: string;
Expand All @@ -23,15 +20,10 @@ interface IDocsProvider extends IProvider {
const DocsPopup = React.memo<{ nodeType: NodeType; nodeData: unknown; className?: string }>(
({ nodeType, nodeData, className }) => {
const document = useParsedData(nodeType, nodeData);
const inlineRefResolver = React.useCallback<SchemaTreeRefDereferenceFn>(
({ pointer }, _, schema) =>
pointer === null ? null : get(isObject(document) ? document : schema, pointerToPath(pointer)),
[document],
);
return (
<InlineRefResolverContext.Provider value={inlineRefResolver}>
<InlineRefResolverProvider document={document}>
<ParsedDocs className={className} nodeType={nodeType} nodeData={document} />
</InlineRefResolverContext.Provider>
</InlineRefResolverProvider>
);
},
);
Expand Down
18 changes: 18 additions & 0 deletions packages/elements/src/containers/Provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { pointerToPath } from '@stoplight/json';
import { SchemaTreeRefDereferenceFn } from '@stoplight/json-schema-viewer';
import { IComponentMapping } from '@stoplight/markdown-viewer';
import { get, isObject } from 'lodash';
import * as React from 'react';
import { Client, Provider as UrqlProvider } from 'urql';

Expand All @@ -23,6 +25,22 @@ export const ComponentsContext = createNamedContext<IComponentMapping | undefine

export const InlineRefResolverContext = React.createContext<SchemaTreeRefDereferenceFn | undefined>(void 0);

interface InlineRefResolverProviderTypes {
document: unknown;
}

/**
* Populates `InlineRefResolverContext` with a standard inline ref resolver based on `document`.
*/
export const InlineRefResolverProvider: React.FC<InlineRefResolverProviderTypes> = ({ document, children }) => {
const inlineRefResolver = React.useCallback<SchemaTreeRefDereferenceFn>(
({ pointer }, _, schema) =>
pointer === null ? null : get(isObject(document) ? document : schema, pointerToPath(pointer)),
[document],
);
return <InlineRefResolverContext.Provider value={inlineRefResolver}>{children}</InlineRefResolverContext.Provider>;
};

const defaultIcons: NodeIconMapping = {};
export const IconsContext = createNamedContext<NodeIconMapping>('IconsContext', defaultIcons);

Expand Down
53 changes: 53 additions & 0 deletions packages/elements/src/hooks/useBundleRefsIntoDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import $RefParser from '@stoplight/json-schema-ref-parser';
import { isObject } from 'lodash';
import * as React from 'react';

/**
* @param type branch node snapshot type
* @param data branch node snapshot data
*/

interface Options {
baseUrl?: string;
}

/**
* Fetches and bundles external $refs into an OAS document. Internal $refs are not resolved.
*/
export function useBundleRefsIntoDocument(document: unknown, options?: Options) {
const [bundledData, setBundledData] = React.useState(document);

React.useEffect(() => {
if (!isObject(document)) {
setBundledData(document);
return;
}

let isActive = true;
doBundle(document, options?.baseUrl)
.then(res => {
if (isActive) {
setBundledData({ ...res }); // this hmm....library mutates document so a shallow copy is required to force a rerender in all cases
}
})
.catch(reason => {
console.error(`Could not bundle: ${reason.message}`);
setBundledData(document);
});

return () => {
isActive = false;
};
}, [document, options?.baseUrl]);

return bundledData;
}

const commonBundleOptions = { continueOnError: true };
const doBundle = (data: object, baseUrl?: string) => {
if (!baseUrl) {
return $RefParser.bundle(data, commonBundleOptions);
} else {
return $RefParser.bundle(baseUrl, data, commonBundleOptions);
}
};