From 3062f85432cc9572ed13025ddb21f5486ae4b9c8 Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Mon, 5 Sep 2022 17:12:33 +0200 Subject: [PATCH] refactor: add custom resolver --- package-lock.json | 40 +-------- package.json | 3 +- src/document.ts | 41 +++++++++ src/parse.ts | 3 +- src/parser.ts | 4 +- src/resolver.ts | 82 ++++++++++++++---- src/spectral.ts | 7 +- src/stringify.ts | 7 ++ test/mocks/simple-message.yaml | 5 ++ test/resolver.spec.ts | 150 +++++++++++++++++++++++++++++++++ 10 files changed, 280 insertions(+), 62 deletions(-) create mode 100644 src/document.ts create mode 100644 test/mocks/simple-message.yaml create mode 100644 test/resolver.spec.ts diff --git a/package-lock.json b/package-lock.json index 5a1550dd2..c9755b940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,10 @@ "@asyncapi/specs": "^3.1.0", "@openapi-contrib/openapi-schema-to-json-schema": "^3.2.0", "@stoplight/json-ref-readers": "^1.2.2", - "@stoplight/json-ref-resolver": "^3.1.4", "@stoplight/spectral-core": "^1.13.1", "@stoplight/spectral-functions": "^1.7.1", "@stoplight/spectral-parsers": "^1.0.2", + "@stoplight/spectral-ref-resolver": "^1.0.1", "@stoplight/spectral-rulesets": "^1.12.0", "ajv": "^8.11.0", "avsc": "^5.7.4", @@ -35,6 +35,7 @@ "@types/js-yaml": "^4.0.5", "@types/json-schema": "^7.0.11", "@types/lodash": "^4.14.179", + "@types/urijs": "^1.19.19", "conventional-changelog-conventionalcommits": "^4.2.3", "cross-env": "^7.0.3", "eslint": "^7.27.0", @@ -2012,26 +2013,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, - "node_modules/@stoplight/json-ref-resolver": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.4.tgz", - "integrity": "sha512-842JVmMsi++qpDuIX+JpQvK7YY8FXEZZb+/z4xuRfStOAVEryJT/tbgGOWxniSdxEl9Eni5D/I2afMyy6BuiNw==", - "dependencies": { - "@stoplight/json": "^3.17.0", - "@stoplight/path": "^1.3.2", - "@stoplight/types": "^12.3.0 || ^13.0.0", - "@types/urijs": "^1.19.19", - "dependency-graph": "~0.11.0", - "fast-memoize": "^2.5.2", - "immer": "^9.0.6", - "lodash": "^4.17.21", - "tslib": "^2.3.1", - "urijs": "^1.19.11" - }, - "engines": { - "node": ">=8.3.0" - } - }, "node_modules/@stoplight/json/node_modules/@stoplight/types": { "version": "13.6.0", "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", @@ -17246,23 +17227,6 @@ } } }, - "@stoplight/json-ref-resolver": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.4.tgz", - "integrity": "sha512-842JVmMsi++qpDuIX+JpQvK7YY8FXEZZb+/z4xuRfStOAVEryJT/tbgGOWxniSdxEl9Eni5D/I2afMyy6BuiNw==", - "requires": { - "@stoplight/json": "^3.17.0", - "@stoplight/path": "^1.3.2", - "@stoplight/types": "^12.3.0 || ^13.0.0", - "@types/urijs": "^1.19.19", - "dependency-graph": "~0.11.0", - "fast-memoize": "^2.5.2", - "immer": "^9.0.6", - "lodash": "^4.17.21", - "tslib": "^2.3.1", - "urijs": "^1.19.11" - } - }, "@stoplight/lifecycle": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@stoplight/lifecycle/-/lifecycle-2.3.2.tgz", diff --git a/package.json b/package.json index 8e327613c..03605bb93 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/js-yaml": "^4.0.5", "@types/json-schema": "^7.0.11", "@types/lodash": "^4.14.179", + "@types/urijs": "^1.19.19", "conventional-changelog-conventionalcommits": "^4.2.3", "cross-env": "^7.0.3", "eslint": "^7.27.0", @@ -52,10 +53,10 @@ "@asyncapi/specs": "^3.1.0", "@openapi-contrib/openapi-schema-to-json-schema": "^3.2.0", "@stoplight/json-ref-readers": "^1.2.2", - "@stoplight/json-ref-resolver": "^3.1.4", "@stoplight/spectral-core": "^1.13.1", "@stoplight/spectral-functions": "^1.7.1", "@stoplight/spectral-parsers": "^1.0.2", + "@stoplight/spectral-ref-resolver": "^1.0.1", "@stoplight/spectral-rulesets": "^1.12.0", "ajv": "^8.11.0", "avsc": "^5.7.4", diff --git a/src/document.ts b/src/document.ts new file mode 100644 index 000000000..d37112fb8 --- /dev/null +++ b/src/document.ts @@ -0,0 +1,41 @@ +import { newAsyncAPIDocument, AsyncAPIDocumentV2, AsyncAPIDocumentV3 } from './models'; +import { unstringify } from './stringify'; +import { createDetailedAsyncAPI } from './utils'; + +import { + xParserSpecParsed, + xParserSpecStringified, +} from './constants'; + +import type { AsyncAPIDocumentInterface } from './models'; + +export function toAsyncAPIDocument(maybeDoc: unknown): AsyncAPIDocumentInterface | undefined { + if (isAsyncAPIDocument(maybeDoc)) { + return maybeDoc; + } + if (!isParsedDocument(maybeDoc)) { + return; + } + return unstringify(maybeDoc) || newAsyncAPIDocument(createDetailedAsyncAPI(maybeDoc, maybeDoc as any)); +} + +export function isAsyncAPIDocument(maybeDoc: unknown): maybeDoc is AsyncAPIDocumentInterface { + return maybeDoc instanceof AsyncAPIDocumentV2 || maybeDoc instanceof AsyncAPIDocumentV3; +} + +export function isParsedDocument(maybeDoc: unknown): maybeDoc is Record { + if (typeof maybeDoc !== 'object' || maybeDoc === null) { + return false; + } + return Boolean((maybeDoc as Record)[xParserSpecParsed]); +} + +export function isStringifiedDocument(maybeDoc: unknown): maybeDoc is Record { + if (typeof maybeDoc !== 'object' || maybeDoc === null) { + return false; + } + return ( + Boolean((maybeDoc as Record)[xParserSpecParsed]) && + Boolean((maybeDoc as Record)[xParserSpecStringified]) + ); +} diff --git a/src/parse.ts b/src/parse.ts index fb7d6822a..e33c0e6d3 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -2,6 +2,7 @@ import { AsyncAPIDocumentInterface, newAsyncAPIDocument } from "./models"; import { customOperations } from './custom-operations'; import { validate } from "./lint"; +import { unfreeze } from "./stringify"; import { createDetailedAsyncAPI, normalizeInput, toAsyncAPIDocument, unfreezeObject } from "./utils"; import { xParserSpecParsed } from './constants'; @@ -47,7 +48,7 @@ export async function parse(parser: Parser, asyncapi: ParseInput, options?: Pars } // unfreeze the object - Spectral makes resolved document "freezed" - const validatedDoc = unfreezeObject(validated); + const validatedDoc = unfreeze(validated as Record); validatedDoc[String(xParserSpecParsed)] = true; const detailed = createDetailedAsyncAPI(asyncapi as string | Record, validatedDoc); diff --git a/src/parser.ts b/src/parser.ts index d1d6579d8..1abb4026d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -8,9 +8,11 @@ import type { Spectral } from "@stoplight/spectral-core"; import type { ParseInput, ParseOptions } from "./parse"; import type { LintOptions, ValidateOptions } from "./lint"; import type { SchemaParser } from './schema-parser'; +import type { ResolverOptions } from './resolver'; export interface ParserOptions { schemaParsers?: Array; + __unstableResolver?: ResolverOptions; } export class Parser { @@ -20,7 +22,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 cd809e834..17fd4d06a 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -2,16 +2,17 @@ import { Spectral } from "@stoplight/spectral-core"; import { createRulesetFunction } from '@stoplight/spectral-core'; import { asyncapi as aasRuleset } from "@stoplight/spectral-rulesets"; +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) { - const spectral = new Spectral(); +export function createSpectral(parser: Parser, options: ParserOptions = {}) { + const spectral = new Spectral({ resolver: createResolver(options.__unstableResolver) }); const ruleset = configureRuleset(parser); spectral.setRuleset(ruleset); return spectral; diff --git a/src/stringify.ts b/src/stringify.ts index 802c95890..afb18c1a6 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -50,6 +50,13 @@ export function unstringify(document: unknown): AsyncAPIDocumentInterface | unde return newAsyncAPIDocument(createDetailedAsyncAPI(document as string, parsed as DetailedAsyncAPI['parsed'] )); } +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(); + }); +});