From 092eb43645587d63e840e46972f2e6526cc3483e Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Wed, 9 Mar 2022 14:51:19 +0100 Subject: [PATCH 1/4] refactor: init parse, lint and validate functions --- jest.config.ts | 5 +++ src/lint.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++ src/models/base.ts | 4 +-- src/parse.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/stringify.ts | 8 +++-- src/types.ts | 10 ++++++ src/utils.ts | 16 ++++++++++ test/lint.spec.ts | 68 +++++++++++++++++++++++++++++++++++++++++ test/parse.spec.ts | 33 ++++++++++++++++++++ 9 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 src/lint.ts create mode 100644 src/parse.ts create mode 100644 src/types.ts create mode 100644 test/lint.spec.ts create mode 100644 test/parse.spec.ts diff --git a/jest.config.ts b/jest.config.ts index e8ae860c9..72a24eb07 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -10,6 +10,11 @@ const config: Config.InitialOptions = { // The root of your source code, typically /src // `` is a token Jest substitutes roots: [''], + moduleNameMapper: { + '^nimma/legacy$': '/node_modules/nimma/dist/legacy/cjs/index.js', + '^nimma/(.*)': '/node_modules/nimma/dist/cjs/$1', + '^@stoplight/spectral-ruleset-bundler/(.*)$': '/node_modules/@stoplight/spectral-ruleset-bundler/dist/$1' + }, // Test spec file resolution pattern // Matches parent folder `__tests__` and filename diff --git a/src/lint.ts b/src/lint.ts new file mode 100644 index 000000000..dffc41d33 --- /dev/null +++ b/src/lint.ts @@ -0,0 +1,76 @@ +import { + IConstructorOpts, + ISpectralDiagnostic, + IRunOpts, + Spectral, + Ruleset, + RulesetDefinition, +} from "@stoplight/spectral-core"; +import { asyncapi as aasRuleset } from "@stoplight/spectral-rulesets"; + +import { toAsyncAPIDocument, normalizeInput, hasWarningDiagnostic, hasErrorDiagnostic } from "./utils"; + +import type { AsyncAPIDocument } from "./models/asyncapi"; +import type { ParserInput } from "./types"; + +export interface LintOptions extends IConstructorOpts, IRunOpts { + ruleset?: RulesetDefinition | Ruleset; +} + +export interface ValidateOptions extends LintOptions { + allowedSeverity?: { + warning?: boolean; + }; +} + +export interface ValidateOutput { + validated: unknown; + diagnostics: ISpectralDiagnostic[]; +} + +export async function lint(asyncapi: ParserInput, options?: LintOptions): Promise { + if (toAsyncAPIDocument(asyncapi)) { + return; + } + const document = normalizeInput(asyncapi as Exclude); + return (await validate(document, options)).diagnostics; +} + +export async function validate(asyncapi: string, options?: ValidateOptions): Promise { + const { ruleset, allowedSeverity, ...restOptions } = normalizeOptions(options); + const spectral = new Spectral(restOptions); + + spectral.setRuleset(ruleset!); + let { resolved, results } = await spectral.runWithResolved(asyncapi); + + if ( + hasErrorDiagnostic(results) || + (!allowedSeverity?.warning && hasWarningDiagnostic(results)) + ) { + resolved = undefined; + } + + return { validated: resolved, diagnostics: results }; +} + +const defaultOptions: ValidateOptions = { + // TODO: fix that type + ruleset: aasRuleset as any, + allowedSeverity: { + warning: true, + } +}; +function normalizeOptions(options?: ValidateOptions): ValidateOptions { + if (!options || typeof options !== 'object') { + return defaultOptions; + } + // shall copy + options = { ...defaultOptions, ...options }; + + // ruleset + options.ruleset = options.ruleset || defaultOptions.ruleset; + // severity + options.allowedSeverity = { ...defaultOptions.allowedSeverity, ...(options.allowedSeverity || {}) }; + + return options; +} diff --git a/src/models/base.ts b/src/models/base.ts index bda573a79..f5ac549b5 100644 --- a/src/models/base.ts +++ b/src/models/base.ts @@ -3,8 +3,8 @@ export class BaseModel { private readonly _json: Record, ) {} - json>(): T; - json(key: string | number): T; + json>(): T; + json(key: string | number): T; json(key?: string | number) { if (key === undefined) return this._json; if (!this._json) return; diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 000000000..4da038d05 --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,64 @@ +import { AsyncAPIDocument } from "./models"; +import { hasErrorDiagnostic, normalizeInput, toAsyncAPIDocument } from "./utils"; +import { validate } from "./lint"; + +import type { ParserInput, ParserOutput } from './types'; +import type { ValidateOptions } from './lint'; + +export interface ParseOptions { + applyTraits?: boolean; + validateOptions?: ValidateOptions; +} + +export async function parse(asyncapi: ParserInput, options?: ParseOptions): Promise { + let maybeDocument = toAsyncAPIDocument(asyncapi); + if (maybeDocument) { + return { + source: asyncapi, + parsed: maybeDocument, + diagnostics: [], + }; + } + + try { + const document = normalizeInput(asyncapi as Exclude); + options = normalizeOptions(options); + + const { validated, diagnostics } = await validate(document, options.validateOptions); + if (validated === undefined) { + return { + source: asyncapi, + parsed: undefined, + diagnostics, + }; + } + + const parsed = new AsyncAPIDocument(validated as Record); + return { + source: asyncapi, + parsed, + diagnostics, + }; + } catch(err) { + // TODO: throw proper error + throw Error(); + } +} + +const defaultOptions: ParseOptions = { + applyTraits: true, +}; +function normalizeOptions(options?: ParseOptions): ParseOptions { + if (!options || typeof options !== 'object') { + return defaultOptions; + } + // shall copy + options = { ...defaultOptions, ...options }; + + // traits + if (options.applyTraits === undefined) { + options.applyTraits = true; + } + + return options; +} diff --git a/src/stringify.ts b/src/stringify.ts index c2679d0a9..d4287a8b3 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -3,7 +3,11 @@ import { AsyncAPIDocument } from './models'; import { isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from './utils'; import { xParserSpecStringified } from './constants'; -export function stringify(document: unknown, space?: string | number): string | undefined { +export interface StringifyOptions { + space?: string | number; +} + +export function stringify(document: unknown, options: StringifyOptions = {}): string | undefined { if (isAsyncAPIDocument(document)) { document = document.json(); } else if (isParsedDocument(document)) { @@ -18,7 +22,7 @@ export function stringify(document: unknown, space?: string | number): string | return JSON.stringify({ ...document as Record, [String(xParserSpecStringified)]: true, - }, refReplacer(), space); + }, refReplacer(), options.space || 2); } export function unstringify(document: unknown): AsyncAPIDocument | undefined { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..b253433d0 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +import type { ISpectralDiagnostic } from '@stoplight/spectral-core'; +import type { AsyncAPIDocument } from './models/asyncapi'; + +export type MaybeAsyncAPI = { asyncapi: unknown } & Record; +export type ParserInput = string | MaybeAsyncAPI | AsyncAPIDocument; +export interface ParserOutput { + source: ParserInput; + parsed: AsyncAPIDocument | undefined; + diagnostics: ISpectralDiagnostic[]; +} diff --git a/src/utils.ts b/src/utils.ts index 26370bb48..253a8026e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { DiagnosticSeverity } from '@stoplight/types'; import { AsyncAPIDocument } from './models'; import { unstringify } from './stringify'; @@ -6,6 +7,9 @@ import { xParserSpecStringified, } from './constants'; +import type { ISpectralDiagnostic } from '@stoplight/spectral-core'; +import type { MaybeAsyncAPI } from 'types'; + export function toAsyncAPIDocument(maybeDoc: unknown): AsyncAPIDocument | undefined { if (isAsyncAPIDocument(maybeDoc)) { return maybeDoc; @@ -36,3 +40,15 @@ export function isStringifiedDocument(maybeDoc: unknown): maybeDoc is Record)[xParserSpecStringified]) ); } + +export function normalizeInput(asyncapi: string | MaybeAsyncAPI): string { + return JSON.stringify(asyncapi, undefined, 2); +}; + +export function hasErrorDiagnostic(diagnostics: ISpectralDiagnostic[]): boolean { + return diagnostics.some(diagnostic => diagnostic.severity === DiagnosticSeverity.Error); +} + +export function hasWarningDiagnostic(diagnostics: ISpectralDiagnostic[]): boolean { + return diagnostics.some(diagnostic => diagnostic.severity === DiagnosticSeverity.Warning); +} diff --git a/test/lint.spec.ts b/test/lint.spec.ts new file mode 100644 index 000000000..c724c7a8a --- /dev/null +++ b/test/lint.spec.ts @@ -0,0 +1,68 @@ +import { lint, validate } from '../src/lint'; + +describe('lint() & validate()', function() { + describe('lint()', function() { + it('should lint invalid document', async function() { + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + } + + const diagnostics = await lint(document); + if (!diagnostics) { + return; + } + + expect(diagnostics.length > 0).toEqual(true); + }); + }); + + describe('validate()', function() { + it('should validate invalid document', async function() { + const document = JSON.stringify({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + }, undefined, 2); + const { validated, diagnostics } = await validate(document); + + expect(validated).toBeUndefined(); + expect(diagnostics.length > 0).toEqual(true); + }); + + it('should validate valid document', async function() { + const document = JSON.stringify({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: {} + }, undefined, 2); + const { validated, diagnostics } = await validate(document); + + expect(validated).not.toBeUndefined(); + expect(diagnostics.length > 0).toEqual(true); + }); + + it('should validate valid document - do not allow warning severity', async function() { + const document = JSON.stringify({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: {} + }, undefined, 2); + const { validated, diagnostics } = await validate(document, { allowedSeverity: { warning: false } }); + + expect(validated).toBeUndefined(); + expect(diagnostics.length > 0).toEqual(true); + }); + }); +}); diff --git a/test/parse.spec.ts b/test/parse.spec.ts new file mode 100644 index 000000000..3d17329f3 --- /dev/null +++ b/test/parse.spec.ts @@ -0,0 +1,33 @@ +import { AsyncAPIDocument } from '../src/models/asyncapi'; +import { parse } from '../src/parse'; + +describe('parse()', function() { + it('should parse valid document', async function() { + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: {} + } + const { parsed, diagnostics } = await parse(document); + + expect(parsed).toBeInstanceOf(AsyncAPIDocument); + expect(diagnostics.length > 0).toEqual(true); + }); + + it('should parse invalid document', async function() { + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + } + const { parsed, diagnostics } = await parse(document); + + expect(parsed).toEqual(undefined); + expect(diagnostics.length > 0).toEqual(true); + }); +}); From 9d116279a2803afd97d698c2cb2d429a7a07432b Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Wed, 9 Mar 2022 14:57:52 +0100 Subject: [PATCH 2/4] refactor: init parse, lint and validate functions --- src/lint.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lint.ts b/src/lint.ts index dffc41d33..df7b4ecb1 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -67,8 +67,6 @@ function normalizeOptions(options?: ValidateOptions): ValidateOptions { // shall copy options = { ...defaultOptions, ...options }; - // ruleset - options.ruleset = options.ruleset || defaultOptions.ruleset; // severity options.allowedSeverity = { ...defaultOptions.allowedSeverity, ...(options.allowedSeverity || {}) }; From ed4fd7a2364dcd1ec5aaf5201f809bfb4e454172 Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Wed, 9 Mar 2022 15:11:56 +0100 Subject: [PATCH 3/4] add more tests --- test/lint.spec.ts | 23 ++++++++++++ test/utils.spec.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/test/lint.spec.ts b/test/lint.spec.ts index c724c7a8a..8e4d657fc 100644 --- a/test/lint.spec.ts +++ b/test/lint.spec.ts @@ -1,4 +1,5 @@ import { lint, validate } from '../src/lint'; +import { hasErrorDiagnostic, hasWarningDiagnostic } from '../src/utils'; describe('lint() & validate()', function() { describe('lint()', function() { @@ -17,6 +18,28 @@ describe('lint() & validate()', function() { } expect(diagnostics.length > 0).toEqual(true); + expect(hasErrorDiagnostic(diagnostics)).toEqual(true); + expect(hasWarningDiagnostic(diagnostics)).toEqual(true); + }); + + it('should lint valid document', async function() { + const document = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: {} + } + + const diagnostics = await lint(document); + if (!diagnostics) { + return; + } + + expect(diagnostics.length > 0).toEqual(true); + expect(hasErrorDiagnostic(diagnostics)).toEqual(false); + expect(hasWarningDiagnostic(diagnostics)).toEqual(true); }); }); diff --git a/test/utils.spec.ts b/test/utils.spec.ts index c2077a2d8..c87c733e6 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,6 +1,15 @@ +import { ISpectralDiagnostic } from '@stoplight/spectral-core'; +import { DiagnosticSeverity } from '@stoplight/types'; import { xParserSpecParsed, xParserSpecStringified } from '../src/constants'; import { AsyncAPIDocument, BaseModel } from '../src/models'; -import { toAsyncAPIDocument, isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from '../src/utils'; +import { + toAsyncAPIDocument, + isAsyncAPIDocument, + isParsedDocument, + isStringifiedDocument, + hasErrorDiagnostic, + hasWarningDiagnostic, +} from '../src/utils'; describe('utils', function() { describe('toAsyncAPIDocument()', function() { @@ -122,4 +131,84 @@ describe('utils', function() { expect(isStringifiedDocument({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toEqual(true); }); }); + + describe('hasErrorDiagnostic()', function() { + const simpleDiagnostic: ISpectralDiagnostic = { + code: 'test-code', + message: 'test-message', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + severity: DiagnosticSeverity.Error, + path: [], + } + + it('should return true when diagnostics have at least one error', function() { + const diagnostics: ISpectralDiagnostic[] = [ + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Error, + }, + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Warning, + } + ] + + expect(hasErrorDiagnostic(diagnostics)).toEqual(true); + }); + + it('should return false when diagnostics have no error', function() { + const diagnostics: ISpectralDiagnostic[] = [ + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Warning, + }, + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Warning, + } + ] + + expect(hasErrorDiagnostic(diagnostics)).toEqual(false); + }); + }); + + describe('hasErrorDiagnostic()', function() { + const simpleDiagnostic: ISpectralDiagnostic = { + code: 'test-code', + message: 'test-message', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + severity: DiagnosticSeverity.Error, + path: [], + } + + it('should return true when diagnostics have at least one warning', function() { + const diagnostics: ISpectralDiagnostic[] = [ + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Error, + }, + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Warning, + } + ] + + expect(hasWarningDiagnostic(diagnostics)).toEqual(true); + }); + + it('should return false when diagnostics have no warning', function() { + const diagnostics: ISpectralDiagnostic[] = [ + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Error, + }, + { + ...simpleDiagnostic, + severity: DiagnosticSeverity.Error, + } + ] + + expect(hasWarningDiagnostic(diagnostics)).toEqual(false); + }); + }); }); From 63a93b695fc33c3deae2228c7f39e9a7bba4ae2f Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Wed, 9 Mar 2022 15:31:54 +0100 Subject: [PATCH 4/4] improve typing --- src/index.ts | 7 +++++++ src/lint.ts | 7 +++---- src/parse.ts | 2 +- src/types.ts | 5 ++++- test/utils.spec.ts | 15 ++++++++------- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 815c4aa3a..9809b29cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,10 @@ export * from './models'; +export { lint, validate } from './lint'; +export { parse } from './parse'; export { stringify, unstringify } from './stringify'; + +export type { LintOptions, ValidateOptions, ValidateOutput } from './lint'; +export type { StringifyOptions } from './stringify'; +export type { ParseOptions } from './parse'; +export type { ParserInput, ParserOutput, Diagnostic } from './types'; diff --git a/src/lint.ts b/src/lint.ts index df7b4ecb1..f108ea5ae 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -1,6 +1,5 @@ import { IConstructorOpts, - ISpectralDiagnostic, IRunOpts, Spectral, Ruleset, @@ -11,7 +10,7 @@ import { asyncapi as aasRuleset } from "@stoplight/spectral-rulesets"; import { toAsyncAPIDocument, normalizeInput, hasWarningDiagnostic, hasErrorDiagnostic } from "./utils"; import type { AsyncAPIDocument } from "./models/asyncapi"; -import type { ParserInput } from "./types"; +import type { ParserInput, Diagnostic } from "./types"; export interface LintOptions extends IConstructorOpts, IRunOpts { ruleset?: RulesetDefinition | Ruleset; @@ -25,10 +24,10 @@ export interface ValidateOptions extends LintOptions { export interface ValidateOutput { validated: unknown; - diagnostics: ISpectralDiagnostic[]; + diagnostics: Diagnostic[]; } -export async function lint(asyncapi: ParserInput, options?: LintOptions): Promise { +export async function lint(asyncapi: ParserInput, options?: LintOptions): Promise { if (toAsyncAPIDocument(asyncapi)) { return; } diff --git a/src/parse.ts b/src/parse.ts index 4da038d05..9fe9a1253 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,5 +1,5 @@ import { AsyncAPIDocument } from "./models"; -import { hasErrorDiagnostic, normalizeInput, toAsyncAPIDocument } from "./utils"; +import { normalizeInput, toAsyncAPIDocument } from "./utils"; import { validate } from "./lint"; import type { ParserInput, ParserOutput } from './types'; diff --git a/src/types.ts b/src/types.ts index b253433d0..73bf56bdd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,8 +3,11 @@ import type { AsyncAPIDocument } from './models/asyncapi'; export type MaybeAsyncAPI = { asyncapi: unknown } & Record; export type ParserInput = string | MaybeAsyncAPI | AsyncAPIDocument; + +export type Diagnostic = ISpectralDiagnostic; + export interface ParserOutput { source: ParserInput; parsed: AsyncAPIDocument | undefined; - diagnostics: ISpectralDiagnostic[]; + diagnostics: Diagnostic[]; } diff --git a/test/utils.spec.ts b/test/utils.spec.ts index c87c733e6..136ec2044 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,4 +1,3 @@ -import { ISpectralDiagnostic } from '@stoplight/spectral-core'; import { DiagnosticSeverity } from '@stoplight/types'; import { xParserSpecParsed, xParserSpecStringified } from '../src/constants'; import { AsyncAPIDocument, BaseModel } from '../src/models'; @@ -11,6 +10,8 @@ import { hasWarningDiagnostic, } from '../src/utils'; +import type { Diagnostic } from '../src/types'; + describe('utils', function() { describe('toAsyncAPIDocument()', function() { it('normal object should not return AsyncAPIDocument instance', function() { @@ -133,7 +134,7 @@ describe('utils', function() { }); describe('hasErrorDiagnostic()', function() { - const simpleDiagnostic: ISpectralDiagnostic = { + const simpleDiagnostic: Diagnostic = { code: 'test-code', message: 'test-message', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, @@ -142,7 +143,7 @@ describe('utils', function() { } it('should return true when diagnostics have at least one error', function() { - const diagnostics: ISpectralDiagnostic[] = [ + const diagnostics: Diagnostic[] = [ { ...simpleDiagnostic, severity: DiagnosticSeverity.Error, @@ -157,7 +158,7 @@ describe('utils', function() { }); it('should return false when diagnostics have no error', function() { - const diagnostics: ISpectralDiagnostic[] = [ + const diagnostics: Diagnostic[] = [ { ...simpleDiagnostic, severity: DiagnosticSeverity.Warning, @@ -173,7 +174,7 @@ describe('utils', function() { }); describe('hasErrorDiagnostic()', function() { - const simpleDiagnostic: ISpectralDiagnostic = { + const simpleDiagnostic: Diagnostic = { code: 'test-code', message: 'test-message', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, @@ -182,7 +183,7 @@ describe('utils', function() { } it('should return true when diagnostics have at least one warning', function() { - const diagnostics: ISpectralDiagnostic[] = [ + const diagnostics: Diagnostic[] = [ { ...simpleDiagnostic, severity: DiagnosticSeverity.Error, @@ -197,7 +198,7 @@ describe('utils', function() { }); it('should return false when diagnostics have no warning', function() { - const diagnostics: ISpectralDiagnostic[] = [ + const diagnostics: Diagnostic[] = [ { ...simpleDiagnostic, severity: DiagnosticSeverity.Error,