diff --git a/src/parser.ts b/src/parser.ts index bd43ca683..c6e7f0c4c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -8,11 +8,13 @@ import { createSpectral } from './spectral'; import type { Spectral } from '@stoplight/spectral-core'; import type { ParseOptions, ParseOutput } from './parse'; import type { ValidateOptions } from './validate'; +import type { ResolverOptions } from './resolver'; import type { SchemaParser } from './schema-parser'; import type { Diagnostic, Input } from './types'; export interface ParserOptions { schemaParsers?: Array; + __unstableResolver?: ResolverOptions; } export class Parser { @@ -22,7 +24,7 @@ export class Parser { constructor( private readonly options: ParserOptions = {} ) { - this.spectral = createSpectral(this); + this.spectral = createSpectral(this, options); this.registerSchemaParser(AsyncAPISchemaParser()); this.options.schemaParsers?.forEach(parser => this.registerSchemaParser(parser)); } diff --git a/src/resolver.ts b/src/resolver.ts index a207c9e8a..fa8576de6 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,35 +1,81 @@ -import { Resolver as SpectralResolver } from '@stoplight/json-ref-resolver'; +import { Resolver as SpectralResolver } from '@stoplight/spectral-ref-resolver'; import { resolveFile, resolveHttp } from '@stoplight/json-ref-readers'; +import type Uri from 'urijs'; + export interface Resolver { + schema: 'file' | 'http' | 'https' | string; order?: number; - canRead?: boolean | ((input: ResolverInput) => boolean); - read: (input: ResolverInput) => string | Buffer | Promise; + canRead?: boolean | ((uri: Uri, ctx?: any) => boolean); + read: (uri: Uri, ctx?: any) => string | undefined | Promise; } -export interface ResolverInput { - url: string; - extension: string; +export interface ResolverOptions { + resolvers?: Array; } -interface ResolverOptions { - resolvers: Array; -} +export function createResolver(options: ResolverOptions = {}): SpectralResolver { + const availableResolvers: Array = [ + ...createDefaultResolvers(), + ...(options?.resolvers || []) + ].map(r => ({ + ...r, + order: r.order || Number.MAX_SAFE_INTEGER, + canRead: typeof r.canRead === 'undefined' ? true: r.canRead, + })); + const availableSchemas = [...new Set(availableResolvers.map(r => r.schema))]; + const resolvers = availableSchemas.reduce((acc, schema) => { + acc[schema] = { resolve: createSchemaResolver(schema, availableResolvers) }; + return acc; + }, {} as Record string | Promise }>); -export function createResolver(options?: ResolverOptions): SpectralResolver { return new SpectralResolver({ - resolvers: { - https: { resolve: resolveHttp }, - http: { resolve: resolveHttp }, - file: { resolve: resolveFile }, - }, + resolvers: resolvers as any, }); } -export function createFileResolver() { +function createDefaultResolvers(): Array { + return [ + { + schema: 'file', + read: resolveFile as (input: Uri, ctx?: any) => string | Promise, + }, + { + schema: 'https', + read: resolveHttp as (input: Uri, ctx?: any) => string | Promise, + }, + { + schema: 'http', + read: resolveHttp as (input: Uri, ctx?: any) => string | Promise, + }, + ] +} + +function createSchemaResolver(schema: string, allResolvers: Array): (uri: Uri, ctx?: any) => string | Promise { + const resolvers = allResolvers.filter(r => r.schema === schema).sort((a, b) => { return (a.order as number) - (b.order as number); }); + return async (uri, ctx) => { + let result: string | undefined = undefined; + let lastError: Error | undefined; + for (const resolver of resolvers) { + try { + if (!canRead(resolver, uri, ctx)) continue; + result = await resolver.read(uri, ctx); + if (typeof result === 'string') { + break; + } + } catch(e: any) { + lastError = e; + continue; + } + } + if (typeof result !== 'string') { + throw lastError || new Error(`None of the available resolvers for "${schema}" can resolve the given reference.`); + } + return result; + } } -export function createHttpResolver() { - +function canRead(resolver: Resolver, uri: Uri, ctx?: any) { + return typeof resolver.canRead === 'function' ? resolver.canRead(uri, ctx) : resolver.canRead; } diff --git a/src/spectral.ts b/src/spectral.ts index 7155ff2ac..214ae8baa 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -1,17 +1,18 @@ import { Spectral, createRulesetFunction } from '@stoplight/spectral-core'; import aasRuleset from '@stoplight/spectral-rulesets/dist/asyncapi'; +import { createResolver } from './resolver'; import { asyncApi2SchemaParserRule } from './schema-parser/spectral-rule-v2'; import { specVersions } from './constants'; import { isObject } from './utils'; import type { RuleDefinition, RulesetDefinition } from '@stoplight/spectral-core'; -import type { Parser } from './parser'; +import type { Parser, ParserOptions } from './parser'; import type { MaybeAsyncAPI } from './types'; -export function createSpectral(parser: Parser): Spectral { +export function createSpectral(parser: Parser, options: ParserOptions): Spectral { + const spectral = new Spectral({ resolver: createResolver(options.__unstableResolver) }); const ruleset = configureRuleset(parser); - const spectral = new Spectral(); spectral.setRuleset(ruleset); return spectral; } diff --git a/src/stringify.ts b/src/stringify.ts index 95d3c253b..4797d9549 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -57,6 +57,13 @@ export function copy(data: Record) { return unstringifiedData; } +export function unfreeze(data: Record) { + const stringifiedData = JSON.stringify(data, refReplacer()); + const unstringifiedData = JSON.parse(stringifiedData); + traverseStringifiedDoc(unstringifiedData, undefined, unstringifiedData, new Map(), new Map()); + return unstringifiedData; +} + function refReplacer() { const modelPaths = new Map(); const paths = new Map(); diff --git a/test/mocks/simple-message.yaml b/test/mocks/simple-message.yaml new file mode 100644 index 000000000..14ef70324 --- /dev/null +++ b/test/mocks/simple-message.yaml @@ -0,0 +1,5 @@ +payload: + type: object + properties: + someProperty: + type: string \ No newline at end of file diff --git a/test/resolver.spec.ts b/test/resolver.spec.ts new file mode 100644 index 000000000..9d9586ae6 --- /dev/null +++ b/test/resolver.spec.ts @@ -0,0 +1,150 @@ +import { AsyncAPIDocumentV2 } from '../src/models'; +import { Parser } from '../src/parser'; +import { parse } from '../src/parse'; + +describe('custom resolver', function() { + it('should resolve document references', async function() { + const parser = new Parser(); + + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'publish', + message: { + $ref: '#/components/messages/message' + } + }, + } + }, + components: { + messages: { + message: { + payload: { + type: 'string' + } + } + } + } + } + const { parsed } = await parse(parser, document); + + expect(parsed).toBeInstanceOf(AsyncAPIDocumentV2); + const refMessage = parsed?.channels().get('channel')?.operations().get('publish')?.messages()[0]; + expect(refMessage?.json()).not.toBeUndefined(); + expect(refMessage?.json() === parsed?.components().messages().get('message')?.json()).toEqual(true); + }); + + it('should resolve file references', async function() { + const parser = new Parser(); + + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'publish', + message: { + $ref: './mocks/simple-message.yaml' + } + }, + } + }, + } + const { parsed } = await parse(parser, document, { validateOptions: { path: __filename } }); + + expect(parsed).toBeInstanceOf(AsyncAPIDocumentV2); + const refMessage = parsed?.channels().get('channel')?.operations().get('publish')?.messages()[0]; + expect(refMessage?.json()).not.toBeUndefined(); + expect(refMessage?.json('$ref')).toBeUndefined(); + }); + + it('should resolve http references', async function() { + const parser = new Parser(); + + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'publish', + message: { + $ref: 'https://raw.githubusercontent.com/asyncapi/spec/v2.0.0/examples/2.0.0/streetlights.yml#/components/messages/lightMeasured' + } + }, + } + }, + } + const { parsed } = await parse(parser, document); + + expect(parsed).toBeInstanceOf(AsyncAPIDocumentV2); // we should have parsed document + }); + + it('should resolve custom protocols', async function() { + const parser = new Parser({ + __unstableResolver: { + resolvers: [ + { + schema: 'customProtocol', + read(uri) { + if (uri.path() === '/someRef') { + return '{"someRef": "value"}'; + } + return '{"anotherRef": "value"}'; + }, + } + ] + } + }); + + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'publish', + message: { + payload: { + $ref: 'customProtocol:///someRef' + } + } + }, + subscribe: { + operationId: 'subscribe', + message: { + payload: { + $ref: 'customProtocol:///anotherRef' + } + } + }, + } + }, + } + const { parsed } = await parse(parser, document); + + expect(parsed).toBeInstanceOf(AsyncAPIDocumentV2); + const someRef = parsed?.channels().get('channel')?.operations().get('publish')?.messages()[0]?.payload(); + expect(someRef?.json()).toEqual({ someRef: 'value' }); + expect(someRef?.json('$ref')).toBeUndefined(); + const anotherRef = parsed?.channels().get('channel')?.operations().get('subscribe')?.messages()[0]?.payload(); + expect(anotherRef?.json()).toEqual({ anotherRef: 'value' }); + expect(anotherRef?.json('$ref')).toBeUndefined(); + }); +});