diff --git a/package.json b/package.json index 7905f9f..1bf97ed 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "homepage": "https://github.com/asyncapi/diff#readme", "dependencies": { "fast-json-patch": "^3.0.0-1", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "json2md": "^1.12.0" }, "devDependencies": { "@asyncapi/parser": "^1.15.0", @@ -52,6 +53,7 @@ "@semantic-release/release-notes-generator": "^9.0.1", "@types/jest": "^26.0.23", "@types/js-yaml": "^4.0.5", + "@types/json2md": "^1.5.1", "@types/node": "^15.12.1", "@typescript-eslint/eslint-plugin": "^4.26.0", "@typescript-eslint/parser": "^4.26.0", diff --git a/src/asyncapidiff.ts b/src/asyncapidiff.ts index 89833d8..fe4d8e6 100644 --- a/src/asyncapidiff.ts +++ b/src/asyncapidiff.ts @@ -7,21 +7,24 @@ import { } from './types'; import { breaking, nonBreaking, unclassified } from './constants'; import toProperFormat from './helpers/output/toProperFormat'; +import {MarkdownSubtype} from './types'; /** * Implements methods to deal with diff output. * @class * - * @returns {AsyncAPIDiff} AsynAPIDiff + * @returns {AsyncAPIDiff} AsyncAPIDiff */ export default class AsyncAPIDiff { private output: JSONOutput; private outputType: OutputType; + private markdownSubtype: MarkdownSubtype; constructor(output: string, options: AsyncAPIDiffOptions) { // output is a stringified JSON this.output = JSON.parse(output); this.outputType = options.outputType; + this.markdownSubtype = options.markdownSubtype || 'json'; } /** @@ -32,7 +35,7 @@ export default class AsyncAPIDiff { (diff) => diff.type === breaking ); - return toProperFormat(breakingChanges, this.outputType); + return toProperFormat({data: breakingChanges, outputType: this.outputType, markdownSubtype: this.markdownSubtype}); } /** @@ -43,7 +46,7 @@ export default class AsyncAPIDiff { (diff) => diff.type === nonBreaking ); - return toProperFormat(nonBreakingChanges, this.outputType); + return toProperFormat({data: nonBreakingChanges, outputType: this.outputType, markdownSubtype: this.markdownSubtype}); } /** @@ -54,13 +57,13 @@ export default class AsyncAPIDiff { (diff) => diff.type === unclassified ); - return toProperFormat(unclassifiedChanges, this.outputType); + return toProperFormat({data: unclassifiedChanges, outputType: this.outputType, markdownSubtype: this.markdownSubtype}); } /** * @returns {Output} The full output */ getOutput(): Output { - return toProperFormat(this.output, this.outputType); + return toProperFormat({data: this.output, outputType: this.outputType, markdownSubtype: this.markdownSubtype}); } } diff --git a/src/helpers/MarkdownHelpers.ts b/src/helpers/MarkdownHelpers.ts new file mode 100644 index 0000000..00aaa7b --- /dev/null +++ b/src/helpers/MarkdownHelpers.ts @@ -0,0 +1,76 @@ +import {ChangeMarkdownGenerationConfig, MarkdownDropdownGenerationConfig} from '../types'; +import convertToYAML from './output/convertToYAML'; + +/** + * Groups an array of changes by their 'type' property + * @param object The input object + * @returns The grouped object + */ +export function groupChangesByType(object: any): { string: [{ path: string, any: any }] } { + return object.reduce((objectsByKeyValue: { [x: string]: any; }, obj: { [x: string]: any; }) => { + const value = obj['type']; + // eslint-disable-next-line security/detect-object-injection + objectsByKeyValue[value] = (objectsByKeyValue[value] || []).concat(obj); + return objectsByKeyValue; + }, {}); +} + +/** + * Sets the first letter of a string to uppercase + * @param s The input string + * @returns The string with the first letter capitalised + */ +export function capitaliseFirstLetter(s: string): string { + return s[0].toUpperCase() + s.slice(1); +} + +/** + * Generates the Markdown list items for a single change + * @param: config Configuration options for the generated markdown + * @param config.change The object describing the change + * @param config.markdownSubtype the format to display the dropdown data in + * @returns The Markdown list describing the change + */ +export function generateMarkdownForChange(config: ChangeMarkdownGenerationConfig): any { + const toAppend: any[] = [`**Path**: \`${config.change.path}\``]; + const listItem = {ul: [] as any[]}; + + for (const [label, value] of Object.entries(config.change)) { + if (label !== 'path' && label !== 'type') { + // if the value is an object, display within a dropdown + if (typeof value === 'object') { + listItem.ul.push(convertDataToDropdown({ + label: capitaliseFirstLetter(label), + data: value, + markdownSubtype: config.markdownSubtype + })); + } else { + listItem.ul.push(`**${capitaliseFirstLetter(label)}**: ${value}`); + } + } + } + + toAppend.push(listItem); + return toAppend; +} + +/** + * Converts the label and data to a markdown dropdown + * @param config: Configuration options for the generated dropdown + * @param config.label The summary / title + * @param config.data The data to hide in dropdown + * @param config.markdownSubtype the format to display the dropdown data in + * @returns Markdown string with the label as a summary and the data formatted as JSON code + */ +export function convertDataToDropdown(config: MarkdownDropdownGenerationConfig): string { + const displayData = config.markdownSubtype === 'json' ? JSON.stringify(config.data, null, 2) : convertToYAML(config.data); + + return `
+ ${config.label} + +\`\`\`${config.markdownSubtype} +${displayData} +\`\`\` +
+`; +} diff --git a/src/helpers/output/convertToMarkdown.ts b/src/helpers/output/convertToMarkdown.ts new file mode 100644 index 0000000..8cb43f4 --- /dev/null +++ b/src/helpers/output/convertToMarkdown.ts @@ -0,0 +1,36 @@ +import json2md from 'json2md'; +import { + capitaliseFirstLetter, + generateMarkdownForChange, + groupChangesByType +} from '../MarkdownHelpers'; +import {MarkdownSubtype} from '../../types'; + +/** + * Converts the diff to Markdown + * @param object The input object + * @param markdownSubtype the format to display the dropdown data in + * @returns Markdown output + */ +export default function convertToMarkdown(object: any, markdownSubtype: MarkdownSubtype): string { + if (Object.prototype.hasOwnProperty.call(object, 'changes')) { + object = object.changes; + } + + const changeTypeGroups = groupChangesByType(object); + + const markdownStructure = []; + + for (const [changeType, changes] of Object.entries(changeTypeGroups)) { + markdownStructure.push({h2: capitaliseFirstLetter(changeType)}); + const outerList = {ul: [] as any[]}; + + for (const change of changes) { + outerList.ul.push(...generateMarkdownForChange({change, markdownSubtype})); + } + + markdownStructure.push(outerList); + } + + return json2md(markdownStructure); +} diff --git a/src/helpers/output/toProperFormat.ts b/src/helpers/output/toProperFormat.ts index dff4bbf..017ff29 100644 --- a/src/helpers/output/toProperFormat.ts +++ b/src/helpers/output/toProperFormat.ts @@ -1,18 +1,21 @@ -import { OutputType } from '../../types'; +import {FormatterConfig} from '../../types'; import convertToYAML from './convertToYAML'; +import convertToMarkdown from './convertToMarkdown'; /** * Converts diff data to the specified format - * @param data The diff data + * @param config: Configuration options for the target format + * @param config.data The diff data + * @param config.outputType The intended type of the output + * @param config.markdownSubtype the format to display the dropdown data in * @returns formatted diff output */ -export default function toProperFormat( - data: T, - outputType: OutputType -): T | string { - if (outputType === 'yaml' || outputType === 'yml') { - return convertToYAML(data); +export default function toProperFormat(config: FormatterConfig): T | string { + if (config.outputType === 'yaml' || config.outputType === 'yml') { + return convertToYAML(config.data); + } else if (config.outputType === 'markdown' || config.outputType === 'md') { + return convertToMarkdown(config.data, config.markdownSubtype); } - return data; + return config.data; } diff --git a/src/main.ts b/src/main.ts index 89edfc5..09082b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,5 +44,6 @@ export function diff( const output = categorizeChanges(standard as OverrideStandard, diffOutput); return new AsyncAPIDiff(JSON.stringify(output), { outputType: config.outputType || 'json', + markdownSubtype: config.markdownSubtype || 'json' }); } diff --git a/src/types.ts b/src/types.ts index 873754e..9bc778b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ -import { ReplaceOperation, AddOperation } from 'fast-json-patch'; +import {ReplaceOperation, AddOperation} from 'fast-json-patch'; -import { standard } from './standard'; -import { breaking, nonBreaking, unclassified } from './constants'; +import {standard} from './standard'; +import {breaking, nonBreaking, unclassified} from './constants'; export type ActionType = 'add' | 'remove' | 'edit'; @@ -46,13 +46,34 @@ export interface OverrideObject { export type OverrideStandard = StandardType & OverrideObject; -export type OutputType = 'json' | 'yaml' | 'yml'; +export type OutputType = 'json' | 'yaml' | 'yml' | 'markdown' | 'md'; + +export type MarkdownSubtype = 'json' | 'yaml' | 'yml'; + +export interface FormatterConfig { + data: T, + outputType: OutputType, + markdownSubtype: MarkdownSubtype +} + +export interface ChangeMarkdownGenerationConfig { + change: { path: string, any: any }, + markdownSubtype: MarkdownSubtype +} + +export interface MarkdownDropdownGenerationConfig { + label: string, + data: { string: any }, + markdownSubtype: MarkdownSubtype +} export interface AsyncAPIDiffOptions { outputType: OutputType; + markdownSubtype?: MarkdownSubtype; } export interface Config { override?: OverrideObject; outputType?: OutputType; + markdownSubtype?: MarkdownSubtype; } diff --git a/test/asycnapidiff.spec.ts b/test/asycnapidiff.spec.ts index a3b9027..9fc4c1f 100644 --- a/test/asycnapidiff.spec.ts +++ b/test/asycnapidiff.spec.ts @@ -9,12 +9,22 @@ import { YAMLNonbreakingChanges, YAMLOutputDiff, YAMLUnclassifiedChanges, + MarkdownBreakingChanges, + MarkdownNonbreakingChanges, + MarkdownOutputDiff, + MarkdownUnclassifiedChanges, + MarkdownJSONSubtypeChanges, + MarkdownYAMLSubtypeChanges, } from './fixtures/asyncapidiff.fixtures'; +import { + diffOutput +} from './fixtures/main.fixtures'; + describe('AsyncAPIDiff wrapper', () => { test('checks the instance', () => { expect( - new AsyncAPIDiff(JSON.stringify(inputDiff), { outputType: 'json' }) + new AsyncAPIDiff(JSON.stringify(inputDiff), {outputType: 'json'}) ).toBeInstanceOf(AsyncAPIDiff); }); @@ -73,4 +83,47 @@ describe('AsyncAPIDiff wrapper', () => { }); expect(diff.unclassified()).toEqual(YAMLUnclassifiedChanges); }); + + test('Markdown: returns the original full output', () => { + const diff = new AsyncAPIDiff(JSON.stringify(inputDiff), { + outputType: 'markdown', + }); + expect(diff.getOutput()).toEqual(MarkdownOutputDiff); + }); + + test('Markdown: returns breaking changes', () => { + const diff = new AsyncAPIDiff(JSON.stringify(inputDiff), { + outputType: 'markdown', + }); + expect(diff.breaking()).toEqual(MarkdownBreakingChanges); + }); + + test('Markdown: returns non-breaking changes', () => { + const diff = new AsyncAPIDiff(JSON.stringify(inputDiff), { + outputType: 'markdown', + }); + expect(diff.nonBreaking()).toEqual(MarkdownNonbreakingChanges); + }); + + test('Markdown: returns unclassified changes', () => { + const diff = new AsyncAPIDiff(JSON.stringify(inputDiff), { + outputType: 'markdown', + }); + expect(diff.unclassified()).toEqual(MarkdownUnclassifiedChanges); + }); + + test('Markdown: returns changes using subtype JSON as the default', () => { + const diff = new AsyncAPIDiff(JSON.stringify(diffOutput), { + outputType: 'markdown', + }); + expect(diff.getOutput()).toEqual(MarkdownJSONSubtypeChanges); + }); + + test('Markdown: returns changes using subtype YAML', () => { + const diff = new AsyncAPIDiff(JSON.stringify(diffOutput), { + outputType: 'markdown', + markdownSubtype: 'yaml', + }); + expect(diff.getOutput()).toEqual(MarkdownYAMLSubtypeChanges); + }); }); diff --git a/test/fixtures/asyncapidiff.fixtures.ts b/test/fixtures/asyncapidiff.fixtures.ts index a95ed7a..56fe8aa 100644 --- a/test/fixtures/asyncapidiff.fixtures.ts +++ b/test/fixtures/asyncapidiff.fixtures.ts @@ -56,3 +56,227 @@ export const YAMLNonbreakingChanges = `- type: non-breaking export const YAMLUnclassifiedChanges = `- type: unclassified path: /info `; + +export const MarkdownOutputDiff = `## Breaking + + + - **Path**: \`/servers\` + + +## Non-breaking + + + - **Path**: \`/channels\` + + +## Unclassified + + + - **Path**: \`/info\` + +`; + +export const MarkdownBreakingChanges = `## Breaking + + + - **Path**: \`/servers\` + +`; + +export const MarkdownNonbreakingChanges = `## Non-breaking + + + - **Path**: \`/channels\` + +`; + +export const MarkdownUnclassifiedChanges = `## Unclassified + + + - **Path**: \`/info\` + +`; + +export const MarkdownJSONSubtypeChanges = `## Breaking + + + - **Path**: \`/channels/mychannel\` + - **Action**: remove + -
+ Before + + \`\`\`json + { + "publish": { + "message": { + "headers": { + "properties": { + "some-common-header": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "type": "object", + "x-parser-schema-id": "" + }, + "schemaFormat": "application/vnd.aai.asyncapi;version=2.0.0", + "x-parser-message-name": "channelMessage", + "x-parser-message-parsed": true, + "x-parser-original-traits": [ + { + "headers": { + "properties": { + "some-common-header": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "type": "object", + "x-parser-schema-id": "" + }, + "x-some-extension": "some extension" + } + ], + "x-some-extension": "some extension" + } + } + } + \`\`\` +
+ + + - **Path**: \`/info/version\` + - **Action**: edit + - **After**: 1.1.0 + - **Before**: 1.0.0 + + +## Non-breaking + + + - **Path**: \`/channels/anotherChannel\` + - **Action**: add + -
+ After + + \`\`\`json + { + "publish": { + "message": { + "headers": { + "properties": { + "some-common-header": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "type": "object", + "x-parser-schema-id": "" + }, + "schemaFormat": "application/vnd.aai.asyncapi;version=2.0.0", + "x-parser-message-name": "channelMessage", + "x-parser-message-parsed": true, + "x-parser-original-traits": [ + { + "headers": { + "properties": { + "some-common-header": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "type": "object", + "x-parser-schema-id": "" + }, + "x-some-extension": "some extension" + } + ], + "x-some-extension": "some extension" + } + } + } + \`\`\` +
+ + +`; + +export const MarkdownYAMLSubtypeChanges = `## Breaking + + + - **Path**: \`/channels/mychannel\` + - **Action**: remove + -
+ Before + + \`\`\`yaml + publish: + message: + headers: + properties: + some-common-header: + type: string + x-parser-schema-id: + type: object + x-parser-schema-id: + schemaFormat: application/vnd.aai.asyncapi;version=2.0.0 + x-parser-message-name: channelMessage + x-parser-message-parsed: true + x-parser-original-traits: + - headers: + properties: + some-common-header: + type: string + x-parser-schema-id: + type: object + x-parser-schema-id: + x-some-extension: some extension + x-some-extension: some extension + + \`\`\` +
+ + + - **Path**: \`/info/version\` + - **Action**: edit + - **After**: 1.1.0 + - **Before**: 1.0.0 + + +## Non-breaking + + + - **Path**: \`/channels/anotherChannel\` + - **Action**: add + -
+ After + + \`\`\`yaml + publish: + message: + headers: + properties: + some-common-header: + type: string + x-parser-schema-id: + type: object + x-parser-schema-id: + schemaFormat: application/vnd.aai.asyncapi;version=2.0.0 + x-parser-message-name: channelMessage + x-parser-message-parsed: true + x-parser-original-traits: + - headers: + properties: + some-common-header: + type: string + x-parser-schema-id: + type: object + x-parser-schema-id: + x-some-extension: some extension + x-some-extension: some extension + + \`\`\` +
+ + +`; diff --git a/test/fixtures/main.fixtures.ts b/test/fixtures/main.fixtures.ts index 355c1ee..289daf5 100644 --- a/test/fixtures/main.fixtures.ts +++ b/test/fixtures/main.fixtures.ts @@ -332,3 +332,13 @@ export const YAMLArrayChanges = `changes: before: 2 type: breaking `; + +export const MarkdownArrayChanges = `## Breaking + + + - **Path**: \`/servers/google/variables/port/enum/1\` + - **Action**: remove + - **IsArrayIndex**: true + - **Before**: 2 + +`; diff --git a/test/main.spec.ts b/test/main.spec.ts index 419db0f..b481550 100644 --- a/test/main.spec.ts +++ b/test/main.spec.ts @@ -1,10 +1,10 @@ -import { parse } from '@asyncapi/parser'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; +import {parse} from '@asyncapi/parser'; +import {readFileSync} from 'fs'; +import {resolve} from 'path'; import AsyncAPIDiff from '../src/asyncapidiff'; -import { diff } from '../src/main'; -import { OverrideObject } from '../src/types'; +import {diff} from '../src'; +import {OverrideObject} from '../src'; import { diffOutput, @@ -16,6 +16,7 @@ import { specDocument2, arrayChanges, YAMLArrayChanges, + MarkdownArrayChanges } from './fixtures/main.fixtures'; describe('main function', () => { @@ -68,7 +69,12 @@ describe('main function', () => { }); test('YAML: checks output with array changes', () => { - const output = diff(specDocument1, specDocument2, { outputType: 'yaml' }); + const output = diff(specDocument1, specDocument2, {outputType: 'yaml'}); expect(output.getOutput()).toEqual(YAMLArrayChanges); }); + + test('Markdown: checks output with array changes', () => { + const output = diff(specDocument1, specDocument2, {outputType: 'markdown'}); + expect(output.getOutput()).toEqual(MarkdownArrayChanges); + }); });