Skip to content

Commit

Permalink
refactor: add custom resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu committed Sep 19, 2022
1 parent 6e5553c commit 7144359
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 22 deletions.
4 changes: 3 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SchemaParser>;
__unstableResolver?: ResolverOptions;
}

export class Parser {
Expand All @@ -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));
}
Expand Down
82 changes: 64 additions & 18 deletions src/resolver.ts
Original file line number Diff line number Diff line change
@@ -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<string | Buffer>;
canRead?: boolean | ((uri: Uri, ctx?: any) => boolean);
read: (uri: Uri, ctx?: any) => string | undefined | Promise<string | undefined>;
}

export interface ResolverInput {
url: string;
extension: string;
export interface ResolverOptions {
resolvers?: Array<Resolver>;
}

interface ResolverOptions {
resolvers: Array<Resolver>;
}
export function createResolver(options: ResolverOptions = {}): SpectralResolver {
const availableResolvers: Array<Resolver> = [
...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, { resolve: (uri: Uri, ctx?: any) => string | Promise<string> }>);

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<Resolver> {
return [
{
schema: 'file',
read: resolveFile as (input: Uri, ctx?: any) => string | Promise<string>,
},
{
schema: 'https',
read: resolveHttp as (input: Uri, ctx?: any) => string | Promise<string>,
},
{
schema: 'http',
read: resolveHttp as (input: Uri, ctx?: any) => string | Promise<string>,
},
]
}

function createSchemaResolver(schema: string, allResolvers: Array<Resolver>): (uri: Uri, ctx?: any) => string | Promise<string> {
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;
}
7 changes: 4 additions & 3 deletions src/spectral.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export function copy(data: Record<string, any>) {
return unstringifiedData;
}

export function unfreeze(data: Record<string, any>) {
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();
Expand Down
5 changes: 5 additions & 0 deletions test/mocks/simple-message.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
payload:
type: object
properties:
someProperty:
type: string
150 changes: 150 additions & 0 deletions test/resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});

0 comments on commit 7144359

Please sign in to comment.