diff --git a/demo/package.json b/demo/package.json index fc7d8bcbd..5e226ca0a 100644 --- a/demo/package.json +++ b/demo/package.json @@ -49,7 +49,7 @@ "sass-loader": "^10.1.1", "style-loader": "^2.0.0", "ts-loader": "^8.1.0", - "typescript": "^4.2.3", + "typescript": "^4.3.2", "webpack": "^4.46.0", "webpack-cli": "^4.6.0", "webpack-dev-server": "^3.11.2", diff --git a/package.json b/package.json index 412bab793..1be727b5f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "start-server-and-test": "^1.11.6", "ts-jest": "^26.4.4", "tsconfig-paths-webpack-plugin": "^3.2.0", - "typescript": "4.2.2" + "typescript": "4.3.2" }, "scripts": { "demo": "yarn workspace @stoplight/elements-demo", diff --git a/packages/elements-core/src/components/Docs/HttpOperation/Body.tsx b/packages/elements-core/src/components/Docs/HttpOperation/Body.tsx index 75973f590..cc94245bb 100644 --- a/packages/elements-core/src/components/Docs/HttpOperation/Body.tsx +++ b/packages/elements-core/src/components/Docs/HttpOperation/Body.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { useInlineRefResolver } from '../../../context/InlineRefResolver'; import { isJSONSchema } from '../../../utils/guards'; +import { getOriginalObject } from '../../../utils/ref-resolving/resolvedObject'; import { MarkdownViewer } from '../../MarkdownViewer'; import { SubSectionPanel } from '../Sections'; @@ -46,7 +47,7 @@ export const Body = ({ body: { contents = [], description }, onChange }: BodyPro {isJSONSchema(schema) && ( - + )} diff --git a/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx b/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx index 52c4240d1..5837534fa 100644 --- a/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx +++ b/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx @@ -6,6 +6,7 @@ import { flatten, sortBy } from 'lodash'; import * as React from 'react'; import { MockingContext } from '../../../containers/MockingProvider'; +import { useResolvedObject } from '../../../context/InlineRefResolver'; import { getServiceUriFromOperation } from '../../../utils/oas/security'; import { MarkdownViewer } from '../../MarkdownViewer'; import { TryItWithRequestSamples } from '../../TryIt'; @@ -17,7 +18,9 @@ import { Responses } from './Responses'; export type HttpOperationProps = DocsComponentProps; const HttpOperationComponent = React.memo( - ({ className, data, headless, uri, hideTryIt, hideTryItPanel, allowRouting = false }) => { + ({ className, data: unresolvedData, headless, uri, hideTryIt, hideTryItPanel, allowRouting = false }) => { + const data = useResolvedObject(unresolvedData) as IHttpOperation; + const mocking = React.useContext(MockingContext); const isDeprecated = !!data.deprecated; const isInternal = !!data.internal; diff --git a/packages/elements-core/src/components/Docs/HttpOperation/Parameters.tsx b/packages/elements-core/src/components/Docs/HttpOperation/Parameters.tsx index 368fcf8f3..43f2563e5 100644 --- a/packages/elements-core/src/components/Docs/HttpOperation/Parameters.tsx +++ b/packages/elements-core/src/components/Docs/HttpOperation/Parameters.tsx @@ -5,8 +5,6 @@ import { Dictionary, HttpParamStyles, IHttpParam } from '@stoplight/types'; import { get, isEmpty, omit, omitBy, sortBy } from 'lodash'; import * as React from 'react'; -import { useInlineRefResolver } from '../../../context/InlineRefResolver'; - type ParameterType = 'query' | 'header' | 'path' | 'cookie'; interface ParametersProps { @@ -32,24 +30,12 @@ const defaultStyle = { } as const; export const Parameters: React.FunctionComponent = ({ parameters, parameterType }) => { - const resolveRef = useInlineRefResolver(); if (!parameters || !parameters.length) return null; return ( }> {sortBy(parameters, ['required', 'name']).map(parameter => { - const resolvedSchema = - parameter.schema?.$ref && resolveRef - ? resolveRef({ pointer: parameter.schema.$ref, source: null }, null, {}) - : null; - - return ( - - ); + return ; })} ); diff --git a/packages/elements-core/src/components/Docs/HttpOperation/Responses.tsx b/packages/elements-core/src/components/Docs/HttpOperation/Responses.tsx index 40f7b9e8f..27a675a7e 100644 --- a/packages/elements-core/src/components/Docs/HttpOperation/Responses.tsx +++ b/packages/elements-core/src/components/Docs/HttpOperation/Responses.tsx @@ -5,6 +5,7 @@ import { sortBy, uniqBy } from 'lodash'; import * as React from 'react'; import { useInlineRefResolver } from '../../../context/InlineRefResolver'; +import { getOriginalObject } from '../../../utils/ref-resolving/resolvedObject'; import { MarkdownViewer } from '../../MarkdownViewer'; import { SectionTitle, SubSectionPanel } from '../Sections'; import { Parameters } from './Parameters'; @@ -97,7 +98,12 @@ const Response = ({ response: { contents = [], headers = [], description }, onMe > {schema && ( - + )} diff --git a/packages/elements-core/src/components/Docs/Model/Model.tsx b/packages/elements-core/src/components/Docs/Model/Model.tsx index d2fe61960..e1863175c 100644 --- a/packages/elements-core/src/components/Docs/Model/Model.tsx +++ b/packages/elements-core/src/components/Docs/Model/Model.tsx @@ -6,16 +6,19 @@ import cn from 'classnames'; import { JSONSchema7 } from 'json-schema'; import * as React from 'react'; -import { useInlineRefResolver } from '../../../context/InlineRefResolver'; +import { useInlineRefResolver, useResolvedObject } from '../../../context/InlineRefResolver'; import { generateExampleFromJsonSchema } from '../../../utils/exampleGeneration'; +import { getOriginalObject } from '../../../utils/ref-resolving/resolvedObject'; import { MarkdownViewer } from '../../MarkdownViewer'; import { DocsComponentProps } from '..'; import { InternalBadge } from '../HttpOperation/Badges'; export type ModelProps = DocsComponentProps; -const ModelComponent: React.FC = ({ data, className, headless, nodeTitle }) => { +const ModelComponent: React.FC = ({ data: unresolvedData, className, headless, nodeTitle }) => { const resolveRef = useInlineRefResolver(); + const data = useResolvedObject(unresolvedData) as JSONSchema7; + const title = data.title ?? nodeTitle; const isInternal = !!data['x-internal']; @@ -39,7 +42,7 @@ const ModelComponent: React.FC = ({ data, className, headless, nodeT - + diff --git a/packages/elements-core/src/components/MarkdownViewer/CustomComponents/CodeComponent.tsx b/packages/elements-core/src/components/MarkdownViewer/CustomComponents/CodeComponent.tsx index af586e9bb..850ae04e4 100644 --- a/packages/elements-core/src/components/MarkdownViewer/CustomComponents/CodeComponent.tsx +++ b/packages/elements-core/src/components/MarkdownViewer/CustomComponents/CodeComponent.tsx @@ -13,6 +13,7 @@ import { useInlineRefResolver } from '../../../context/InlineRefResolver'; import { useParsedValue } from '../../../hooks/useParsedValue'; import { JSONSchema } from '../../../types'; import { isHttpOperation, isJSONSchema } from '../../../utils/guards'; +import { getOriginalObject } from '../../../utils/ref-resolving/resolvedObject'; import { TryIt } from '../../TryIt'; type PartialHttpRequest = Pick & Partial; @@ -46,7 +47,7 @@ const SchemaAndDescription = ({ title: titleProp, schema }: ISchemaAndDescriptio )} - + ); }; diff --git a/packages/elements-core/src/context/InlineRefResolver.tsx b/packages/elements-core/src/context/InlineRefResolver.tsx index fa690b805..bc754c03f 100644 --- a/packages/elements-core/src/context/InlineRefResolver.tsx +++ b/packages/elements-core/src/context/InlineRefResolver.tsx @@ -1,33 +1,22 @@ -import { resolveInlineRef } from '@stoplight/json'; -import { JSONSchema7 } from 'json-schema'; import { isPlainObject } from 'lodash'; import * as React from 'react'; import { useContext } from 'react'; -import { JSONSchema } from '../types'; +import { defaultResolver, ReferenceResolver } from '../utils/ref-resolving/ReferenceResolver'; +import { createResolvedObject } from '../utils/ref-resolving/resolvedObject'; -export type SchemaTreeRefInfo = { - source: string | null; - pointer: string | null; -}; - -export type SchemaTreeRefDereferenceFn = ( - ref: SchemaTreeRefInfo, - propertyPath: string[] | null, - schema: JSONSchema, -) => JSONSchema7; - -const InlineRefResolverContext = React.createContext(undefined); +const InlineRefResolverContext = React.createContext(undefined); InlineRefResolverContext.displayName = 'InlineRefResolverContext'; -export const DocumentContext = React.createContext(undefined); +const DocumentContext = React.createContext(undefined); +DocumentContext.displayName = 'DocumentContext'; type InlineRefResolverProviderProps = | { document: unknown; } | { - resolver: SchemaTreeRefDereferenceFn; + resolver: ReferenceResolver; }; /** @@ -36,30 +25,8 @@ type InlineRefResolverProviderProps = export const InlineRefResolverProvider: React.FC = ({ children, ...props }) => { const document = 'document' in props && isPlainObject(props.document) ? Object(props.document) : undefined; - const documentBasedRefResolver = React.useCallback( - ({ pointer }, _, schema) => { - const activeSchema = document ?? schema; - - if (pointer === null) { - return null; - } - - if (pointer === '#') { - return activeSchema; - } - - const resolved = resolveInlineRef(activeSchema, pointer); - if (isPlainObject(resolved)) { - return resolved; - } - - throw new ReferenceError(`Could not resolve '${pointer}`); - }, - [document], - ); - return ( - + {children} ); @@ -68,3 +35,10 @@ export const InlineRefResolverProvider: React.FC export const useInlineRefResolver = () => useContext(InlineRefResolverContext); export const useDocument = () => useContext(DocumentContext); + +export const useResolvedObject = (currentObject: object): object => { + const document = useDocument(); + const resolver = useInlineRefResolver(); + + return createResolvedObject(currentObject, { contextObject: document as object, resolver }); +}; diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index c79f864c0..972c272aa 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -17,7 +17,7 @@ export { NodeTypePrettyName, } from './constants'; export { MockingProvider } from './containers/MockingProvider'; -export { InlineRefResolverProvider, SchemaTreeRefDereferenceFn } from './context/InlineRefResolver'; +export { InlineRefResolverProvider } from './context/InlineRefResolver'; export { PersistenceContextProvider, withPersistenceBoundary } from './context/Persistence'; export { withMosaicProvider } from './hoc/withMosaicProvider'; export { withQueryClientProvider } from './hoc/withQueryClientProvider'; @@ -29,3 +29,5 @@ export { useRouter } from './hooks/useRouter'; export { useTocContents } from './hooks/useTocContents'; export { Styled, withStyles } from './styled'; export { Divider, Group, ITableOfContentsTree, Item, RoutingProps, TableOfContentItem } from './types'; +export { ReferenceResolver } from './utils/ref-resolving/ReferenceResolver'; +export { createResolvedObject } from './utils/ref-resolving/resolvedObject'; diff --git a/packages/elements-core/src/utils/ref-resolving/ReferenceResolver.ts b/packages/elements-core/src/utils/ref-resolving/ReferenceResolver.ts new file mode 100644 index 000000000..e4c67666e --- /dev/null +++ b/packages/elements-core/src/utils/ref-resolving/ReferenceResolver.ts @@ -0,0 +1,29 @@ +import { resolveInlineRef } from '@stoplight/json'; +import { Dictionary } from '@stoplight/types'; + +export type ReferenceInfo = { + source: string | null; + pointer: string | null; +}; + +export type ReferenceResolver = (ref: ReferenceInfo, propertyPath: string[] | null, currentObject: object) => any; + +export const defaultResolver = + (contextObject: object): ReferenceResolver => + ({ pointer }, _, currentObject) => { + const activeObject = contextObject ?? currentObject; + + if (pointer === null) { + return null; + } + + if (pointer === '#') { + return activeObject; + } + + const resolved = resolveInlineRef(activeObject as Dictionary, pointer); + + if (resolved) return resolved; + + throw new ReferenceError(`Could not resolve '${pointer}`); + }; diff --git a/packages/elements-core/src/utils/ref-resolving/resolvedObject.test.ts b/packages/elements-core/src/utils/ref-resolving/resolvedObject.test.ts new file mode 100644 index 000000000..ec73410ef --- /dev/null +++ b/packages/elements-core/src/utils/ref-resolving/resolvedObject.test.ts @@ -0,0 +1,234 @@ +import { createResolvedObject, getOriginalObject } from './resolvedObject'; + +describe('createResolvedObject', () => { + it('resolves a reference', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + $ref: '#' as const, + }, + bundled: { + parameterB: 'parameterB value', + }, + }); + + expect(resolvedObject.paramaterA.paramaterA.paramaterA.paramaterA).toBe('parameterB value'); + }); + + it('resolves a reference to an object', () => { + const resolvedObject = createResolvedObject({ + parameterA: { + $ref: `#/bundled/parameterB`, + }, + asd: { + x: 3, + }, + bundled: { + parameterB: { + something: 'something else', + }, + }, + } as const); + + expect(resolvedObject.parameterA.something).toEqual({ + something: 'something else', + }); + }); + + it('resolves a circular reference', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + $ref: `#/bundled/paramaterA`, + }, + bundled: { + paramaterA: { + parameterB: { + $ref: `#/bundled/parameterB`, + }, + parameterC: 'parameterC value', + }, + parameterB: { + paramaterA: { + $ref: `#/bundled/paramaterA`, + }, + }, + }, + } as const); + + expect(resolvedObject.paramaterA.parameterB.paramaterA.parameterB.paramaterA.parameterC).toBe('parameterC value'); + }); + + it('resolves circular reference to the same value', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + $ref: `#/bundled/paramaterA`, + }, + bundled: { + paramaterA: { + parameterB: { + $ref: `#/bundled/parameterB`, + }, + parameterC: 'parameterC value', + }, + parameterB: { + paramaterA: { + $ref: `#/bundled/paramaterA`, + }, + }, + }, + } as const); + + expect(resolvedObject.paramaterA.parameterB.paramaterA.parameterB.paramaterA).toBe(resolvedObject.paramaterA); + }); + + it('resolves deeply nested reference', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + bundled: { + parameterB: { + parameterC: { + $ref: '#/bundled/parameterC', + }, + }, + parameterC: 'parameterC value', + }, + }); + + expect((resolvedObject as any).paramaterA.parameterB.parameterC).toBe('parameterC value'); + }); + + it('resolves references nested in arrays', () => { + const resolvedObject = createResolvedObject({ + list: [ + { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + ], + bundled: { + parameterB: { + parameterC: { + $ref: '#/bundled/parameterC', + }, + }, + parameterC: 'parameterC value', + }, + }); + + expect((resolvedObject as any).list[0].parameterB.parameterC).toBe('parameterC value'); + }); + + it('returns the same object when accessing the parameter multiple times', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + bundled: { + parameterB: 'parameterB value', + }, + }); + + expect((resolvedObject as any).paramaterA).toBe((resolvedObject as any).paramaterA); + }); + + it('returns the same object when called twice', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + bundled: { + parameterB: 'parameterB value', + }, + }); + + expect(createResolvedObject(resolvedObject)).toBe(resolvedObject); + }); + + it('allows to retrieve the original object', () => { + const originalObject = { + paramaterA: { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + bundled: { + parameterB: 'parameterB value', + }, + }; + const resolvedObject = createResolvedObject(originalObject); + + expect(getOriginalObject((resolvedObject as any).paramaterA)).toBe(originalObject.paramaterA); + }); + + it('allows to customize resolution process', () => { + const originalObject = { + paramaterA: { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + bundled: { + parameterB: 'parameterB value', + }, + }; + + const resolvedObject = createResolvedObject(originalObject, { + resolver: ({ pointer }, propertyPath, originalObject) => ({ + pointer, + propertyPath, + originalObjectProperties: Object.keys(originalObject), + }), + }); + + expect((resolvedObject as any).paramaterA.parameterB).toEqual({ + pointer: '#/bundled/parameterB', + propertyPath: ['paramaterA', 'parameterB'], + originalObjectProperties: ['paramaterA', 'bundled'], + }); + }); + + it('provides error message when resolving fails', () => { + const resolvedObject = createResolvedObject({ + paramaterA: { + parameterB: { + $ref: '#/bundled/parameterB', + }, + }, + }); + + expect((resolvedObject as any).paramaterA.parameterB).toEqual({ + $ref: '#/bundled/parameterB', + $error: "Could not resolve '#/bundled/parameterB'", + }); + }); + + it('does not pass non-string references to resolver and ignores them', () => { + const originalObject = { + paramaterA: { + parameterB: { + $ref: { + not: 'a reference string', + }, + }, + }, + }; + + const resolvedObject = createResolvedObject(originalObject, { + resolver: ({ pointer }) => { + if (typeof pointer !== 'string') { + throw new Error('Pointer should be a string!!!'); + } + }, + }); + + expect(resolvedObject).toEqual(originalObject); + }); +}); diff --git a/packages/elements-core/src/utils/ref-resolving/resolvedObject.ts b/packages/elements-core/src/utils/ref-resolving/resolvedObject.ts new file mode 100644 index 000000000..3108be4eb --- /dev/null +++ b/packages/elements-core/src/utils/ref-resolving/resolvedObject.ts @@ -0,0 +1,128 @@ +import { isArray, isPlainObject } from 'lodash'; + +import { defaultResolver, ReferenceResolver } from './ReferenceResolver'; + +const originalObjectSymbol = Symbol('OriginalObject'); + +interface CreateResolvedObjectOptions { + contextObject?: object; + resolver?: ReferenceResolver; +} + +type ObjectWithRef = { $ref: string }; + +type RefToRoot = { $ref: '#' }; + +type ErrorObject = { + $ref: string; + $error: string; +}; + +type TryResolveRef1 = TContext extends { [key in Key]: infer SubProp } + ? SubProp + : ErrorObject; +type TryResolveRef2 = TContext extends { + [key in Key1]: { [key in Key2]: infer SubProp }; +} + ? ResolvedObject + : ErrorObject; +type TryResolveRef3 = + TContext extends { + [key in Key1]: { [key in Key2]: { [key in Key3]: infer SubProp } }; + } + ? ResolvedObject + : ErrorObject; + +type ResolvedProperty = TOriginal extends RefToRoot + ? ResolvedObject + : TOriginal extends { $ref: `#/${infer Key1}/${infer Key2}/${infer Key3}` } + ? TryResolveRef3 + : TOriginal extends { $ref: `#/${infer Key1}/${infer Key2}` } + ? TryResolveRef2 + : TOriginal extends { $ref: `#/${infer Key}` } + ? TryResolveRef1 + : TOriginal; + +type ResolvedObject = TOriginal extends ObjectWithRef + ? ErrorObject + : { + [key in keyof TOriginal]: ResolvedProperty; + }; + +export const createResolvedObject = ( + currentObject: T, + options: CreateResolvedObjectOptions = {}, +): ResolvedObject => recursivelyCreateResolvedObject(currentObject, currentObject, [], new Map(), options) as any; + +const recursivelyCreateResolvedObject = ( + currentObject: object, + rootCurrentObject: object, + propertyPath: string[], + objectToProxiedObjectCache: Map, + options: CreateResolvedObjectOptions = {}, +): object => { + const mergedOptions = { + contextObject: options.contextObject || currentObject, + resolver: options.resolver || defaultResolver(options.contextObject || currentObject), + }; + if (!currentObject || isResolvedObjectProxy(currentObject)) return currentObject; + + if (objectToProxiedObjectCache.has(currentObject)) return objectToProxiedObjectCache.get(currentObject)!; + + const resolvedObjectProxy = new Proxy(currentObject, { + get(target, name) { + if (name === originalObjectSymbol) return currentObject; + + const value = target[name]; + const newPropertyPath = [...propertyPath, name.toString()]; + + let resolvedValue; + if (isReference(value)) { + try { + resolvedValue = mergedOptions.resolver( + { pointer: value['$ref'], source: null }, + newPropertyPath, + rootCurrentObject, + ); + } catch (e) { + resolvedValue = { + ...value, + $error: e.message, + }; + } + } else { + resolvedValue = value; + } + + if (isPlainObject(resolvedValue) || isArray(resolvedValue)) { + return recursivelyCreateResolvedObject( + resolvedValue, + rootCurrentObject, + newPropertyPath, + objectToProxiedObjectCache, + mergedOptions, + ); + } + + return resolvedValue; + }, + }); + + objectToProxiedObjectCache.set(currentObject, resolvedObjectProxy); + + return resolvedObjectProxy; +}; + +export const isResolvedObjectProxy = (someObject: object): boolean => { + return !!someObject[originalObjectSymbol]; +}; + +export const getOriginalObject = (resolvedObject: object): object => { + return resolvedObject[originalObjectSymbol] || resolvedObject; +}; + +const isReference = (value: unknown): boolean => { + return ( + isPlainObject(value) && (value as object).hasOwnProperty('$ref') && typeof (value as object)['$ref'] === 'string' + ); +}; diff --git a/yarn.lock b/yarn.lock index 2a574320e..905068897 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22222,12 +22222,7 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.2.tgz#1450f020618f872db0ea17317d16d8da8ddb8c4c" - integrity sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ== - -typescript@^4.2.3: +typescript@4.3.2, typescript@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805" integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==