From f17d52d11859dd6c05376f027e9a5de0c0e7f63b Mon Sep 17 00:00:00 2001 From: catosaurusrex2003 Date: Sat, 19 Oct 2024 13:48:38 +0530 Subject: [PATCH 1/4] added a format command its helpers functions. --- .prettierrc | 3 + package-lock.json | 10 ++- src/commands/format.ts | 121 ++++++++++++++++++++++++++ src/core/errors/specification-file.ts | 7 ++ src/core/flags/format.flags.ts | 22 +++++ src/core/models/SpecificationFile.ts | 37 ++++++++ 6 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 .prettierrc create mode 100644 src/commands/format.ts create mode 100644 src/core/flags/format.flags.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000000..dc2fb828f03 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2dc99e0ba33..6502b759600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8951,7 +8951,8 @@ "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==" + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true }, "node_modules/are-we-there-yet": { "version": "3.0.1", @@ -9890,6 +9891,7 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, "funding": [ { "type": "github", @@ -31343,7 +31345,8 @@ "archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==" + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true }, "are-we-there-yet": { "version": "3.0.1", @@ -32038,7 +32041,8 @@ "ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==" + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true }, "classcat": { "version": "5.0.5", diff --git a/src/commands/format.ts b/src/commands/format.ts new file mode 100644 index 00000000000..d68e12acba5 --- /dev/null +++ b/src/commands/format.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { promises as fPromises } from 'fs'; +import { Args } from '@oclif/core'; +import Command from '../core/base'; + +import { + convertToJSON, + convertToYaml, + load, + retrieveFileFormat, +} from '../core/models/SpecificationFile'; +import { SpecificationWrongFileFormat } from '../core/errors/specification-file'; +import { cyan, green } from 'picocolors'; +import { + convertFormatFlags, + fileFormat, +} from '../core/flags/format.flags'; + +export default class Convert extends Command { + static specFile: any; + static metricsMetadata: any = {}; + static description = + 'Convert asyncapi documents from any format to yaml, yml or JSON'; + + static flags = convertFormatFlags(); + + static args = { + 'spec-file': Args.string({ + description: 'spec path, url, or context-name', + required: false, + }), + }; + + async run() { + const { args, flags } = await this.parse(Convert); + const filePath = args['spec-file']; + const outputFileFormat = flags['format'] as fileFormat; + let convertedFile; + try { + this.specFile = await load(filePath); + // eslint-disable-next-line sonarjs/no-duplicate-string + this.metricsMetadata.to_version = flags['target-version']; + + const ff = retrieveFileFormat(this.specFile.text()); + const isSpecFileJson = ff == 'json'; + const isSpecFileYaml = ff == 'yaml'; + + if (!isSpecFileJson && !isSpecFileYaml) { + throw new SpecificationWrongFileFormat(filePath); + } + + convertedFile = this.handleConversion( + isSpecFileJson, + isSpecFileYaml, + outputFileFormat, + ); + + if (!convertedFile) return; + await this.handleOutput(flags.output, convertedFile, outputFileFormat); + } catch (err) { + this.error(err as Error); + } + } + + private handleConversion( + isSpecFileJson: boolean, + isSpecFileYaml: boolean, + outputFileFormat: fileFormat, + ): string | undefined { + const text = this.specFile?.text(); + if (isSpecFileJson && text) { + if (outputFileFormat == 'json') { + throw new Error(`Your document is already a ${cyan('JSON')}`); + } + return convertToYaml(text); + } + if (isSpecFileYaml && text) { + if (outputFileFormat == 'yaml' || outputFileFormat == 'yml') { + throw new Error(`Your document is already a ${cyan('YAML')}`); + } + return convertToJSON(text); + } + } + + private async handleOutput( + outputPath: string | undefined, + formattedFile: string, + outputFileFormat: fileFormat, + ) { + if (outputPath) { + outputPath = this.removeExtensionFromOutputPath(outputPath); + try { + const finalFileName = `${outputPath}.${outputFileFormat}`; + await fPromises.writeFile(finalFileName, formattedFile, { + encoding: 'utf8', + }); + this.log(`converted to ${outputFileFormat} at ${green(finalFileName)}`); + } catch (err) {} + } else { + this.log(formattedFile); + } + } + + private removeExtensionFromOutputPath(filename: string): string { + // Removes the extension from a filename if it is .json, .yaml, or .yml + // this is so that we can remove the provided extension name in the -o flag and + // apply our own extension name according to the content of the file + const validExtensions = ['json', 'yaml', 'yml']; + + const parts = filename.split('.'); + + if (parts.length > 1) { + const extension = parts.pop()?.toLowerCase(); + if (extension && validExtensions.includes(extension)) { + return parts.join('.'); + } + } + + return filename; + } +} diff --git a/src/core/errors/specification-file.ts b/src/core/errors/specification-file.ts index 9df74db9218..e7f9e134137 100644 --- a/src/core/errors/specification-file.ts +++ b/src/core/errors/specification-file.ts @@ -17,6 +17,13 @@ export class SpecificationFileNotFound extends SpecificationFileError { } } +export class SpecificationWrongFileFormat extends SpecificationFileError { + constructor(filePath?: string) { + super(); + this.message = `File ${filePath} is not of correct format.`; + } +} + export class SpecificationURLNotFound extends SpecificationFileError { constructor(URL: string) { super(); diff --git a/src/core/flags/format.flags.ts b/src/core/flags/format.flags.ts new file mode 100644 index 00000000000..7b6e0a6ea08 --- /dev/null +++ b/src/core/flags/format.flags.ts @@ -0,0 +1,22 @@ +import { Flags } from '@oclif/core'; + +export type fileFormat = 'yaml' | 'yml' | 'json'; + +const availFileFormats: fileFormat[] = ['yaml', 'yml', 'json']; + +export const convertFormatFlags = () => { + return { + help: Flags.help({ char: 'h' }), + output: Flags.string({ + char: 'o', + description: 'path to the file where the result is saved', + }), + format: Flags.string({ + char: 'f', + description: 'Specify the format to convert to', + options: availFileFormats, + required: true, + default: 'json', + }), + }; +}; diff --git a/src/core/models/SpecificationFile.ts b/src/core/models/SpecificationFile.ts index 30051beb80e..be3681e6158 100644 --- a/src/core/models/SpecificationFile.ts +++ b/src/core/models/SpecificationFile.ts @@ -6,6 +6,7 @@ import yaml from 'js-yaml'; import { loadContext } from './Context'; import { ErrorLoadingSpec } from '../errors/specification-file'; import { MissingContextFileError } from '../errors/context-error'; +import { fileFormat } from 'core/flags/format.flags'; const { readFile, lstat } = fs; const allowedFileNames: string[] = [ @@ -222,3 +223,39 @@ async function detectSpecFile(): Promise { })); return existingFileNames.find(filename => filename !== undefined); } + +export function retrieveFileFormat(content: string): fileFormat | undefined { + try { + if (content.trimStart()[0] === '{') { + JSON.parse(content); + return 'json'; + } + // below yaml.load is not a definitive way to determine if a file is yaml or not. + // it is able to load .txt text files also. + yaml.load(content); + return 'yaml'; + } catch (err) { + return undefined; + } +} + +export function convertToYaml(spec: string) { + try { + // JS object -> YAML string + const jsonContent = yaml.load(spec); + return yaml.dump(jsonContent); + } catch (err) { + console.error(err); + } +} + +export function convertToJSON(spec: string) { + try { + // JSON or YAML String -> JS object + const jsonContent = yaml.load(spec); + // JS Object -> pretty JSON string + return JSON.stringify(jsonContent, null, 2); + } catch (err) { + console.error(err); + } +} From 314d6b695af50c0a7276b9cbff91026bd8305d60 Mon Sep 17 00:00:00 2001 From: catosaurusrex2003 Date: Sun, 20 Oct 2024 16:21:54 +0530 Subject: [PATCH 2/4] linting issues fixed --- src/commands/format.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/commands/format.ts b/src/commands/format.ts index d68e12acba5..d669e8fcb85 100644 --- a/src/commands/format.ts +++ b/src/commands/format.ts @@ -11,10 +11,7 @@ import { } from '../core/models/SpecificationFile'; import { SpecificationWrongFileFormat } from '../core/errors/specification-file'; import { cyan, green } from 'picocolors'; -import { - convertFormatFlags, - fileFormat, -} from '../core/flags/format.flags'; +import { convertFormatFlags, fileFormat } from '../core/flags/format.flags'; export default class Convert extends Command { static specFile: any; @@ -42,8 +39,8 @@ export default class Convert extends Command { this.metricsMetadata.to_version = flags['target-version']; const ff = retrieveFileFormat(this.specFile.text()); - const isSpecFileJson = ff == 'json'; - const isSpecFileYaml = ff == 'yaml'; + const isSpecFileJson = ff === 'json'; + const isSpecFileYaml = ff === 'yaml'; if (!isSpecFileJson && !isSpecFileYaml) { throw new SpecificationWrongFileFormat(filePath); @@ -55,7 +52,9 @@ export default class Convert extends Command { outputFileFormat, ); - if (!convertedFile) return; + if (!convertedFile) { + return; + } await this.handleOutput(flags.output, convertedFile, outputFileFormat); } catch (err) { this.error(err as Error); @@ -69,13 +68,13 @@ export default class Convert extends Command { ): string | undefined { const text = this.specFile?.text(); if (isSpecFileJson && text) { - if (outputFileFormat == 'json') { + if (outputFileFormat === 'json') { throw new Error(`Your document is already a ${cyan('JSON')}`); } return convertToYaml(text); } if (isSpecFileYaml && text) { - if (outputFileFormat == 'yaml' || outputFileFormat == 'yml') { + if (outputFileFormat === 'yaml' || outputFileFormat === 'yml') { throw new Error(`Your document is already a ${cyan('YAML')}`); } return convertToJSON(text); @@ -89,13 +88,11 @@ export default class Convert extends Command { ) { if (outputPath) { outputPath = this.removeExtensionFromOutputPath(outputPath); - try { - const finalFileName = `${outputPath}.${outputFileFormat}`; - await fPromises.writeFile(finalFileName, formattedFile, { - encoding: 'utf8', - }); - this.log(`converted to ${outputFileFormat} at ${green(finalFileName)}`); - } catch (err) {} + const finalFileName = `${outputPath}.${outputFileFormat}`; + await fPromises.writeFile(finalFileName, formattedFile, { + encoding: 'utf8', + }); + this.log(`converted to ${outputFileFormat} at ${green(finalFileName)}`); } else { this.log(formattedFile); } From 843f4be8db4b17f212ba769f9ee7257662d7d2b8 Mon Sep 17 00:00:00 2001 From: catosaurusrex2003 Date: Sun, 20 Oct 2024 20:35:17 +0530 Subject: [PATCH 3/4] tests added in format command --- .gitignore | 3 + src/commands/format.ts | 5 +- src/core/flags/format.flags.ts | 8 +- test/integration/format.test.ts | 209 ++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 test/integration/format.test.ts diff --git a/.gitignore b/.gitignore index 5b8a7c8b336..d9a57e16e83 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ node_modules test.asyncapi-cli asyncapi.json test/fixtures/minimaltemplate/__transpiled +test/fixtures/specification-conv.yml +test/fixtures/specification-conv.yaml +test/fixtures/specification-conv.json .vscode /action/ diff --git a/src/commands/format.ts b/src/commands/format.ts index d669e8fcb85..9bdf4b2b710 100644 --- a/src/commands/format.ts +++ b/src/commands/format.ts @@ -92,9 +92,12 @@ export default class Convert extends Command { await fPromises.writeFile(finalFileName, formattedFile, { encoding: 'utf8', }); - this.log(`converted to ${outputFileFormat} at ${green(finalFileName)}`); + this.log( + `succesfully formatted to ${outputFileFormat} at ${green(finalFileName)} ✅`, + ); } else { this.log(formattedFile); + this.log(`succesfully logged after formatting to ${outputFileFormat} ✅`); } } diff --git a/src/core/flags/format.flags.ts b/src/core/flags/format.flags.ts index 7b6e0a6ea08..103e648a337 100644 --- a/src/core/flags/format.flags.ts +++ b/src/core/flags/format.flags.ts @@ -7,10 +7,6 @@ const availFileFormats: fileFormat[] = ['yaml', 'yml', 'json']; export const convertFormatFlags = () => { return { help: Flags.help({ char: 'h' }), - output: Flags.string({ - char: 'o', - description: 'path to the file where the result is saved', - }), format: Flags.string({ char: 'f', description: 'Specify the format to convert to', @@ -18,5 +14,9 @@ export const convertFormatFlags = () => { required: true, default: 'json', }), + output: Flags.string({ + char: 'o', + description: 'path to the file where the result is saved', + }), }; }; diff --git a/test/integration/format.test.ts b/test/integration/format.test.ts new file mode 100644 index 00000000000..b4679ad7f0b --- /dev/null +++ b/test/integration/format.test.ts @@ -0,0 +1,209 @@ +import { test } from '@oclif/test'; +import { NO_CONTEXTS_SAVED } from '../../src/core/errors/context-error'; +import TestHelper, { createMockServer, stopMockServer } from '../helpers'; +import { expect } from '@oclif/test'; + +const testHelper = new TestHelper(); +const yamlFilePath = './test/fixtures/specification.yml'; +const JSONFilePath = './test/fixtures/specification.json'; +const convYmlFilePath = './test/fixtures/specification-conv.yml'; +const convYamlFilePath = './test/fixtures/specification-conv.yaml'; +const convJSONFilePath = './test/fixtures/specification-conv.json'; + + +describe('format', () => { + describe('with file paths', () => { + beforeEach(() => { + testHelper.createDummyContextFile(); + }); + + afterEach(() => { + testHelper.deleteDummyContextFile(); + }); + + before(() => { + createMockServer(); + }); + + after(() => { + stopMockServer(); + }); + + test + .stderr() + .stdout() + .command(['format', yamlFilePath, '-f', 'json']) + .it('should log formatted content if no -o is passed', (ctx, done) => { + expect(ctx.stdout).to.contain( + 'succesfully logged after formatting to json ✅', + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + + test + .stderr() + .stdout() + .command(['format', './test/fixtures/not-found.yml', '-f', 'json']) + .it('should throw error if file path is wrong', (ctx, done) => { + expect(ctx.stdout).to.equal(''); + expect(ctx.stderr).to.equal( + 'error loading AsyncAPI document from file: ./test/fixtures/not-found.yml file does not exist.\n', + ); + done(); + }); + + test + .stderr() + .stdout() + .command(['format', 'http://localhost:8080/dummySpec.yml', '-f', 'json']) + .it('works when url is passed', (ctx, done) => { + expect(ctx.stdout).to.contain( + 'succesfully logged after formatting to json ✅', + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + }); + + describe('with no arguments or required flags', () => { + beforeEach(() => { + testHelper.createDummyContextFile(); + }); + + afterEach(() => { + testHelper.setCurrentContext('home'); + testHelper.deleteDummyContextFile(); + }); + + test + .stderr() + .stdout() + .command(['format', yamlFilePath]) + .it('should default to json without -f flag', (ctx, done) => { + expect(ctx.stdout).to.contain( + 'succesfully logged after formatting to json ✅', + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + + test + .stderr() + .stdout() + .command(['format', '-f', 'json']) + .it('converts from current context', (ctx, done) => { + expect(ctx.stdout).to.contain( + 'succesfully logged after formatting to json ✅', + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + + test + .stderr() + .stdout() + .do(() => { + testHelper.unsetCurrentContext(); + testHelper.createDummyContextFile(); + }) + .command(['format', '-f', 'json']) + .it('throws error message if no current context', (ctx, done) => { + expect(ctx.stdout).to.equal(''); + expect(ctx.stderr).to.equal( + 'ContextError: No context is set as current, please set a current context.\n', + ); + done(); + }); + }); + + describe('with no spec file', () => { + beforeEach(() => { + try { + testHelper.deleteDummyContextFile(); + } catch (e: any) { + if (e.code !== 'ENOENT') { + throw e; + } + } + }); + + test + .stderr() + .stdout() + .command(['format', '-f', 'json']) + .it('throws error message if no spec file exists', (ctx, done) => { + expect(ctx.stdout).to.equal(''); + expect(ctx.stderr).to.equal( + `error locating AsyncAPI document: ${NO_CONTEXTS_SAVED}\n`, + ); + done(); + }); + }); + + describe('format with output flag', () => { + beforeEach(() => { + testHelper.createDummyContextFile(); + }); + + afterEach(() => { + testHelper.deleteDummyContextFile(); + }); + + test + .stderr() + .stdout() + .command(['format', yamlFilePath, '-f', 'json', '-o', convJSONFilePath]) + .it('create file yaml -> json', (ctx, done) => { + expect(ctx.stdout).to.contain( + `succesfully formatted to json at ${convJSONFilePath} ✅`, + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + + test + .stderr() + .stdout() + .command(['format', JSONFilePath, '-f', 'yaml', '-o', convYamlFilePath]) + .it('create file json -> yaml', (ctx, done) => { + expect(ctx.stdout).to.contain( + `succesfully formatted to yaml at ${convYamlFilePath} ✅`, + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + + test + .stderr() + .stdout() + .command(['format', JSONFilePath, '-f', 'yml', '-o', convYmlFilePath]) + .it('create file json -> yml', (ctx, done) => { + expect(ctx.stdout).to.contain( + `succesfully formatted to yml at ${convYmlFilePath} ✅`, + ); + expect(ctx.stderr).to.equal(''); + done(); + }); + }); + + describe('invalid or redundant format conversions', () => { + test + .stderr() + .stdout() + .command(['format', yamlFilePath, '-f', 'yaml']) + .it('yaml -> yaml', (ctx, done) => { + expect(ctx.stderr).to.contain('Your document is already a YAML'); + done(); + }); + + test + .stderr() + .stdout() + .command(['format', JSONFilePath, '-f', 'json']) + .it('json -> json', (ctx, done) => { + expect(ctx.stderr).to.contain('Your document is already a JSON'); + done(); + }); + }); +}); From 03a9c81c667e26c48b206ad31ab0a0741366894b Mon Sep 17 00:00:00 2001 From: catosaurusrex2003 Date: Sat, 26 Oct 2024 15:28:58 +0530 Subject: [PATCH 4/4] lint issues fixed --- test/integration/format.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/integration/format.test.ts b/test/integration/format.test.ts index b4679ad7f0b..3774d8065cd 100644 --- a/test/integration/format.test.ts +++ b/test/integration/format.test.ts @@ -10,7 +10,6 @@ const convYmlFilePath = './test/fixtures/specification-conv.yml'; const convYamlFilePath = './test/fixtures/specification-conv.yaml'; const convJSONFilePath = './test/fixtures/specification-conv.json'; - describe('format', () => { describe('with file paths', () => { beforeEach(() => {