/* eslint-disable no-console */ import { diff, DiffOutputItem } from '@asyncapi/diff'; import Parser, { AsyncAPIDocumentInterface, ChannelInterface, fromFile, SchemaInterface, } from '@asyncapi/parser'; import { green, red, white } from 'chalk'; import * as jsonDiff from 'diff'; import * as fs from 'fs'; import * as jsonSchemaDiff from 'json-schema-diff'; import * as path from 'path'; import { exitCode } from '../../common'; import { override } from './asyncapi-override'; import { GitCheckoutTmp } from './git-checkout-tmp'; import { MessageDiffOptions } from './message-diff-options'; /** * Diff results. Each file (asyncapi document or payload) is classified to one of 'skipped', 'same', 'changed' or 'breaking'. */ interface Results { skipped: number; same: number; changed: number; breaking: number; } /** * Finds AsyncAPI documents found in git, and compares them with the current file system. * The following changes are considered breaking: * - Removed / renamed asyncapi document * - Breaking changes in any asyncapi document * - Breaking changes in any message payload * Message payloads are referenced through asyncapi channel definitions. It is safe to rename payload files if * file references in the asyncapi document are updated accordingly. * * @param options (object): * schemaRoot Path to directory to scan. All files required to parse the schema must be inside this dir * & subdirectories. This includes JSON Schema references. * filePattern Regular expression that matches suitable input files. * excludePattern Regular expression that matches suitable input files. * branch Git branch to compare to. * verbose Verbose output includes full diff for all files, not only where breaking changes were found. */ export class MessageDiff { private readonly schemaRoot: string; private readonly filePattern: RegExp; private readonly excludePattern: RegExp | undefined; private readonly branch: string; private readonly verbose: boolean; public readonly results = { skipped: 0, same: 0, changed: 0, breaking: 0, }; constructor(options: MessageDiffOptions) { this.schemaRoot = path.resolve(options.inputDir); this.branch = options.branch; this.verbose = options.verbose; try { this.filePattern = new RegExp(options.filePattern); } catch (_error) { console.error( `${options.filePattern} is not a valid regular expression.`, ); process.exit(2); } try { this.excludePattern = options.excludePattern === undefined ? undefined : new RegExp(options.excludePattern); } catch (_error) { console.error( `${options.excludePattern} is not a valid regular expression.`, ); process.exit(2); } } public async run(): Promise { // checkout branch to temp dir const gitTmp = new GitCheckoutTmp(); gitTmp.checkout(this.schemaRoot, this.branch); const rootDir1 = gitTmp.tmpDir; const rootDir2 = gitTmp.gitRoot; if (rootDir1 === undefined || rootDir2 === undefined) { throw `Git checkout failed`; } // scan the checked out files try { const relPath = path.relative(rootDir2, this.schemaRoot); await this.walk( path.join(rootDir1, relPath), path.join(rootDir2, relPath), ); } finally { // remove tmp files gitTmp.cleanUp(); } // check results const totalFiles = this.results.skipped + this.results.same + this.results.changed + this.results.breaking; if (totalFiles === 0) { throw `No files found matching the given arguments`; } const color = this.results.breaking > 0 ? red : this.results.skipped > 0 ? white : green; console.log( color( `${totalFiles} file${totalFiles === 1 ? ' was' : 's were'} checked. ${ this.results.skipped } ${this.results.skipped === 1 ? 'was' : 'were'} skipped. ${ this.results.same } ${this.results.same === 1 ? 'is' : 'are'} the same. ${ this.results.changed } ${ this.results.changed === 1 ? 'has' : 'have' } non-breaking changes. ${this.results.breaking} ${ this.results.breaking === 1 ? 'has' : 'have' } breaking changes.`, ), ); if (this.results.breaking > 0) { process.exit(exitCode); } } /** * Recursively walks a directory comparing matching files to another directory. * @param dir1 - Root dir to scan for matching files. * @param dir2 - Root dir for comparision. */ public async walk(dir1: string, dir2: string): Promise { const items = await fs.promises.readdir(dir1); const fullPathItems = items.map((i) => path.join(dir1, i)); const dirs = fullPathItems.filter((i) => fs.statSync(i).isDirectory()); const files = fullPathItems.filter( (i) => fs.statSync(i).isFile() && this.filePattern.test(i) && (!this.excludePattern || !this.excludePattern.test(i)), ); if ([...files, ...dirs].length === 0) { return; } for (const file1 of files) { const file2 = path.join(dir2, path.relative(dir1, file1)); await this.asyncApiDiff(file1, file2); } for (const subDir1 of dirs) { const subDir2 = path.join(dir2, path.relative(dir1, subDir1)); await this.walk(subDir1, subDir2); } } /** * Full comparison of asyncapi documents. Each channel payload is also compared. */ private async asyncApiDiff(file1: string, file2: string): Promise { console.log( `Comparing asyncapi document '${file2}'. Changes from branch '${this.branch}':`, ); let document1: AsyncAPIDocumentInterface; let document2: AsyncAPIDocumentInterface; // load asyncapi document from git try { const parser = new Parser(); const { document, diagnostics } = await fromFile(parser, file1).parse(); if (!document) { throw `Error parsing ${file1}: ${diagnostics}`; } document1 = document; } catch (e) { throw `Error reading ${file1}: ${e}`; } // load local asyncapi document try { if (!fs.existsSync(file2)) { console.log( red( `❌ File not found. This document may have been removed. Assuming this is a breaking change.`, ), ); this.results.breaking++; return; } const parser = new Parser(); const { document, diagnostics } = await fromFile(parser, file2).parse(); if (!document) { throw `Error parsing ${file2}: ${diagnostics}`; } document2 = document; } catch (e) { throw `Error reading ${file2}: ${e}`; } // diff document const resultKey = await this.documentDiff(document1, document2); this.results[resultKey]++; const channels1 = document1.channels(); const channels2 = document2.channels(); // diff payloads for channels which exist in both documents // removed / renamed channels are already marked as breaking for (const channel1 of channels1) { if (channels2.has(channel1.id()) === false) { continue; } console.log( `Comparing payload for channel ${channel1.id()} in document ${file2}. Changes from branch ${ this.branch }`, ); const channel2 = channels2.get(channel1.id()); const resultKey = await this.payloadDiff(channel1, channel2); this.results[resultKey]++; } } /** * Compare asyncapi documents using AsyncAPI Diff: https://github.com/asyncapi/diff * Note: non standard rules are defined in file: asyncapi-override.ts */ public async documentDiff( document1: AsyncAPIDocumentInterface, document2: AsyncAPIDocumentInterface, ): Promise { // generate diff // overrides are applied through options here: const output = diff(document1.json(), document2.json(), { override }); const breaking = output.breaking(); const nonBreaking = output.nonBreaking(); const unclassified = output.unclassified(); // log full results if breaking changes were found or verbose is set const itemMsg = (change: string | DiffOutputItem): string => typeof change === 'string' ? change : `${change.action} ${change.path}`; if (this.verbose) { for (const item of unclassified) { console.log(` ? ${itemMsg(item)}`); } } if (breaking.length > 0 || this.verbose) { for (const item of nonBreaking) { console.log(` ✔ ${itemMsg(item)}`); } } for (const item of breaking) { console.log(` ✖ ${itemMsg(item)}`); } // log summary const color = breaking.length > 0 ? red : green; console.log( color( `${breaking.length > 0 ? '❌ ' : ''}${ nonBreaking.length } non-breaking change${nonBreaking.length === 1 ? '' : 's'}, ${ breaking.length } breaking change${ breaking.length === 1 ? '' : 's' } in asyncapi document`, ), ); return breaking.length > 0 ? 'breaking' : nonBreaking.length > 0 ? 'changed' : 'same'; } /** * Compare payload schemas using JsonSchema Diff: https://www.npmjs.com/package/json-schema-diff * NOTE: It might be possible to define rules for asyncapi-diff to compare properties in message schemas but * it would not have the ability to properly evaluate breaking changes as json-schema-diff does. * e.g. adding an existing property to the list of required properties is a breaking change. * * Only schema version http://json-schema.org/draft-07/schema# is supported. * If any other version is used (in local file or git) then the comparison result will be 'skipped'. */ public async payloadDiff( channel1: ChannelInterface | undefined, channel2: ChannelInterface | undefined, ): Promise { const getChannelPayload = ( channel: ChannelInterface | undefined, ): SchemaInterface | undefined => { if (channel === undefined) { return undefined; } const messages = channel.messages(); if (messages.length > 1) { throw new Error( `Channel ${channel.id()} has more than one message. This is currently not supported.`, ); } return messages[0].payload(); }; const schema1 = getChannelPayload(channel1); const schema2 = getChannelPayload(channel2); if (schema1 === undefined) { throw new Error(`Could not find payload schema in ${this.branch}`); } if (schema2 === undefined) { throw new Error(`Could not find payload schema`); } let result: jsonSchemaDiff.DiffResult; try { result = await jsonSchemaDiff.diffSchemas({ sourceSchema: schema1.json(), destinationSchema: schema2.json(), }); } catch (e) { // Skip if unsupported schema version is used by either payload if ( e instanceof Error && e.message.startsWith('no schema with key or ref') ) { console.log( `WARNING: Unsupported JsonSchema version. This payload will be skipped. Both versions of the file must use $schema: "http://json-schema.org/draft-07/schema#".`, ); return 'skipped'; } throw e; } if (result.removalsFound || this.verbose) { // json-schema-diff does not give a useful report of what are the breaking changes. See: // https://bitbucket.org/atlassian/json-schema-diff/issues/6/is-it-supposed-to-report-only-what-has // So we display a line-by-line diff of the schemas..its not targeted to what is breaking, but its more useful // than just logging both versions in full which is the observed behavior of json-schema-diff. const diffResult = jsonDiff.diffJson(schema1, schema2); diffResult.forEach((r) => { for (const line of r.value.split(/\r?\n/)) { // strip blank lines and annotations added by asyncapi parser if (!line || line.match(/^\s+"x-parser-schema/)) { continue; } console.log(`${r.removed ? ' -' : r.added ? ' +' : ' '} ${line}`); } }); } const color = result.removalsFound ? red : green; console.log( color( result.removalsFound ? '❌ Breaking changes in payload' : result.additionsFound ? 'No breaking changes in payload' : 'No changes in payload', ), ); return result.removalsFound ? 'breaking' : result.additionsFound ? 'changed' : 'same'; } }