diff --git a/package.json b/package.json index e3371b127..7c8225a73 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "test": "jest --silent" }, "dependencies": { - "@stoplight/json": "^3.3.0", + "@stoplight/json": "^3.4.0", "@stoplight/json-ref-readers": "^1.1.1", "@stoplight/json-ref-resolver": "^3.0.1", "@stoplight/path": "^1.3.0", diff --git a/rollup.config.js b/rollup.config.js index 722385f2e..d74dac46b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -28,7 +28,12 @@ module.exports = functions.map(fn => ({ include: ['dist/**/*.{ts,tsx}'], }), resolve(), - commonjs(), + commonjs({ + namedExports: { + 'node_modules/lodash/lodash.js': ['isObject', 'trimStart'], + 'node_modules/@stoplight/types/dist/index.js': ['DiagnosticSeverity'], + }, + }), terser(), ], output: { diff --git a/src/__tests__/__fixtures__/gh-658/URIError.yaml b/src/__tests__/__fixtures__/gh-658/URIError.yaml new file mode 100644 index 000000000..4d545fa39 --- /dev/null +++ b/src/__tests__/__fixtures__/gh-658/URIError.yaml @@ -0,0 +1,44 @@ +%YAML 1.2 +--- +openapi: 3.0.0 + +info: + title: Hey Mum! I'm on GitHub! + description: Resource definition. + version: 1.0.0 + +servers: + - url: https://boom.com + +paths: + "/test": + get: + summary: Dummy endpoint. + description: Cf. summary + responses: + "200": + description: All is good. + content: + application/json: + schema: + type: string + "400": + description: Bad Request. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: No Bueno. + content: + application/json: + schema: + $ref: "./lib.yaml#/components/schemas/Error" +components: + schemas: + Error: + $ref: "#/components/schemas/Baz" + Baz: + $ref: ./lib.yaml#/components/schemas/Error + Foo: + type: string diff --git a/src/__tests__/__fixtures__/gh-658/lib.yaml b/src/__tests__/__fixtures__/gh-658/lib.yaml new file mode 100644 index 000000000..be265ae0c --- /dev/null +++ b/src/__tests__/__fixtures__/gh-658/lib.yaml @@ -0,0 +1,24 @@ +%YAML 1.2 +--- +openapi: 3.0.0 + +info: + title: Library + description: Collection of reusable standard definitions. + version: 2.0.0 +paths: {} +components: + schemas: + Error: + type: object + properties: + error: + type: string + maxLength: 1 + error_description: + type: string + maxLength: 1 + status_code: + type: string + test: + $ref: ./URIError.yaml#/components/schemas/Foo diff --git a/src/__tests__/spectral.jest.test.ts b/src/__tests__/spectral.jest.test.ts index 8ed9a12ac..c37d922d3 100644 --- a/src/__tests__/spectral.jest.test.ts +++ b/src/__tests__/spectral.jest.test.ts @@ -97,7 +97,7 @@ describe('Spectral', () => { ); }); - test('reports issues for correct files with correct ranges and paths', async () => { + test('should report issues for correct files with correct ranges and paths', async () => { const documentUri = path.join(__dirname, './__fixtures__/document-with-external-refs.oas2.json'); const spectral = new Spectral({ resolver: httpAndFileResolver }); await spectral.loadRuleset('spectral:oas'); @@ -105,6 +105,7 @@ describe('Spectral', () => { const parsed = { parsed: parseWithPointers(fs.readFileSync(documentUri, 'utf8')), getLocationForJsonPath, + source: documentUri, }; const results = await spectral.run(parsed, { @@ -128,7 +129,7 @@ describe('Spectral', () => { line: 16, }, }, - source: undefined, + source: documentUri, }), expect.objectContaining({ code: 'oas2-schema', @@ -158,7 +159,7 @@ describe('Spectral', () => { line: 10, }, }, - source: undefined, + source: documentUri, }), expect.objectContaining({ code: 'info-contact', @@ -173,7 +174,7 @@ describe('Spectral', () => { line: 2, }, }, - source: undefined, + source: documentUri, }), expect.objectContaining({ code: 'operation-description', @@ -188,9 +189,221 @@ describe('Spectral', () => { line: 11, }, }, - source: undefined, + source: documentUri, }), ]), ); }); + + test('should recognize the source of remote $refs', async () => { + const s = new Spectral({ resolver: httpAndFileResolver }); + const documentUri = path.join(__dirname, './__fixtures__/gh-658/URIError.yaml'); + + s.setRules({ + 'schema-strings-maxLength': { + severity: DiagnosticSeverity.Warning, + recommended: true, + message: "String typed properties MUST be further described using 'maxLength'. Error: {{error}}", + given: "$..[?(@.type === 'string')]", + then: { + field: 'maxLength', + function: 'truthy', + }, + }, + }); + + const results = await s.run(fs.readFileSync(documentUri, 'utf8'), { resolve: { documentUri } }); + + return expect(results).toEqual([ + expect.objectContaining({ + path: ['paths', '/test', 'get', 'responses', '200', 'content', 'application/json', 'schema'], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'), + range: { + end: { + character: 28, + line: 23, + }, + start: { + character: 21, + line: 22, + }, + }, + }), + + expect.objectContaining({ + path: [ + 'paths', + '/test', + 'get', + 'responses', + '400', + 'content', + 'application/json', + 'schema', + 'properties', + 'status_code', + ], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'), + range: { + end: { + character: 22, + line: 21, + }, + start: { + character: 20, + line: 20, + }, + }, + }), + expect.objectContaining({ + path: [ + 'paths', + '/test', + 'get', + 'responses', + '400', + 'content', + 'application/json', + 'schema', + 'properties', + 'test', + ], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'), + range: { + end: { + character: 18, + line: 43, + }, + start: { + character: 8, + line: 42, + }, + }, + }), + + expect.objectContaining({ + path: [ + 'paths', + '/test', + 'get', + 'responses', + '500', + 'content', + 'application/json', + 'schema', + 'properties', + 'status_code', + ], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'), + range: { + end: { + character: 22, + line: 21, + }, + start: { + character: 20, + line: 20, + }, + }, + }), + expect.objectContaining({ + path: [ + 'paths', + '/test', + 'get', + 'responses', + '500', + 'content', + 'application/json', + 'schema', + 'properties', + 'test', + ], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'), + range: { + end: { + character: 18, + line: 43, + }, + start: { + character: 8, + line: 42, + }, + }, + }), + + expect.objectContaining({ + path: ['components', 'schemas', 'Foo'], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'), + range: { + end: { + character: 18, + line: 43, + }, + start: { + character: 8, + line: 42, + }, + }, + }), + + expect.objectContaining({ + path: ['components', 'schemas', 'Error', 'properties', 'status_code'], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'), + range: { + end: { + character: 22, + line: 21, + }, + start: { + character: 20, + line: 20, + }, + }, + }), + expect.objectContaining({ + path: ['components', 'schemas', 'Error', 'properties', 'test'], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'), + range: { + end: { + character: 18, + line: 43, + }, + start: { + character: 8, + line: 42, + }, + }, + }), + + expect.objectContaining({ + path: ['components', 'schemas', 'Baz', 'properties', 'status_code'], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'), + range: { + end: { + character: 22, + line: 21, + }, + start: { + character: 20, + line: 20, + }, + }, + }), + expect.objectContaining({ + path: ['components', 'schemas', 'Baz', 'properties', 'test'], + source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'), + range: { + end: { + character: 18, + line: 43, + }, + start: { + character: 8, + line: 42, + }, + }, + }), + ]); + }); }); diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts index ba09b745c..9c512f7a4 100644 --- a/src/__tests__/spectral.test.ts +++ b/src/__tests__/spectral.test.ts @@ -250,7 +250,7 @@ describe('spectral', () => { }, }); - return expect(s.run(parsedResult)).resolves.toEqual([ + return expect(s.run(parsedResult, { resolve: { documentUri: source } })).resolves.toEqual([ { code: 'pagination-responses-have-x-next-token', message: 'All collection endpoints have the X-Next-Token parameter in responses', diff --git a/src/cli/services/__tests__/linter.test.ts b/src/cli/services/__tests__/linter.test.ts index 7a5cfbcdf..ece847976 100644 --- a/src/cli/services/__tests__/linter.test.ts +++ b/src/cli/services/__tests__/linter.test.ts @@ -55,6 +55,7 @@ describe('Linter service', () => { expect.arrayContaining([ expect.objectContaining({ code: 'oas3-schema', + path: ['info', 'contact', 'name'], range: { end: { character: 14, @@ -708,14 +709,26 @@ describe('Linter service', () => { describe('--resolver', () => { it('uses provided resolver for $ref resolving', async () => { - expect(await run(`lint --resolver ${fooResolver} ${fooDocument}`)).toEqual([ - expect.objectContaining({ - code: 'oas3-api-servers', - }), - expect.objectContaining({ - code: 'openapi-tags', - }), - ]); + expect(await run(`lint --resolver ${fooResolver} ${fooDocument}`)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'info-contact', + source: 'foo://openapi-3.0-no-contact.yaml', + }), + expect.objectContaining({ + code: 'info-description', + source: 'foo://openapi-3.0-no-contact.yaml', + }), + expect.objectContaining({ + code: 'oas3-api-servers', + source: 'foo://openapi-3.0-no-contact.yaml', + }), + expect.objectContaining({ + code: 'openapi-tags', + source: 'foo://openapi-3.0-no-contact.yaml', + }), + ]), + ); }); }); }); diff --git a/src/error-messages.ts b/src/error-messages.ts index 010087e74..39e559a98 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -53,7 +53,7 @@ export const formatResolverErrors = (resolved: Resolved): IRuleResult[] => { message: prettyPrintResolverErrorMessage(error.message), severity: DiagnosticSeverity.Error, range: location.range, - source: resolved.spec.source, + source: resolved.source, }); } diff --git a/src/functions/unreferencedReusableObject.ts b/src/functions/unreferencedReusableObject.ts index 013ed118c..35e213a14 100644 --- a/src/functions/unreferencedReusableObject.ts +++ b/src/functions/unreferencedReusableObject.ts @@ -1,5 +1,5 @@ import { IFunction } from '../types'; -import { isObject } from '../utils'; +import { isObject, safePointerToPath } from '../utils'; export const unreferencedReusableObject: IFunction<{ reusableObjectsLocation: string }> = ( data, @@ -15,7 +15,7 @@ export const unreferencedReusableObject: IFunction<{ reusableObjectsLocation: st ); } - const normalizedSource = otherValues.resolved.spec.source === undefined ? '' : otherValues.resolved.spec.source; + const normalizedSource = otherValues.resolved.source ?? ''; const defined = Object.keys(data).map(name => `${normalizedSource}${opts.reusableObjectsLocation}/${name}`); @@ -24,10 +24,7 @@ export const unreferencedReusableObject: IFunction<{ reusableObjectsLocation: st return orphans.map(orphanPath => { return { message: 'Potential orphaned reusable object has been detected.', - path: orphanPath - .split('#')[1] - .split('/') - .slice(1), + path: safePointerToPath(orphanPath), }; }); }; diff --git a/src/resolved.ts b/src/resolved.ts index fde29669c..8e1ac8207 100644 --- a/src/resolved.ts +++ b/src/resolved.ts @@ -1,11 +1,11 @@ -import { decodePointerFragment, pointerToPath } from '@stoplight/json'; +import { extractSourceFromRef, hasRef, isLocalRef, pointerToPath } from '@stoplight/json'; import { IGraphNodeData, IResolveError } from '@stoplight/json-ref-resolver/types'; -import { Dictionary, ILocation, IRange, JsonPath, Segment } from '@stoplight/types'; +import { normalize, resolve } from '@stoplight/path'; +import { Dictionary, ILocation, IRange, JsonPath } from '@stoplight/types'; import { DepGraph } from 'dependency-graph'; import { get } from 'lodash'; -import { IParseMap, REF_METADATA, ResolveResult } from './spectral'; -import { IParsedResult } from './types'; -import { hasRef, isObject } from './utils'; +import { IParsedResult, ResolveResult } from './types'; +import { getEndRef, isAbsoluteRef, safePointerToPath, traverseObjUntilRef } from './utils'; const getDefaultRange = (): IRange => ({ start: { @@ -26,9 +26,17 @@ export class Resolved { public readonly errors: IResolveError[]; public formats?: string[] | null; - constructor(public spec: IParsedResult, resolveResult: ResolveResult, public parsedMap: IParseMap) { - this.unresolved = spec.parsed.data; - this.formats = spec.formats; + public get source() { + return this.parsed.source ? normalize(this.parsed.source) : this.parsed.source; + } + + constructor( + protected parsed: IParsedResult, + resolveResult: ResolveResult, + public parsedRefs: Dictionary, + ) { + this.unresolved = parsed.parsed.data; + this.formats = parsed.formats; this.refMap = resolveResult.refMap; this.graph = resolveResult.graph; @@ -36,58 +44,55 @@ export class Resolved { this.errors = resolveResult.errors; } - public doesBelongToDoc(path: JsonPath): boolean { - if (path.length === 0) { - // todo: each rule and their function should be context-aware, meaning they should aware of the fact they operate on resolved content - // let's assume the error was reported correctly by any custom rule /shrug - return true; - } + public getParsedForJsonPath(path: JsonPath) { + try { + const newPath: JsonPath = [...path]; + let $ref = traverseObjUntilRef(this.unresolved, newPath); + + if ($ref === null) { + return { + path, + doc: this.parsed, + }; + } - let piece = this.unresolved; + let { source } = this; - for (let i = 0; i < path.length; i++) { - if (!isObject(piece)) return false; + while (true) { + if (source === void 0) return null; - if (path[i] in piece) { - piece = piece[path[i]]; - } else if (hasRef(piece)) { - return this.doesBelongToDoc([...pointerToPath(piece.$ref), ...path.slice(i)]); - } - } + $ref = getEndRef(this.graph.getNodeData(source).refMap, $ref); - return true; - } + if ($ref === null) return null; - public getParsedForJsonPath(path: JsonPath) { - let target: object = this.parsedMap.refs; - const newPath = [...path]; - let segment: Segment; - - while (newPath.length > 0) { - segment = newPath.shift()!; - if (segment && segment in target) { - target = target[segment]; - } else { - newPath.unshift(segment); - break; - } - } + if (isLocalRef($ref)) { + return { + path: pointerToPath($ref), + doc: source === this.parsed.source ? this.parsed : this.parsedRefs[source], + }; + } - if (target && target[REF_METADATA]) { - return { - path: [...get(target, [REF_METADATA, 'root'], []).map(decodePointerFragment), ...newPath], - doc: get(this.parsedMap.parsed, get(target, [REF_METADATA, 'ref']), this.spec), - }; - } + const extractedSource = extractSourceFromRef($ref)!; + source = isAbsoluteRef(extractedSource) ? extractedSource : resolve(source, '..', extractedSource); + + const doc = source === this.parsed.source ? this.parsed : this.parsedRefs[source]; + const { parsed } = doc; + const scopedPath = [...safePointerToPath($ref), ...newPath]; - if (!this.doesBelongToDoc(path)) { + const obj = scopedPath.length === 0 || hasRef(parsed.data) ? parsed.data : get(parsed.data, scopedPath); + + if (hasRef(obj)) { + $ref = obj.$ref; + } else { + return { + doc, + path: scopedPath, + }; + } + } + } catch { return null; } - - return { - path, - doc: this.spec, - }; } public getLocationForJsonPath(path: JsonPath, closest?: boolean): ILocation { @@ -102,7 +107,7 @@ export class Resolved { return { ...(parsedResult.doc.source && { uri: parsedResult.doc.source }), - range: location !== void 0 ? location.range : getDefaultRange(), + range: location?.range || getDefaultRange(), }; } diff --git a/src/rulesets/oas/functions/refSiblings.ts b/src/rulesets/oas/functions/refSiblings.ts index 1b7e991a1..4db731c11 100644 --- a/src/rulesets/oas/functions/refSiblings.ts +++ b/src/rulesets/oas/functions/refSiblings.ts @@ -1,6 +1,6 @@ +import { hasRef } from '@stoplight/json'; import { JsonPath } from '@stoplight/types'; import { IFunction, IFunctionResult } from '../../../types'; -import { hasRef } from '../../../utils/hasRef'; // function is needed because `$..$ref` or `$..[?(@.$ref)]` are not parsed correctly // and therefore lead to infinite recursion due to the dollar sign ('$' in '$ref') diff --git a/src/spectral.ts b/src/spectral.ts index c23696646..bab55ead8 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -1,10 +1,10 @@ import { getLocationForJsonPath as getLocationForJsonPathJson, JsonParserResult, safeStringify } from '@stoplight/json'; import { Resolver } from '@stoplight/json-ref-resolver'; import { ICache, IUriParser } from '@stoplight/json-ref-resolver/types'; -import { extname } from '@stoplight/path'; -import { Dictionary, IDiagnostic } from '@stoplight/types'; +import { extname, normalize } from '@stoplight/path'; +import { Dictionary, IDiagnostic, Optional } from '@stoplight/types'; import { getLocationForJsonPath as getLocationForJsonPathYaml, YamlParserResult } from '@stoplight/yaml'; -import { merge, set } from 'lodash'; +import { merge } from 'lodash'; import { STATIC_ASSETS } from './assets'; import { formatParserDiagnostics, formatResolverErrors } from './error-messages'; @@ -37,8 +37,8 @@ export * from './types'; export class Spectral { private readonly _resolver: IResolver; - private readonly _parsedMap: IParseMap; - private static readonly _parsedCache = new WeakMap(); + private readonly _parsedRefs: Dictionary; + private static readonly _parsedCache = new WeakMap>(); public functions: FunctionCollection = { ...defaultFunctions }; public rules: RunRuleCollection = {}; @@ -49,17 +49,13 @@ export class Spectral { this.formats = {}; const cacheKey = this._resolver instanceof Resolver ? this._resolver.uriCache : this._resolver; - const _parsedMap = Spectral._parsedCache.get(cacheKey); - if (_parsedMap) { - this._parsedMap = _parsedMap; + const _parsedRefs = Spectral._parsedCache.get(cacheKey); + if (_parsedRefs) { + this._parsedRefs = _parsedRefs; } else { - this._parsedMap = { - refs: {}, - parsed: {}, - pointers: {}, - }; + this._parsedRefs = {}; - Spectral._parsedCache.set(cacheKey, this._parsedMap); + Spectral._parsedCache.set(cacheKey, this._parsedRefs); } } @@ -79,6 +75,8 @@ export class Spectral { parsedResult = { parsed: parseYaml(typeof target === 'string' ? target : safeStringify(target, undefined, 2)), getLocationForJsonPath: getLocationForJsonPathYaml, + // we need to normalize the path in case path with forward slashes is given + source: opts.resolve?.documentUri && normalize(opts.resolve.documentUri), }; } else { parsedResult = target; @@ -95,7 +93,7 @@ export class Spectral { baseUri: documentUri, parseResolveResult: this._parseResolveResult(refDiagnostics), }), - this._parsedMap, + this._parsedRefs, ); if (resolved.formats === void 0) { @@ -186,35 +184,12 @@ export class Spectral { this.formats[format] = fn; } - private _processExternalRef(parsedResult: IParsedResult, opts: IUriParser) { - const ref = opts.targetAuthority.toString(); - this._parsedMap.parsed[ref] = parsedResult; - this._parsedMap.pointers[ref] = opts.parentPath; - const parentRef = opts.parentAuthority.toString(); - - set( - this._parsedMap.refs, - [...(this._parsedMap.pointers[parentRef] ? this._parsedMap.pointers[parentRef] : []), ...opts.parentPath], - Object.defineProperty({}, REF_METADATA, { - enumerable: false, - writable: false, - value: { - ref, - root: opts.fragment.split('/').slice(1), - }, - }), - ); - } - private _parseResolveResult = (refDiagnostics: IDiagnostic[]) => async (resolveOpts: IUriParser) => { - const ref = resolveOpts.targetAuthority.toString(); + const ref = resolveOpts.targetAuthority.href().replace(/\/$/, ''); const ext = extname(ref); const content = String(resolveOpts.result); - let parsedRefResult: - | IParsedResult> - | IParsedResult> - | undefined; + let parsedRefResult: Optional> | IParsedResult>>; if (ext === '.yml' || ext === '.yaml') { parsedRefResult = { parsed: parseYaml(content), @@ -229,21 +204,19 @@ export class Spectral { }; } - if (parsedRefResult !== undefined) { + if (parsedRefResult !== void 0) { resolveOpts.result = parsedRefResult.parsed.data; if (parsedRefResult.parsed.diagnostics.length > 0) { refDiagnostics.push(...formatParserDiagnostics(parsedRefResult.parsed.diagnostics, parsedRefResult.source)); } - this._processExternalRef(parsedRefResult, resolveOpts); + this._parsedRefs[ref] = parsedRefResult; } return resolveOpts; }; } -export const REF_METADATA = Symbol('external_ref_metadata'); - export const isParsedResult = (obj: any): obj is IParsedResult => { if (!obj || typeof obj !== 'object') return false; if (!obj.parsed || typeof obj.parsed !== 'object') return false; @@ -251,9 +224,3 @@ export const isParsedResult = (obj: any): obj is IParsedResult => { return true; }; - -export interface IParseMap { - refs: Dictionary; - parsed: Dictionary; - pointers: Dictionary; -} diff --git a/src/utils/__tests__/refs.jest.test.ts b/src/utils/__tests__/refs.jest.test.ts new file mode 100644 index 000000000..03e833a92 --- /dev/null +++ b/src/utils/__tests__/refs.jest.test.ts @@ -0,0 +1,48 @@ +import { traverseObjUntilRef } from '..'; + +describe('$ref utils', () => { + describe('traverseObjUntilRef', () => { + it('given a broken json path, throws', () => { + const obj = { + foo: { + bar: { + baz: '', + }, + }, + bar: '', + }; + + expect(traverseObjUntilRef.bind(null, obj, ['foo', 'baz'])).toThrow('Segment is not a part of the object'); + expect(traverseObjUntilRef.bind(null, obj, ['bar', 'foo'])).toThrow('Segment is not a part of the object'); + }); + + it('given a json path pointing at object with ref, returns the ref', () => { + const obj = { + x: { + $ref: 'test.json#', + }, + foo: { + bar: { + $ref: '../a.json#', + }, + }, + }; + + expect(traverseObjUntilRef(obj, ['x', 'bar'])).toEqual('test.json#'); + expect(traverseObjUntilRef(obj, ['foo', 'bar', 'baz'])).toEqual('../a.json#'); + }); + + it('given a finite json path pointing at value in project, returns null', () => { + const obj = { + x: {}, + foo: { + bar: 'test', + }, + }; + + expect(traverseObjUntilRef(obj, ['x'])).toBeNull(); + expect(traverseObjUntilRef(obj, ['foo'])).toBeNull(); + expect(traverseObjUntilRef(obj, ['foo', 'bar'])).toBeNull(); + }); + }); +}); diff --git a/src/utils/hasRef.ts b/src/utils/hasRef.ts deleted file mode 100644 index 3d42e5f2e..000000000 --- a/src/utils/hasRef.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const hasRef = (obj: object): obj is object & { $ref: string } => - '$ref' in obj && typeof (obj as Partial<{ $ref: unknown }>).$ref === 'string'; diff --git a/src/utils/index.ts b/src/utils/index.ts index d075e157a..60a050ee6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ export * from './empty'; -export * from './hasRef'; export * from './hasIntersectingElement'; export * from './isObject'; +export * from './refs'; diff --git a/src/utils/refs.ts b/src/utils/refs.ts new file mode 100644 index 000000000..03935be6f --- /dev/null +++ b/src/utils/refs.ts @@ -0,0 +1,45 @@ +import { extractPointerFromRef, hasRef, pointerToPath } from '@stoplight/json'; +import { isAbsolute } from '@stoplight/path'; +import { Dictionary, JsonPath } from '@stoplight/types'; +import { isObject } from 'lodash'; + +export const isAbsoluteRef = (ref: string) => isAbsolute(ref) || /^[a-z]+:\/\//i.test(ref); + +export const traverseObjUntilRef = (obj: unknown, path: JsonPath): string | null => { + let piece: unknown = obj; + + for (const segment of path.slice()) { + if (!isObject(piece)) { + throw new TypeError('Segment is not a part of the object'); + } + + if (segment in piece) { + piece = piece[segment]; + } else if (hasRef(piece)) { + return piece.$ref; + } else { + throw new Error('Segment is not a part of the object'); + } + + path.shift(); + } + + if (isObject(piece) && hasRef(piece) && Object.keys(piece).length === 1) { + return piece.$ref; + } + + return null; +}; + +export const getEndRef = (refMap: Dictionary, $ref: string): string => { + while ($ref in refMap) { + $ref = refMap[$ref]; + } + + return $ref; +}; + +export const safePointerToPath = (pointer: string): JsonPath => { + const rawPointer = extractPointerFromRef(pointer); + return rawPointer ? pointerToPath(rawPointer) : []; +}; diff --git a/yarn.lock b/yarn.lock index bfb3be460..5be8ae14b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -359,10 +359,10 @@ lodash "^4.17.15" safe-stable-stringify "^1.1" -"@stoplight/json@^3.3.0": - version "3.3.0" - resolved "https://registry.yarnpkg.com/@stoplight/json/-/json-3.3.0.tgz#25d35daf0f189039d17fa1bbf6b2bd4da157a2e6" - integrity sha512-lseHw4Tr7SpmfumNdNSsxn/KlrTFZ2EyyVDUXCn10CVDzHYPfVIngVeYPbLc8c5RRvhow6S0zmBK0m9rXPFZiQ== +"@stoplight/json@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@stoplight/json/-/json-3.4.0.tgz#c2557ced0834a127b1ce524e77f15364d814047a" + integrity sha512-Ljjj11Wa+MusOMeXTehLbuJQJe8CG3sovCGcD/6c8Zyz1n39EsDLZwJIpQ6/DQAdKChiJGWdcULsrGdheFaiLg== dependencies: "@stoplight/types" "^11.1.1" jsonc-parser "~2.2.0"