From aca9e254f7ea81cd35a16ecd42c7ad921c270971 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Wed, 10 May 2023 13:25:35 +0000 Subject: [PATCH 01/22] feat: support context file location in repository --- package.json | 3 ++- src/errors/context-error.ts | 8 ++++++++ src/models/Context.ts | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c11ed4daecc..b7804d8eb36 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@oclif/plugin-not-found": "^2.3.22", "@stoplight/spectral-cli": "6.6.0", "ajv": "^8.12.0", + "app-root-path": "^3.1.0", "chalk": "^4.1.0", "chokidar": "^3.5.2", "fs-extra": "^11.1.0", @@ -149,7 +150,7 @@ "release": "semantic-release", "pretest": "npm run build", "test": "npm run test:unit", - "test:unit": "cross-env NODE_ENV=development TEST=1 CONTEXT_FILENAME=\"./test.asyncapi\" CONTEXT_FILE_PATH=\"./\" jest --coverage -i", + "test:unit": "cross-env NODE_ENV=development TEST=1 CUSTOM_CONTEXT_FILENAME=\"./test.asyncapi\" CUSTOM_CONTEXT_FILE_LOCATION=\"./\" jest --coverage -i", "get-version": "echo $npm_package_version" }, "types": "lib/index.d.ts" diff --git a/src/errors/context-error.ts b/src/errors/context-error.ts index 1839b3cb9d7..7724a8073dd 100644 --- a/src/errors/context-error.ts +++ b/src/errors/context-error.ts @@ -7,6 +7,7 @@ export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI w - In case you did not specify a context that you want to use, the CLI checks if there is a default context and uses it. To set default context run: asyncapi config context use mycontext - In case you did not provide any reference to AsyncAPI file and there is no default context, the CLI detects if in your current working directory you have files like asyncapi.json, asyncapi.yaml, asyncapi.yml. Just rename your file accordingly. `; +const CONTEXT_WRONG_FORMAT = 'Context file has wrong format'; class ContextError extends Error { constructor() { @@ -35,3 +36,10 @@ export class ContextNotFound extends ContextError { this.message = CONTEXT_NOT_FOUND(contextName); } } + +export class ContextWrongFormat extends ContextError { + constructor() { + super(); + this.message = CONTEXT_WRONG_FORMAT; + } +} diff --git a/src/models/Context.ts b/src/models/Context.ts index bcc8affa92c..8e8e943cd99 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -1,12 +1,19 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as repoRoot from 'app-root-path'; import { ContextNotFound, MissingContextFileError, MissingCurrentContextError } from '../errors/context-error'; const { readFile, writeFile } = fs; -const CONTEXT_FILENAME = process.env.CONTEXT_FILENAME || '.asyncapi'; -export const DEFAULT_CONTEXT_FILE_PATH = path.resolve(process.env.CONTEXT_FILE_PATH || os.homedir(), CONTEXT_FILENAME); + +const DEFAULT_CONTEXT_FILENAME = '.asyncapi'; +const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); +const DEFAULT_CONTEXT_FILE_PATH = path.resolve(DEFAULT_CONTEXT_FILE_LOCATION, DEFAULT_CONTEXT_FILENAME); + +const CONTEXT_FILENAME = process.env.CUSTOM_CONTEXT_FILENAME || DEFAULT_CONTEXT_FILENAME; +const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION || DEFAULT_CONTEXT_FILE_LOCATION; +const CONTEXT_FILE_PATH = path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILENAME); export interface IContextFile { current?: string, @@ -87,7 +94,7 @@ export async function setCurrentContext(contextName: string) { export async function loadContextFile(): Promise { try { - return JSON.parse(await readFile(DEFAULT_CONTEXT_FILE_PATH, { encoding: 'utf8' })) as IContextFile; + return JSON.parse(await readFile(await getContextFilePath(), { encoding: 'utf8' })) as IContextFile; } catch (e) { throw new MissingContextFileError(); } @@ -95,7 +102,7 @@ export async function loadContextFile(): Promise { async function saveContextFile(fileContent: IContextFile) { try { - writeFile(DEFAULT_CONTEXT_FILE_PATH, JSON.stringify({ + writeFile(CONTEXT_FILE_PATH, JSON.stringify({ current: fileContent.current, store: fileContent.store }), { encoding: 'utf8' }); @@ -104,3 +111,24 @@ async function saveContextFile(fileContent: IContextFile) { return; } } + +async function getContextFilePath(): Promise { + const currentPath = process.cwd().slice(repoRoot.path.length + 1).split(path.sep); + currentPath.unshift(repoRoot.path); + + for (let i = currentPath.length; i >= 0; i--) { + const currentPathString = currentPath[0] + ? currentPath.join(path.sep) + path.sep + CONTEXT_FILENAME + : os.homedir() + path.sep + CONTEXT_FILENAME; + + try { + if (JSON.parse(await readFile(currentPathString + path.sep + CONTEXT_FILENAME, { encoding: 'utf8' })) as IContextFile) { + return currentPathString; + } + } catch (e) {} + + currentPath.pop(); + } + + return ''; +} From ee173564f8efd3ac667e90272aaeb3bb9ac0cbfd Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Mon, 22 May 2023 05:11:00 +0000 Subject: [PATCH 02/22] feat: support context file location in repository --- src/commands/config/context/index.ts | 2 + src/commands/config/context/list.ts | 2 +- src/errors/context-error.ts | 18 +++-- src/models/Context.ts | 114 +++++++++++++++++++++++---- 4 files changed, 116 insertions(+), 20 deletions(-) diff --git a/src/commands/config/context/index.ts b/src/commands/config/context/index.ts index f386e90be33..32c3b85d42b 100644 --- a/src/commands/config/context/index.ts +++ b/src/commands/config/context/index.ts @@ -2,6 +2,8 @@ import { loadHelpClass } from '@oclif/core'; import Command from '../../../base'; export default class Context extends Command { + static description = 'Manage short aliases for full paths to AsyncAPI documents'; + async run() { const Help = await loadHelpClass(this.config); const help = new Help(this.config); diff --git a/src/commands/config/context/list.ts b/src/commands/config/context/list.ts index 576f4130f31..1f02585c95e 100644 --- a/src/commands/config/context/list.ts +++ b/src/commands/config/context/list.ts @@ -3,7 +3,7 @@ import Command from '../../../base'; import { loadContextFile } from '../../../models/Context'; export default class ContextList extends Command { - static description = 'List all the stored context in the store'; + static description = 'List all the stored contexts in the store'; static flags = { help: Flags.help({char: 'h'}) }; diff --git a/src/errors/context-error.ts b/src/errors/context-error.ts index 7724a8073dd..b9278316737 100644 --- a/src/errors/context-error.ts +++ b/src/errors/context-error.ts @@ -1,4 +1,4 @@ -const CONTEXT_NOT_FOUND = (contextName: string) => `Context "${contextName}" does not exists.`; +const CONTEXT_NOT_FOUND = (contextName: string) => `Context "${contextName}" does not exist.`; const MISSING_CURRENT_CONTEXT = 'No context is set as current, please set a current context.'; export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI what AsyncAPI file should be used: - You can provide a path to the AsyncAPI file: asyncapi path/to/file/asyncapi.yml @@ -7,7 +7,8 @@ export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI w - In case you did not specify a context that you want to use, the CLI checks if there is a default context and uses it. To set default context run: asyncapi config context use mycontext - In case you did not provide any reference to AsyncAPI file and there is no default context, the CLI detects if in your current working directory you have files like asyncapi.json, asyncapi.yaml, asyncapi.yml. Just rename your file accordingly. `; -const CONTEXT_WRONG_FORMAT = 'Context file has wrong format'; +const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file ${contextFileName} has wrong format.`; +const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => `Context with name '${contextName}' already exists in context file '${contextFileName}'.`; class ContextError extends Error { constructor() { @@ -23,6 +24,13 @@ export class MissingContextFileError extends ContextError { } } +export class ContextFileWrongFormatError extends ContextError { + constructor(contextFileName: string) { + super(); + this.message = CONTEXT_WRONG_FORMAT(contextFileName); + } +} + export class MissingCurrentContextError extends ContextError { constructor() { super(); @@ -37,9 +45,9 @@ export class ContextNotFound extends ContextError { } } -export class ContextWrongFormat extends ContextError { - constructor() { +export class ContextAlreadyExistsError extends ContextError { + constructor(contextName: string, contextFileName: string) { super(); - this.message = CONTEXT_WRONG_FORMAT; + this.message = CONTEXT_ALREADY_EXISTS(contextName, contextFileName); } } diff --git a/src/models/Context.ts b/src/models/Context.ts index 8e8e943cd99..769ffb8568f 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -3,17 +3,49 @@ import * as path from 'path'; import * as os from 'os'; import * as repoRoot from 'app-root-path'; -import { ContextNotFound, MissingContextFileError, MissingCurrentContextError } from '../errors/context-error'; +import { + ContextNotFound, + MissingContextFileError, + MissingCurrentContextError, + ContextFileWrongFormatError, + ContextAlreadyExistsError, +} from '../errors/context-error'; const { readFile, writeFile } = fs; -const DEFAULT_CONTEXT_FILENAME = '.asyncapi'; +const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); const DEFAULT_CONTEXT_FILE_PATH = path.resolve(DEFAULT_CONTEXT_FILE_LOCATION, DEFAULT_CONTEXT_FILENAME); const CONTEXT_FILENAME = process.env.CUSTOM_CONTEXT_FILENAME || DEFAULT_CONTEXT_FILENAME; const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION || DEFAULT_CONTEXT_FILE_LOCATION; -const CONTEXT_FILE_PATH = path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILENAME); + +// Usage of promises for assignment of their resolved values to constants is +// known to be troublesome: +// https://www.reddit.com/r/learnjavascript/comments/p7p7zw/assigning_data_from_a_promise_to_a_constant +// +// In this particular case and usage of ES6, there is a race condition during +// code execution, due to faster assignment of default values to +// `CONTEXT_FILE_PATH` than resolution of the promise. This is the cause +// `CONTEXT_FILE_PATH` will always pick default values for context file's path +// instead of waiting for resolution of the promise from `getContextFilePath()`. +// The situation might become better with use of top-level await which should +// pause code execution, until promise in construction +// +// const CONTEXT_FILE_PATH = await getContextFilePath() || path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILENAME) || DEFAULT_CONTEXT_FILE_PATH; +// +// is resolved, but for this to be checked, all codebase (including +// `@oclif/core`) needs to be migrated to ES2022 or higher. +// +// Until then `CONTEXT_FILE_PATH` name is mimicking a `const` while right now it +// is a `let` reassigned inside of `getContextFilePath()`. +export let CONTEXT_FILE_PATH = path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILENAME) || DEFAULT_CONTEXT_FILE_PATH; +// Sonar recognizes next line as a bug `Promises must be awaited, end with a +// call to .catch, or end with a call to .then with a rejection handler.` but +// due to absence of top-level await in ES6, this bug cannot be fixed without +// migrating the codebase to ES2022 or higher, thus suppressing Sonar analysis +// for this line. +getContextFilePath(); // NOSONAR export interface IContextFile { current?: string, @@ -47,15 +79,21 @@ export async function addContext(contextName: string, pathToFile: string) { try { fileContent = await loadContextFile(); - } catch (err) { - if (err instanceof MissingContextFileError) { + // If context file already has context name similar to the one specified as + // an argument, notify user about it (throw `ContextAlreadyExistsError()` + // error) and exit. + if (fileContent.store.hasOwnProperty.call(fileContent.store, contextName)) { + throw new ContextAlreadyExistsError(contextName, CONTEXT_FILE_PATH); + } + } catch (e) { + if (e instanceof MissingContextFileError) { fileContent = { store: { [contextName]: pathToFile, } }; } else { - throw err; + throw e; } } fileContent.store[String(contextName)] = pathToFile; @@ -93,11 +131,29 @@ export async function setCurrentContext(contextName: string) { } export async function loadContextFile(): Promise { + // If the context file cannot be read, then it's a 'MissingContextFileError' + // error. try { - return JSON.parse(await readFile(await getContextFilePath(), { encoding: 'utf8' })) as IContextFile; + await readFile(CONTEXT_FILE_PATH, { encoding: 'utf8' }); } catch (e) { throw new MissingContextFileError(); } + // If the context file cannot be parsed, then it's a + // 'ContextFileWrongFormatError' error. + try { + const fileContent: IContextFile = JSON.parse( + await readFile(CONTEXT_FILE_PATH, { encoding: 'utf8' }) + ); + if (await isContextFileValid(fileContent)) { + return fileContent; + } + // This `throw` is for `isContextFileValid()`. + throw new ContextFileWrongFormatError(CONTEXT_FILE_PATH); + } catch (e) { + // This `throw` is for `JSON.parse()`. + // https://stackoverflow.com/questions/29797946/handling-bad-json-parse-in-node-safely + throw new ContextFileWrongFormatError(CONTEXT_FILE_PATH); + } } async function saveContextFile(fileContent: IContextFile) { @@ -107,13 +163,16 @@ async function saveContextFile(fileContent: IContextFile) { store: fileContent.store }), { encoding: 'utf8' }); return fileContent; - } catch (error) { + } catch (e) { return; } } -async function getContextFilePath(): Promise { - const currentPath = process.cwd().slice(repoRoot.path.length + 1).split(path.sep); +async function getContextFilePath(): Promise { + const currentPath = process + .cwd() + .slice(repoRoot.path.length + 1) + .split(path.sep); currentPath.unshift(repoRoot.path); for (let i = currentPath.length; i >= 0; i--) { @@ -121,14 +180,41 @@ async function getContextFilePath(): Promise { ? currentPath.join(path.sep) + path.sep + CONTEXT_FILENAME : os.homedir() + path.sep + CONTEXT_FILENAME; + // This `try...catch` is a part of `for` loop and is used only to swallow + // errors if the file does not exist or cannot be read, to continue + // uninterrupted execution of the loop. For validation of context file's + // format is responsible `isContextFileValid()`. try { - if (JSON.parse(await readFile(currentPathString + path.sep + CONTEXT_FILENAME, { encoding: 'utf8' })) as IContextFile) { - return currentPathString; + // Paths to both [existing / can be read] files and files with size of 0 + // bytes should be returned. Files sized zero bytes are still subject to + // validation by `isContextFileValid()`, while [non-existence / + // impossibility to read] are subject to returning `null`. + if ( + (await readFile(currentPathString, { encoding: 'utf8' })) || + (await readFile(currentPathString, { encoding: 'utf8' })) === '' + ) { + CONTEXT_FILE_PATH = currentPathString; + return CONTEXT_FILE_PATH; } - } catch (e) {} + } catch (e) {} // eslint-disable-line currentPath.pop(); } + return null; +} - return ''; +export async function isContextFileValid( + fileContent: IContextFile +): Promise { + // Validation of context file's format against interface `IContextFile`. + return ( + Object.keys(fileContent).length !== 0 && + fileContent.hasOwnProperty.call(fileContent, 'store') && + !Array.from(Object.keys(fileContent.store)).find( + (elem) => typeof elem !== 'string' + ) && + !Array.from(Object.values(fileContent.store)).find( + (elem) => typeof elem !== 'string' + ) + ); } From 79116442cb3de49e87e9da923c0ff61c6841c412 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Fri, 26 May 2023 14:08:05 +0000 Subject: [PATCH 03/22] feat: support context file location in repository --- package.json | 2 +- sonar-project.properties | 2 + src/errors/context-error.ts | 4 +- src/models/Context.ts | 5 +- test/commands/config/versions.test.ts | 2 +- test/commands/context.test.ts | 118 +++++++++++++++++++++++++- test/commands/validate.test.ts | 2 +- test/testHelper.ts | 17 ++-- 8 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 sonar-project.properties diff --git a/package.json b/package.json index b7804d8eb36..dafb0a24019 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "release": "semantic-release", "pretest": "npm run build", "test": "npm run test:unit", - "test:unit": "cross-env NODE_ENV=development TEST=1 CUSTOM_CONTEXT_FILENAME=\"./test.asyncapi\" CUSTOM_CONTEXT_FILE_LOCATION=\"./\" jest --coverage -i", + "test:unit": "cross-env NODE_ENV=development TEST=1 CUSTOM_CONTEXT_FILENAME=\"test.asyncapi-cli\" CUSTOM_CONTEXT_FILE_LOCATION=\"\" jest --coverage -i", "get-version": "echo $npm_package_version" }, "types": "lib/index.d.ts" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000000..3a368ff251f --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.exclusions=test/minimaltemplate/**/* +sonar.exclusions=test/**/* \ No newline at end of file diff --git a/src/errors/context-error.ts b/src/errors/context-error.ts index b9278316737..799a0a1c406 100644 --- a/src/errors/context-error.ts +++ b/src/errors/context-error.ts @@ -7,8 +7,8 @@ export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI w - In case you did not specify a context that you want to use, the CLI checks if there is a default context and uses it. To set default context run: asyncapi config context use mycontext - In case you did not provide any reference to AsyncAPI file and there is no default context, the CLI detects if in your current working directory you have files like asyncapi.json, asyncapi.yaml, asyncapi.yml. Just rename your file accordingly. `; -const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file ${contextFileName} has wrong format.`; -const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => `Context with name '${contextName}' already exists in context file '${contextFileName}'.`; +const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file "${contextFileName}" has wrong format.`; +const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => `Context with name "${contextName}" already exists in context file "${contextFileName}".`; class ContextError extends Error { constructor() { diff --git a/src/models/Context.ts b/src/models/Context.ts index 769ffb8568f..d51e92be21a 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -15,7 +15,7 @@ const { readFile, writeFile } = fs; const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); -const DEFAULT_CONTEXT_FILE_PATH = path.resolve(DEFAULT_CONTEXT_FILE_LOCATION, DEFAULT_CONTEXT_FILENAME); +export const DEFAULT_CONTEXT_FILE_PATH = path.resolve(DEFAULT_CONTEXT_FILE_LOCATION, DEFAULT_CONTEXT_FILENAME); const CONTEXT_FILENAME = process.env.CUSTOM_CONTEXT_FILENAME || DEFAULT_CONTEXT_FILENAME; const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION || DEFAULT_CONTEXT_FILE_LOCATION; @@ -158,7 +158,7 @@ export async function loadContextFile(): Promise { async function saveContextFile(fileContent: IContextFile) { try { - writeFile(CONTEXT_FILE_PATH, JSON.stringify({ + await writeFile(CONTEXT_FILE_PATH, JSON.stringify({ current: fileContent.current, store: fileContent.store }), { encoding: 'utf8' }); @@ -209,6 +209,7 @@ export async function isContextFileValid( // Validation of context file's format against interface `IContextFile`. return ( Object.keys(fileContent).length !== 0 && + Object.keys(fileContent).length <= 2 && fileContent.hasOwnProperty.call(fileContent, 'store') && !Array.from(Object.keys(fileContent.store)).find( (elem) => typeof elem !== 'string' diff --git a/test/commands/config/versions.test.ts b/test/commands/config/versions.test.ts index cf385a4a75e..85e70573bc5 100644 --- a/test/commands/config/versions.test.ts +++ b/test/commands/config/versions.test.ts @@ -1,6 +1,6 @@ import { test } from '@oclif/test'; -describe('versions', () => { +describe('config', () => { describe('config:versions', () => { test .stderr() diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 127ab966ab7..7da174bbbb5 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -2,10 +2,18 @@ import path from 'path'; import { test } from '@oclif/test'; import TestHelper from '../testHelper'; +import { CONTEXT_FILE_PATH } from '../../src/models/Context'; const testHelper = new TestHelper(); -describe('config', () => { +// Both Jest's method names `test` and `it` are utilized by `@oclif/test`, so +// using them again results in error `Tests cannot be nested`, preventing +// creation of clear block structure of Jest's tests. +// Due to this `beforeEach()` cannot be used, because borders of each test +// cannot be recognized, so workarounds with explicit calls of `TestHelper` +// methods inside of `describe`s had to be implemented. + +describe('config:context, correct format', () => { beforeAll(() => { testHelper.createDummyContextFile(); }); @@ -54,6 +62,20 @@ describe('config', () => { }); }); + describe('config:context:add', () => { + test + .stderr() + .stdout() + .command(['config:context:add', 'test', './test/specification.yml']) + .it('should NOT add new context with already existing in context file name "test"', (ctx, done) => { + expect(ctx.stdout).toEqual( + '' + ); + expect(ctx.stderr).toEqual(`ContextError: Context with name "test" already exists in context file "${CONTEXT_FILE_PATH}".\n`); + done(); + }); + }); + describe('config:context:use', () => { test .stderr() @@ -82,3 +104,97 @@ describe('config', () => { }); }); }); + +describe('config:context, wrong format', () => { + beforeAll(() => { + // Any context file needs to be created before starting test suite, + // otherwise a totally legitimate context file will be created automatically + // by `addContext()`. + testHelper.createDummyContextFileWrong(''); + }); + + afterAll(() => { + testHelper.deleteDummyContextFile(); + }); + + describe('config:context:add', () => { + testHelper.deleteDummyContextFile(); + testHelper.createDummyContextFileWrong(''); + test + .stderr() + .stdout() + .command(['config:context:add', 'home', './test/specification.yml']) + .it( + 'should throw error on empty file saying that context file has wrong format.', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toEqual( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + ); + done(); + } + ); + }); + + describe('config:context:add', () => { + testHelper.deleteDummyContextFile(); + testHelper.createDummyContextFileWrong('{}'); + test + .stderr() + .stdout() + .command(['config:context:add', 'home', './test/specification.yml']) + .it( + 'should throw error on file with empty object saying that context file has wrong format.', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toEqual( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + ); + done(); + } + ); + }); + + describe('config:context:add', () => { + testHelper.deleteDummyContextFile(); + testHelper.createDummyContextFileWrong('[]'); + test + .stderr() + .stdout() + .command(['config:context:add', 'home', './test/specification.yml']) + .it( + 'should throw error on file with empty array saying that context file has wrong format.', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toEqual( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + ); + done(); + } + ); + }); +}); + +describe('config:context, wrong format', () => { + afterAll(() => { + testHelper.deleteDummyContextFile(); + }); + + describe('config:context:list', () => { + testHelper.deleteDummyContextFile(); + test + .stderr() + .stdout() + .command(['config:context:list']) + .it( + 'should throw error on absence of context file.', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toContain( + 'ContextError: These are your options to specify in the CLI what AsyncAPI file should be used:' + ); + done(); + } + ); + }); +}); diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index 585cfae6c94..2e33458a085 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -104,7 +104,7 @@ describe('validate', () => { .command(['validate', 'non-existing-context']) .it('throws error if context name is not saved', (ctx, done) => { expect(ctx.stdout).toEqual(''); - expect(ctx.stderr).toEqual('ContextError: Context "non-existing-context" does not exists.\n'); + expect(ctx.stderr).toEqual('ContextError: Context "non-existing-context" does not exist.\n'); done(); }); }); diff --git a/test/testHelper.ts b/test/testHelper.ts index c23949d5f02..081fab32b24 100644 --- a/test/testHelper.ts +++ b/test/testHelper.ts @@ -1,6 +1,6 @@ import { existsSync, writeFileSync, unlinkSync, rmSync, mkdirSync , promises as fs } from 'fs'; import * as path from 'path'; -import { IContextFile, DEFAULT_CONTEXT_FILE_PATH } from '../src/models/Context'; +import { IContextFile, CONTEXT_FILE_PATH } from '../src/models/Context'; import SpecificationFile from '../src/models/SpecificationFile'; import http from 'http'; @@ -30,11 +30,17 @@ export default class ContextTestingHelper { } createDummyContextFile(): void { - writeFileSync(DEFAULT_CONTEXT_FILE_PATH, JSON.stringify(this._context), { encoding: 'utf-8' }); + writeFileSync(CONTEXT_FILE_PATH, JSON.stringify(this._context), { encoding: 'utf8' }); + } + + createDummyContextFileWrong(data): void { + writeFileSync(CONTEXT_FILE_PATH, data, { encoding: 'utf8' }); } deleteDummyContextFile(): void { - unlinkSync(DEFAULT_CONTEXT_FILE_PATH); + if (existsSync(CONTEXT_FILE_PATH)) { + rmSync(CONTEXT_FILE_PATH); + } } unsetCurrentContext(): void { @@ -77,12 +83,12 @@ export default class ContextTestingHelper { } deleteDummyProjectDirectory(): void { - rmSync(PROJECT_DIRECTORY_PATH, {recursive: true}); + rmSync(PROJECT_DIRECTORY_PATH, { recursive: true, force: true }); } } export function fileCleanup(filepath: string) { - unlinkSync(filepath); + rmSync(filepath); } export function createMockServer (port = 8080) { @@ -124,4 +130,3 @@ function getContentType(filePath:string):string { return 'application/octet-stream'; } } - From da91de36265d65702efa46f9966326478f029ffe Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Fri, 16 Jun 2023 00:41:06 +0000 Subject: [PATCH 04/22] feat: support context file location in repository --- .sonarcloud.properties | 7 ++++++- sonar-project.properties | 3 +-- src/models/Context.ts | 12 ++++++------ test/commands/context.test.ts | 1 + 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 0657ce6336b..eb75a7e5e79 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -1 +1,6 @@ -sonar.exclusions=test/**/* \ No newline at end of file +<<<<<<< HEAD +sonar.exclusions=test/**/* +======= +sonar.sources = src/ +sonar.tests = test/ +>>>>>>> 639d864 (feat: support context file location in repository) diff --git a/sonar-project.properties b/sonar-project.properties index 3a368ff251f..f68aa6a9f6f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1 @@ -sonar.exclusions=test/minimaltemplate/**/* -sonar.exclusions=test/**/* \ No newline at end of file +sonar.exclusions=test/minimaltemplate/**/* \ No newline at end of file diff --git a/src/models/Context.ts b/src/models/Context.ts index d51e92be21a..811a2c2b7d9 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -17,8 +17,8 @@ const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); export const DEFAULT_CONTEXT_FILE_PATH = path.resolve(DEFAULT_CONTEXT_FILE_LOCATION, DEFAULT_CONTEXT_FILENAME); -const CONTEXT_FILENAME = process.env.CUSTOM_CONTEXT_FILENAME || DEFAULT_CONTEXT_FILENAME; -const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION || DEFAULT_CONTEXT_FILE_LOCATION; +const CONTEXT_FILENAME = process.env.CUSTOM_CONTEXT_FILENAME ?? DEFAULT_CONTEXT_FILENAME; +const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION ?? DEFAULT_CONTEXT_FILE_LOCATION; // Usage of promises for assignment of their resolved values to constants is // known to be troublesome: @@ -60,7 +60,7 @@ export interface ICurrentContext { } export async function loadContext(contextName?: string): Promise { - const fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); if (contextName) { const context = fileContent.store[String(contextName)]; if (!context) {throw new ContextNotFound(contextName);} @@ -101,7 +101,7 @@ export async function addContext(contextName: string, pathToFile: string) { } export async function removeContext(contextName: string) { - const fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); if (!fileContent.store[String(contextName)]) { throw new ContextNotFound(contextName); } @@ -113,7 +113,7 @@ export async function removeContext(contextName: string) { } export async function getCurrentContext(): Promise { - const fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); const context = await loadContext(); return { current: fileContent.current as string, @@ -122,7 +122,7 @@ export async function getCurrentContext(): Promise { } export async function setCurrentContext(contextName: string) { - const fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); if (!fileContent.store[String(contextName)]) { throw new ContextNotFound(contextName); } diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 7da174bbbb5..e27c2bb672e 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import path from 'path'; import { test } from '@oclif/test'; From 5f6d08c75289da2799e0f01265aec0ec694d930f Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 27 Jun 2023 07:39:48 +0000 Subject: [PATCH 05/22] feat: support context file location in repository --- .sonarcloud.properties | 7 +- sonar-project.properties | 1 - src/commands/config/context/list.ts | 15 +++- src/errors/context-error.ts | 30 +++++--- src/models/Context.ts | 110 ++++++++++++++++------------ test/commands/context.test.ts | 35 ++++++++- test/testHelper.ts | 4 +- 7 files changed, 130 insertions(+), 72 deletions(-) delete mode 100644 sonar-project.properties diff --git a/.sonarcloud.properties b/.sonarcloud.properties index eb75a7e5e79..0657ce6336b 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -1,6 +1 @@ -<<<<<<< HEAD -sonar.exclusions=test/**/* -======= -sonar.sources = src/ -sonar.tests = test/ ->>>>>>> 639d864 (feat: support context file location in repository) +sonar.exclusions=test/**/* \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index f68aa6a9f6f..00000000000 --- a/sonar-project.properties +++ /dev/null @@ -1 +0,0 @@ -sonar.exclusions=test/minimaltemplate/**/* \ No newline at end of file diff --git a/src/commands/config/context/list.ts b/src/commands/config/context/list.ts index 1f02585c95e..3c21b2f762f 100644 --- a/src/commands/config/context/list.ts +++ b/src/commands/config/context/list.ts @@ -1,15 +1,24 @@ -import {Flags} from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { loadContextFile } from '../../../models/Context'; +import { loadContextFile, CONTEXT_FILE_PATH } from '../../../models/Context'; export default class ContextList extends Command { static description = 'List all the stored contexts in the store'; static flags = { - help: Flags.help({char: 'h'}) + help: Flags.help({ char: 'h' }), }; async run() { const fileContent = await loadContextFile(); + // If context file contains only one empty property `store` then the whole + // context file is considered empty. + if ( + Object.keys(fileContent).length === 1 && + Object.keys(fileContent.store).length === 0 + ) { + this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); + return; + } for (const [contextName, filePath] of Object.entries(fileContent.store)) { this.log(`${contextName}: ${filePath}`); } diff --git a/src/errors/context-error.ts b/src/errors/context-error.ts index 799a0a1c406..83a86b93440 100644 --- a/src/errors/context-error.ts +++ b/src/errors/context-error.ts @@ -1,5 +1,3 @@ -const CONTEXT_NOT_FOUND = (contextName: string) => `Context "${contextName}" does not exist.`; -const MISSING_CURRENT_CONTEXT = 'No context is set as current, please set a current context.'; export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI what AsyncAPI file should be used: - You can provide a path to the AsyncAPI file: asyncapi path/to/file/asyncapi.yml - You can provide URL to the AsyncAPI file: asyncapi https://example.com/path/to/file/asyncapi.yml @@ -7,8 +5,11 @@ export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI w - In case you did not specify a context that you want to use, the CLI checks if there is a default context and uses it. To set default context run: asyncapi config context use mycontext - In case you did not provide any reference to AsyncAPI file and there is no default context, the CLI detects if in your current working directory you have files like asyncapi.json, asyncapi.yaml, asyncapi.yml. Just rename your file accordingly. `; -const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file "${contextFileName}" has wrong format.`; +const MISSING_CURRENT_CONTEXT = 'No context is set as current, please set a current context.'; +const CONTEXT_NOT_FOUND = (contextName: string) => `Context "${contextName}" does not exist.`; const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => `Context with name "${contextName}" already exists in context file "${contextFileName}".`; +const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file "${contextFileName}" has wrong format.`; +const CONTEXT_WRITE_ERROR = (contextFileName: string) => `Error writing context file "${contextFileName}".`; class ContextError extends Error { constructor() { @@ -24,13 +25,6 @@ export class MissingContextFileError extends ContextError { } } -export class ContextFileWrongFormatError extends ContextError { - constructor(contextFileName: string) { - super(); - this.message = CONTEXT_WRONG_FORMAT(contextFileName); - } -} - export class MissingCurrentContextError extends ContextError { constructor() { super(); @@ -38,7 +32,7 @@ export class MissingCurrentContextError extends ContextError { } } -export class ContextNotFound extends ContextError { +export class ContextNotFoundError extends ContextError { constructor(contextName: string) { super(); this.message = CONTEXT_NOT_FOUND(contextName); @@ -51,3 +45,17 @@ export class ContextAlreadyExistsError extends ContextError { this.message = CONTEXT_ALREADY_EXISTS(contextName, contextFileName); } } + +export class ContextFileWrongFormatError extends ContextError { + constructor(contextFileName: string) { + super(); + this.message = CONTEXT_WRONG_FORMAT(contextFileName); + } +} + +export class ContextFileWriteError extends ContextError { + constructor(contextFileName: string) { + super(); + this.message = CONTEXT_WRITE_ERROR(contextFileName); + } +} diff --git a/src/models/Context.ts b/src/models/Context.ts index 811a2c2b7d9..f7585695522 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -4,21 +4,27 @@ import * as os from 'os'; import * as repoRoot from 'app-root-path'; import { - ContextNotFound, + ContextNotFoundError, MissingContextFileError, MissingCurrentContextError, ContextFileWrongFormatError, ContextAlreadyExistsError, + ContextFileWriteError, } from '../errors/context-error'; const { readFile, writeFile } = fs; const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); -export const DEFAULT_CONTEXT_FILE_PATH = path.resolve(DEFAULT_CONTEXT_FILE_LOCATION, DEFAULT_CONTEXT_FILENAME); +export const DEFAULT_CONTEXT_FILE_PATH = path.resolve( + DEFAULT_CONTEXT_FILE_LOCATION, + DEFAULT_CONTEXT_FILENAME +); -const CONTEXT_FILENAME = process.env.CUSTOM_CONTEXT_FILENAME ?? DEFAULT_CONTEXT_FILENAME; -const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION ?? DEFAULT_CONTEXT_FILE_LOCATION; +const CONTEXT_FILENAME = + process.env.CUSTOM_CONTEXT_FILENAME || DEFAULT_CONTEXT_FILENAME; +const CONTEXT_FILE_LOCATION = + process.env.CUSTOM_CONTEXT_FILE_LOCATION || DEFAULT_CONTEXT_FILE_LOCATION; // Usage of promises for assignment of their resolved values to constants is // known to be troublesome: @@ -39,7 +45,10 @@ const CONTEXT_FILE_LOCATION = process.env.CUSTOM_CONTEXT_FILE_LOCATION ?? DEFAUL // // Until then `CONTEXT_FILE_PATH` name is mimicking a `const` while right now it // is a `let` reassigned inside of `getContextFilePath()`. -export let CONTEXT_FILE_PATH = path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILENAME) || DEFAULT_CONTEXT_FILE_PATH; +export let CONTEXT_FILE_PATH = + path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILENAME) || + DEFAULT_CONTEXT_FILE_PATH; + // Sonar recognizes next line as a bug `Promises must be awaited, end with a // call to .catch, or end with a call to .then with a rejection handler.` but // due to absence of top-level await in ES6, this bug cannot be fixed without @@ -48,29 +57,32 @@ export let CONTEXT_FILE_PATH = path.resolve(CONTEXT_FILE_LOCATION, CONTEXT_FILEN getContextFilePath(); // NOSONAR export interface IContextFile { - current?: string, + current?: string; readonly store: { - [name: string]: string - } + [name: string]: string; + }; } export interface ICurrentContext { - readonly current: string, - readonly context: string + readonly current: string; + readonly context: string; } export async function loadContext(contextName?: string): Promise { const fileContent: IContextFile = await loadContextFile(); if (contextName) { const context = fileContent.store[String(contextName)]; - if (!context) {throw new ContextNotFound(contextName);} + if (!context) { + throw new ContextNotFoundError(contextName); + } return context; } else if (fileContent.current) { const context = fileContent.store[fileContent.current]; - if (!context) {throw new ContextNotFound(fileContent.current);} + if (!context) { + throw new ContextNotFoundError(fileContent.current); + } return context; } - throw new MissingCurrentContextError(); } @@ -80,7 +92,7 @@ export async function addContext(contextName: string, pathToFile: string) { try { fileContent = await loadContextFile(); // If context file already has context name similar to the one specified as - // an argument, notify user about it (throw `ContextAlreadyExistsError()` + // an argument, notify user about it (throw `ContextAlreadyExistsError` // error) and exit. if (fileContent.store.hasOwnProperty.call(fileContent.store, contextName)) { throw new ContextAlreadyExistsError(contextName, CONTEXT_FILE_PATH); @@ -90,7 +102,7 @@ export async function addContext(contextName: string, pathToFile: string) { fileContent = { store: { [contextName]: pathToFile, - } + }, }; } else { throw e; @@ -103,7 +115,7 @@ export async function addContext(contextName: string, pathToFile: string) { export async function removeContext(contextName: string) { const fileContent: IContextFile = await loadContextFile(); if (!fileContent.store[String(contextName)]) { - throw new ContextNotFound(contextName); + throw new ContextNotFoundError(contextName); } if (fileContent.current === contextName) { delete fileContent.current; @@ -124,47 +136,55 @@ export async function getCurrentContext(): Promise { export async function setCurrentContext(contextName: string) { const fileContent: IContextFile = await loadContextFile(); if (!fileContent.store[String(contextName)]) { - throw new ContextNotFound(contextName); + throw new ContextNotFoundError(contextName); } fileContent.current = contextName; await saveContextFile(fileContent); } export async function loadContextFile(): Promise { - // If the context file cannot be read, then it's a 'MissingContextFileError' + let fileContent: IContextFile; + + // If the context file cannot be read then it's a 'MissingContextFileError' // error. try { await readFile(CONTEXT_FILE_PATH, { encoding: 'utf8' }); } catch (e) { throw new MissingContextFileError(); } - // If the context file cannot be parsed, then it's a + + // If the context file cannot be parsed then it's a // 'ContextFileWrongFormatError' error. try { - const fileContent: IContextFile = JSON.parse( + fileContent = JSON.parse( await readFile(CONTEXT_FILE_PATH, { encoding: 'utf8' }) ); - if (await isContextFileValid(fileContent)) { - return fileContent; - } - // This `throw` is for `isContextFileValid()`. - throw new ContextFileWrongFormatError(CONTEXT_FILE_PATH); } catch (e) { - // This `throw` is for `JSON.parse()`. // https://stackoverflow.com/questions/29797946/handling-bad-json-parse-in-node-safely throw new ContextFileWrongFormatError(CONTEXT_FILE_PATH); } + + // If the context file cannot be validated then it's a + // 'ContextFileWrongFormatError' error. + if (!(await isContextFileValid(fileContent))) { + throw new ContextFileWrongFormatError(CONTEXT_FILE_PATH); + } + + return fileContent; } async function saveContextFile(fileContent: IContextFile) { try { - await writeFile(CONTEXT_FILE_PATH, JSON.stringify({ - current: fileContent.current, - store: fileContent.store - }), { encoding: 'utf8' }); - return fileContent; + await writeFile( + CONTEXT_FILE_PATH, + JSON.stringify({ + current: fileContent.current, + store: fileContent.store, + }), + { encoding: 'utf8' } + ); } catch (e) { - return; + throw new ContextFileWriteError(CONTEXT_FILE_PATH); } } @@ -182,16 +202,19 @@ async function getContextFilePath(): Promise { // This `try...catch` is a part of `for` loop and is used only to swallow // errors if the file does not exist or cannot be read, to continue - // uninterrupted execution of the loop. For validation of context file's - // format is responsible `isContextFileValid()`. + // uninterrupted execution of the loop. try { - // Paths to both [existing / can be read] files and files with size of 0 - // bytes should be returned. Files sized zero bytes are still subject to - // validation by `isContextFileValid()`, while [non-existence / - // impossibility to read] are subject to returning `null`. + // If a file is found which can be read and passed validation as a + // legitimate context file, then it is considered a legitimate context + // file indeed. + const fileContent = JSON.parse( + await readFile(currentPathString, { + encoding: 'utf8', + }) + ); if ( - (await readFile(currentPathString, { encoding: 'utf8' })) || - (await readFile(currentPathString, { encoding: 'utf8' })) === '' + fileContent && + (await isContextFileValid(fileContent as unknown as IContextFile)) ) { CONTEXT_FILE_PATH = currentPathString; return CONTEXT_FILE_PATH; @@ -203,13 +226,10 @@ async function getContextFilePath(): Promise { return null; } -export async function isContextFileValid( - fileContent: IContextFile -): Promise { +async function isContextFileValid(fileContent: IContextFile): Promise { // Validation of context file's format against interface `IContextFile`. return ( - Object.keys(fileContent).length !== 0 && - Object.keys(fileContent).length <= 2 && + [1, 2].includes(Object.keys(fileContent).length) && fileContent.hasOwnProperty.call(fileContent, 'store') && !Array.from(Object.keys(fileContent.store)).find( (elem) => typeof elem !== 'string' diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index e27c2bb672e..6c73c1343b1 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable sonarjs/no-duplicate-string */ import path from 'path'; import { test } from '@oclif/test'; @@ -14,7 +13,7 @@ const testHelper = new TestHelper(); // cannot be recognized, so workarounds with explicit calls of `TestHelper` // methods inside of `describe`s had to be implemented. -describe('config:context, correct format', () => { +describe('config:context, positive scenario', () => { beforeAll(() => { testHelper.createDummyContextFile(); }); @@ -91,6 +90,10 @@ describe('config:context, correct format', () => { }); }); + // On direct execution of `chmodSync(CONTEXT_FILE_PATH, '444')` context file's + // permissions get changed to 'read only' in file system, but `@oclif/test`'s + // test still passes. Thus there is no sense in implementation of + // `writeFile()` faulty scenario with `@oclif/test` framework. describe('config:context:remove', () => { test .stderr() @@ -106,7 +109,7 @@ describe('config:context, correct format', () => { }); }); -describe('config:context, wrong format', () => { +describe('config:context, negative scenario', () => { beforeAll(() => { // Any context file needs to be created before starting test suite, // otherwise a totally legitimate context file will be created automatically @@ -174,9 +177,33 @@ describe('config:context, wrong format', () => { } ); }); + + // Totally correct (and considered correct by `@oclif/core`) format of the + // context file + // `{"current":"home","store":{"home":"homeSpecFile","code":"codeSpecFile"}}` + // is considered wrong in `@oclif/test`, limiting possibilities of negative + // scenarios coding. + describe('config:context:add', () => { + testHelper.deleteDummyContextFile(); + testHelper.createDummyContextFileWrong('{"current":"home","current2":"test","store":{"home":"homeSpecFile","code":"codeSpecFile"}}'); + test + .stderr() + .stdout() + .command(['config:context:add', 'home', './test/specification.yml']) + .it( + 'should throw error on file with object having three root properties, saying that context file has wrong format.', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toEqual( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + ); + done(); + } + ); + }); }); -describe('config:context, wrong format', () => { +describe('config:context, negative scenario', () => { afterAll(() => { testHelper.deleteDummyContextFile(); }); diff --git a/test/testHelper.ts b/test/testHelper.ts index 081fab32b24..03d39bc4b10 100644 --- a/test/testHelper.ts +++ b/test/testHelper.ts @@ -33,8 +33,8 @@ export default class ContextTestingHelper { writeFileSync(CONTEXT_FILE_PATH, JSON.stringify(this._context), { encoding: 'utf8' }); } - createDummyContextFileWrong(data): void { - writeFileSync(CONTEXT_FILE_PATH, data, { encoding: 'utf8' }); + createDummyContextFileWrong(data: string): void { + writeFileSync(CONTEXT_FILE_PATH, JSON.stringify(data)); } deleteDummyContextFile(): void { From 24f6af9b3a0aeaa32dc3e4b320506acf15eafca7 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 06/22] feat: support context file location in repository --- src/commands/config/context/current.ts | 37 ++++++++++++++++++++++---- src/commands/config/context/list.ts | 25 ++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/commands/config/context/current.ts b/src/commands/config/context/current.ts index f272b9fe240..64db58fef2a 100644 --- a/src/commands/config/context/current.ts +++ b/src/commands/config/context/current.ts @@ -1,15 +1,42 @@ import { Flags } from '@oclif/core'; import Command from '../../../base'; import { getCurrentContext } from '../../../models/Context'; +import { + MissingContextFileError, + ContextFileWrongFormatError, + ContextNotFoundError, +} from '../../../errors/context-error'; export default class ContextCurrent extends Command { - static description='Shows the current context that is being used'; - static flags={ - help: Flags.help({char: 'h'}) + static description = 'Shows the current context that is being used'; + static flags = { + help: Flags.help({ char: 'h' }), }; async run() { - const { current, context } = await getCurrentContext(); - this.log(`${current}: ${context}`); + let fileContent; + + try { + fileContent = await getCurrentContext(); + } catch (e) { + if ( + e instanceof MissingContextFileError || + ContextFileWrongFormatError || + ContextNotFoundError || + (fileContent && !fileContent.current) || + !fileContent + ) { + this.log( + 'No context is set as current. Run "asyncapi config context" to see all available options.' + ); + return; + } else { + throw e; + } + } + + if (fileContent) { + this.log(`${fileContent.current}: ${fileContent.context}`); + } } } diff --git a/src/commands/config/context/list.ts b/src/commands/config/context/list.ts index 3c21b2f762f..6f3d269dd4c 100644 --- a/src/commands/config/context/list.ts +++ b/src/commands/config/context/list.ts @@ -1,6 +1,10 @@ import { Flags } from '@oclif/core'; import Command from '../../../base'; import { loadContextFile, CONTEXT_FILE_PATH } from '../../../models/Context'; +import { + MissingContextFileError, + ContextFileWrongFormatError, +} from '../../../errors/context-error'; export default class ContextList extends Command { static description = 'List all the stored contexts in the store'; @@ -9,18 +13,33 @@ export default class ContextList extends Command { }; async run() { - const fileContent = await loadContextFile(); + let fileContent; + + try { + fileContent = await loadContextFile(); + } catch (e) { + if (e instanceof MissingContextFileError || ContextFileWrongFormatError) { + this.log( + 'You have no context configured. Run "asyncapi config context" to see all available options.' + ); + } + } + // If context file contains only one empty property `store` then the whole // context file is considered empty. if ( + fileContent && Object.keys(fileContent).length === 1 && Object.keys(fileContent.store).length === 0 ) { this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); return; } - for (const [contextName, filePath] of Object.entries(fileContent.store)) { - this.log(`${contextName}: ${filePath}`); + + if (fileContent) { + for (const [contextName, filePath] of Object.entries(fileContent.store)) { + this.log(`${contextName}: ${filePath}`); + } } } } From 2a6cca145da0f9bc956bebcf0a68a8264352c433 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 07/22] feat: support context file location in repository --- src/commands/config/context/current.ts | 4 +--- src/commands/config/context/list.ts | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/config/context/current.ts b/src/commands/config/context/current.ts index 64db58fef2a..bd6ccc4e3b2 100644 --- a/src/commands/config/context/current.ts +++ b/src/commands/config/context/current.ts @@ -23,13 +23,11 @@ export default class ContextCurrent extends Command { e instanceof MissingContextFileError || ContextFileWrongFormatError || ContextNotFoundError || - (fileContent && !fileContent.current) || - !fileContent + (fileContent && !fileContent.current) ) { this.log( 'No context is set as current. Run "asyncapi config context" to see all available options.' ); - return; } else { throw e; } diff --git a/src/commands/config/context/list.ts b/src/commands/config/context/list.ts index 6f3d269dd4c..2d281f741d2 100644 --- a/src/commands/config/context/list.ts +++ b/src/commands/config/context/list.ts @@ -22,6 +22,8 @@ export default class ContextList extends Command { this.log( 'You have no context configured. Run "asyncapi config context" to see all available options.' ); + } else { + throw e; } } From b5faf8fdb20e537525bf009c5299a92f574a0fad Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 08/22] feat: support context file location in repository --- src/commands/config/context/add.ts | 2 +- src/commands/config/context/edit.ts | 24 ++++++++++++++++++++++++ src/models/Context.ts | 12 ++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/commands/config/context/edit.ts diff --git a/src/commands/config/context/add.ts b/src/commands/config/context/add.ts index fbc06e8db8d..80358c63484 100644 --- a/src/commands/config/context/add.ts +++ b/src/commands/config/context/add.ts @@ -3,7 +3,7 @@ import Command from '../../../base'; import { addContext } from '../../../models/Context'; export default class ContextAdd extends Command { - static description='Add or modify a context in the store'; + static description='Add a context to the store'; static flags = { help: Flags.help({char: 'h'}) }; diff --git a/src/commands/config/context/edit.ts b/src/commands/config/context/edit.ts new file mode 100644 index 00000000000..577d066c786 --- /dev/null +++ b/src/commands/config/context/edit.ts @@ -0,0 +1,24 @@ +import {Flags} from '@oclif/core'; +import Command from '../../../base'; +import { editContext } from '../../../models/Context'; + +export default class ContextEdit extends Command { + static description='Edit a context in the store'; + static flags = { + help: Flags.help({char: 'h'}) + }; + + static args = [ + {name: 'context-name', description: 'context name', required: true}, + {name: 'spec-file-path', description: 'file path of the spec file', required: true} + ]; + + async run() { + const {args} = await this.parse(ContextEdit); + const contextName = args['context-name']; + const specFilePath = args['spec-file-path']; + + await editContext(contextName, specFilePath); + this.log(`Edited context "${contextName}".\n\nYou can set it as your current context: asyncapi config context use ${contextName}\nYou can use this context when needed by passing ${contextName} as a parameter: asyncapi validate ${contextName}`); + } +} diff --git a/src/models/Context.ts b/src/models/Context.ts index f7585695522..17db470d4cc 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -112,6 +112,18 @@ export async function addContext(contextName: string, pathToFile: string) { await saveContextFile(fileContent); } +export async function editContext(contextName: string, pathToFile: string) { + let fileContent: IContextFile; + + try { + fileContent = await loadContextFile(); + } catch (e) { + throw e; + } + fileContent.store[String(contextName)] = pathToFile; + await saveContextFile(fileContent); +} + export async function removeContext(contextName: string) { const fileContent: IContextFile = await loadContextFile(); if (!fileContent.store[String(contextName)]) { From 69f7df6f2045a10470880127345dcd3a677f5572 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 09/22] feat: support context file location in repository --- src/models/Context.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/models/Context.ts b/src/models/Context.ts index 17db470d4cc..11feaba99f1 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -115,11 +115,12 @@ export async function addContext(contextName: string, pathToFile: string) { export async function editContext(contextName: string, pathToFile: string) { let fileContent: IContextFile; - try { - fileContent = await loadContextFile(); - } catch (e) { - throw e; - } + // The expression is not wrapped in a `try...catch` block and is allowed to + // throw automatically because it is assumed that `loadContextFile()` works + // with a 100%-existing valid file in this case, thus if it threw anyway - + // some REAL error happened and user should know about it. + fileContent = await loadContextFile(); + fileContent.store[String(contextName)] = pathToFile; await saveContextFile(fileContent); } From b6851a46ef04b38f0d1a3cb1da3f5e2eabcab5b2 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 10/22] feat: support context file location in repository --- test/commands/context.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 6c73c1343b1..69eb7c26a86 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -217,10 +217,8 @@ describe('config:context, negative scenario', () => { .it( 'should throw error on absence of context file.', (ctx, done) => { - expect(ctx.stdout).toEqual(''); - expect(ctx.stderr).toContain( - 'ContextError: These are your options to specify in the CLI what AsyncAPI file should be used:' - ); + expect(ctx.stdout).toContain('You have no context configured. Run "asyncapi config context" to see all available options.'); + expect(ctx.stderr).toEqual(''); done(); } ); From de85375d6aa85466e04820e488b94b4891a72227 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 11/22] feat: support context file location in repository --- src/models/Context.ts | 4 +--- test/commands/context.test.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/models/Context.ts b/src/models/Context.ts index 11feaba99f1..be57665e774 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -113,13 +113,11 @@ export async function addContext(contextName: string, pathToFile: string) { } export async function editContext(contextName: string, pathToFile: string) { - let fileContent: IContextFile; - // The expression is not wrapped in a `try...catch` block and is allowed to // throw automatically because it is assumed that `loadContextFile()` works // with a 100%-existing valid file in this case, thus if it threw anyway - // some REAL error happened and user should know about it. - fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); fileContent.store[String(contextName)] = pathToFile; await saveContextFile(fileContent); diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 69eb7c26a86..03bed6517cd 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -75,6 +75,18 @@ describe('config:context, positive scenario', () => { done(); }); }); + + describe('config:context:edit', () => { + test + .stderr() + .stdout() + .command(['config:context:edit', 'test', './test/specification2.yml']) + .it('should edit existing context "test"', (ctx, done) => { + expect(ctx.stdout).toContain('Edited context "test".'); + expect(ctx.stderr).toEqual(''); + done(); + }); + }); describe('config:context:use', () => { test @@ -215,7 +227,7 @@ describe('config:context, negative scenario', () => { .stdout() .command(['config:context:list']) .it( - 'should throw error on absence of context file.', + 'should output info message (to stdout, NOT stderr) about absence of context file.', (ctx, done) => { expect(ctx.stdout).toContain('You have no context configured. Run "asyncapi config context" to see all available options.'); expect(ctx.stderr).toEqual(''); From f8870fb59d1c88a1bba7f409571f57675d9ee881 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 12/22] feat: support context file location in repository --- docs/context.md | 47 +++++++++++++++++++++++++++++ src/commands/config/context/edit.ts | 6 ++-- test/commands/context.test.ts | 3 ++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 docs/context.md diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 00000000000..4fdff7bc320 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,47 @@ +### Context concept + + +### Tips and tricks + + +### How to add context to a **project** +Context can be added to a project using two ways: +- programmatically, by executing command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]`, +- manually, by creation of file `.asyncapi-cli` of the predefined format anywhere in the repository up the path of main executable, or in a user's home directory. + +### Context file's structure +Context file is a JSON whose structure follows the structure of interface `IContextFile`: +``` +interface IContextFile { + current?: string; + readonly store: { + [name: string]: string; + }; +} +``` +NB: If the context file doesn't pass internal validation, `ContextFileWrongFormatError` error is thrown. + + +### How to add context to the **context file** +`asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` +NB: On attempt to add context with name that already exists in the context file, `ContextAlreadyExistsError` error is thrown. + +### How to list existing contexts in the context file +`asyncapi config context list` +NB: If context file contains only one empty property `store` then the whole context file is considered empty and corresponding message is displayed. + + +### How to set context in the context file as current +`asyncapi config context use [CONTEXT-NAME]` + + +### How to show current context in the context file +`asyncapi config context current` + + +### How to edit a context in the store +`config context edit [CONTEXT-NAME] [NEW-SPEC-FILE-PATH]` + + +### How to remove a context from the context file +`asyncapi config context remove [CONTEXT-NAME]` diff --git a/src/commands/config/context/edit.ts b/src/commands/config/context/edit.ts index 577d066c786..dd78c1c750a 100644 --- a/src/commands/config/context/edit.ts +++ b/src/commands/config/context/edit.ts @@ -10,15 +10,15 @@ export default class ContextEdit extends Command { static args = [ {name: 'context-name', description: 'context name', required: true}, - {name: 'spec-file-path', description: 'file path of the spec file', required: true} + {name: 'new-spec-file-path', description: 'new file path of the spec file', required: true} ]; async run() { const {args} = await this.parse(ContextEdit); const contextName = args['context-name']; - const specFilePath = args['spec-file-path']; + const newSpecFilePath = args['new-spec-file-path']; - await editContext(contextName, specFilePath); + await editContext(contextName, newSpecFilePath); this.log(`Edited context "${contextName}".\n\nYou can set it as your current context: asyncapi config context use ${contextName}\nYou can use this context when needed by passing ${contextName} as a parameter: asyncapi validate ${contextName}`); } } diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 03bed6517cd..7e77ad8cbdf 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -133,6 +133,9 @@ describe('config:context, negative scenario', () => { testHelper.deleteDummyContextFile(); }); + // Make sure your context file has structure as described in section `Context + // file's structure` of `./docs/context.md` if you are having `Context file + // has wrong format` error. describe('config:context:add', () => { testHelper.deleteDummyContextFile(); testHelper.createDummyContextFileWrong(''); From f3f40d0426894e1151c1e7e7902842b38df37783 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 13/22] feat: support context file location in repository --- docs/context.md | 59 ++++++++++++++++++++----------------------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/docs/context.md b/docs/context.md index 4fdff7bc320..d8956abecad 100644 --- a/docs/context.md +++ b/docs/context.md @@ -1,47 +1,36 @@ -### Context concept +--- +title: 'Context concept' +weight: 50 +--- +## Overview -### Tips and tricks +AsyncAPI CLI provides functionality called `context`. It's purpose is to help to work with AsyncAPI CLI in large projects where you do not have just one service exposing AsyncAPI document, but multiple. +Event driven architecture involves multiple actors, subscribers and publishers. One time you want to validate document **A** and the other time you want to generate models from document **B**. Every time you do it, you need to provide to AsyncAPI CLI the location of the AsyncAPI document, which might be time consuming. You can workaround it with aliases in bash profiles or with other solutions but it is better to use `context` feature as you can then store it in your repository and share with other team members. -### How to add context to a **project** -Context can be added to a project using two ways: -- programmatically, by executing command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]`, +In short it means that for example instead of writing `asyncapi validate /some/folder/my-asyncapi.yml` you can create a context called `myasync` that will point to `/some/folder/my-asyncapi.yml`. This way next time you use the CLI you can do `asyncapi validate myasync`. + +### How to add context to a project + +Context can be added to a project in two ways: +- using CLI, by executing command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]`, - manually, by creation of file `.asyncapi-cli` of the predefined format anywhere in the repository up the path of main executable, or in a user's home directory. ### Context file's structure -Context file is a JSON whose structure follows the structure of interface `IContextFile`: + +Example of a context file utilizing [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the Amadeus Airline Platform (property `current` is optional in `.asyncapi-cli` file): ``` -interface IContextFile { - current?: string; - readonly store: { - [name: string]: string; - }; +{ + "current": "monitor", + "store": { + "monitor": "monitor/asyncapi.yaml", + "notifier": "notifier/asyncapi.yaml", + "subscriber": "subscriber/asyncapi.yaml" + } } ``` -NB: If the context file doesn't pass internal validation, `ContextFileWrongFormatError` error is thrown. - - -### How to add context to the **context file** -`asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` -NB: On attempt to add context with name that already exists in the context file, `ContextAlreadyExistsError` error is thrown. - -### How to list existing contexts in the context file -`asyncapi config context list` -NB: If context file contains only one empty property `store` then the whole context file is considered empty and corresponding message is displayed. - - -### How to set context in the context file as current -`asyncapi config context use [CONTEXT-NAME]` - - -### How to show current context in the context file -`asyncapi config context current` - - -### How to edit a context in the store -`config context edit [CONTEXT-NAME] [NEW-SPEC-FILE-PATH]` +### How to work with context using CLI -### How to remove a context from the context file -`asyncapi config context remove [CONTEXT-NAME]` +All commands for managing contexts are available under `asyncapi config context` [CLI commands group](usage.md#asyncapi-config-context). From fe20d89c7c3df2006971f91a14a12481193a3556 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 14/22] feat: support context file location in repository --- docs/context.md | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/context.md b/docs/context.md index d8956abecad..52f3e973212 100644 --- a/docs/context.md +++ b/docs/context.md @@ -11,15 +11,41 @@ Event driven architecture involves multiple actors, subscribers and publishers. In short it means that for example instead of writing `asyncapi validate /some/folder/my-asyncapi.yml` you can create a context called `myasync` that will point to `/some/folder/my-asyncapi.yml`. This way next time you use the CLI you can do `asyncapi validate myasync`. +### Context File location + +You can have a global context for your workstation, and a project specific context. + +If your use case is that you work with multiple repositories, you might want to use a global context. The `.asyncapi-cli` context file is then located in your home directory. This file is automatically created by the CLI once you run `asyncapi config context add` in a project that doesn't have its own context file + +You can also store your custom `.asyncapi-cli` file in your project with custom configuration. This way when you run `asyncapi config context add` inside your project, the new context is added to the context file under your project. + ### How to add context to a project -Context can be added to a project in two ways: -- using CLI, by executing command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]`, -- manually, by creation of file `.asyncapi-cli` of the predefined format anywhere in the repository up the path of main executable, or in a user's home directory. +1. Create file `.asyncapi-cli` in the root of your project +2. Execute command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` + +### Context File structure + +##### Fixed Fields + +Field Name | Type | Description +---|:---:|--- +current | `string` | An optional string value representing one of context names used as default in CLI. +store | [Store Object](#storeObject) | Map of filesystem paths to target AsyncAPI documents. + +#### Store Object + +Map of filesystem paths to target AsyncAPI documents. + +##### Patterned Fields + +Field Pattern | Type | Description +---|:---:|--- +{contextName} | `string` | **REQUIRED**. A filesystem path to the target AsyncAPI document. -### Context file's structure +##### Context File Example -Example of a context file utilizing [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the Amadeus Airline Platform (property `current` is optional in `.asyncapi-cli` file): +Example of a context file utilizing [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the Amadeus Airline Platform: ``` { "current": "monitor", @@ -33,4 +59,4 @@ Example of a context file utilizing [Event Driven Flight status notification ser ### How to work with context using CLI -All commands for managing contexts are available under `asyncapi config context` [CLI commands group](usage.md#asyncapi-config-context). +All commands for managing contexts are available under `asyncapi config context` [CLI commands group](usage#asyncapi-config-context). From c4f8156b922bf896b42e784dfe8cb43d96f1d66b Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 15/22] feat: support context file location in repository --- src/errors/context-error.ts | 2 +- test/commands/context.test.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/errors/context-error.ts b/src/errors/context-error.ts index 83a86b93440..aca02018ecf 100644 --- a/src/errors/context-error.ts +++ b/src/errors/context-error.ts @@ -8,7 +8,7 @@ export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI w const MISSING_CURRENT_CONTEXT = 'No context is set as current, please set a current context.'; const CONTEXT_NOT_FOUND = (contextName: string) => `Context "${contextName}" does not exist.`; const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => `Context with name "${contextName}" already exists in context file "${contextFileName}".`; -const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file "${contextFileName}" has wrong format.`; +const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file "${contextFileName}" has wrong format. Make sure your context file follows the structure described in section "Context File structure" at https://www.asyncapi.com/docs/tools/cli/context#context-file-structure`; const CONTEXT_WRITE_ERROR = (contextFileName: string) => `Error writing context file "${contextFileName}".`; class ContextError extends Error { diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 7e77ad8cbdf..03bed6517cd 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -133,9 +133,6 @@ describe('config:context, negative scenario', () => { testHelper.deleteDummyContextFile(); }); - // Make sure your context file has structure as described in section `Context - // file's structure` of `./docs/context.md` if you are having `Context file - // has wrong format` error. describe('config:context:add', () => { testHelper.deleteDummyContextFile(); testHelper.createDummyContextFileWrong(''); From 0c9cd46583af333fefd379b66a56750c09024d75 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 16/22] feat: support context file location in repository --- docs/context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/context.md b/docs/context.md index 52f3e973212..d4407fc9884 100644 --- a/docs/context.md +++ b/docs/context.md @@ -31,7 +31,7 @@ You can also store your custom `.asyncapi-cli` file in your project with custom Field Name | Type | Description ---|:---:|--- current | `string` | An optional string value representing one of context names used as default in CLI. -store | [Store Object](#storeObject) | Map of filesystem paths to target AsyncAPI documents. +store | [Store Object](#storeObject) | **REQUIRED**. Map of filesystem paths to target AsyncAPI documents. #### Store Object From fb9bba5adb5f3934233520117f0c00d5f1cf7b6a Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 17/22] feat: support context file location in repository --- docs/context.md | 43 ++++++-- src/commands/config/context/add.ts | 40 ++++++-- src/commands/config/context/current.ts | 21 +++- src/commands/config/context/edit.ts | 46 +++++++-- src/commands/config/context/index.ts | 5 +- src/commands/config/context/init.ts | 49 +++++++++ src/commands/config/context/list.ts | 50 +++++----- src/commands/config/context/remove.ts | 38 +++++-- src/commands/config/context/use.ts | 40 ++++++-- src/errors/context-error.ts | 28 ++++-- src/models/Context.ts | 132 +++++++++++++++++++------ test/commands/context.test.ts | 18 ++-- 12 files changed, 392 insertions(+), 118 deletions(-) create mode 100644 src/commands/config/context/init.ts diff --git a/docs/context.md b/docs/context.md index d4407fc9884..5d1ee8f2a82 100644 --- a/docs/context.md +++ b/docs/context.md @@ -7,22 +7,35 @@ weight: 50 AsyncAPI CLI provides functionality called `context`. It's purpose is to help to work with AsyncAPI CLI in large projects where you do not have just one service exposing AsyncAPI document, but multiple. -Event driven architecture involves multiple actors, subscribers and publishers. One time you want to validate document **A** and the other time you want to generate models from document **B**. Every time you do it, you need to provide to AsyncAPI CLI the location of the AsyncAPI document, which might be time consuming. You can workaround it with aliases in bash profiles or with other solutions but it is better to use `context` feature as you can then store it in your repository and share with other team members. +Event driven architecture involves multiple actors, subscribers and publishers. One time you want to validate document **A** and the other time you want to generate models from document **B**. Every time you do it, you need to provide to AsyncAPI CLI the location of the AsyncAPI document, which might be time consuming. You can workaround it with aliases in bash profiles or with other solutions but it is better to use `context` feature, as you can then store it in your repository and share with other team members. -In short it means that for example instead of writing `asyncapi validate /some/folder/my-asyncapi.yml` you can create a context called `myasync` that will point to `/some/folder/my-asyncapi.yml`. This way next time you use the CLI you can do `asyncapi validate myasync`. +In short it means that for example instead of writing `asyncapi validate /some/folder/my-asyncapi.yml` you can create a context called `myasync` that will be an alias for and point to `/some/folder/my-asyncapi.yml`. This way next time you use the CLI you can do `asyncapi validate myasync`. ### Context File location You can have a global context for your workstation, and a project specific context. -If your use case is that you work with multiple repositories, you might want to use a global context. The `.asyncapi-cli` context file is then located in your home directory. This file is automatically created by the CLI once you run `asyncapi config context add` in a project that doesn't have its own context file - -You can also store your custom `.asyncapi-cli` file in your project with custom configuration. This way when you run `asyncapi config context add` inside your project, the new context is added to the context file under your project. +If your use case is that you work with multiple repositories, you might want to use a global context. The `.asyncapi-cli` context file is then located in your home directory. You can also store your custom `.asyncapi-cli` file in your project with custom configuration. This way when you run `asyncapi config context add` inside your project, the new context is added to the context file under your project. ### How to add context to a project -1. Create file `.asyncapi-cli` in the root of your project -2. Execute command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` +##### Using previously created context file: + - Create file `.asyncapi-cli` containing [minimal empty context file](#minimalEmptyContextFile) in: + - current directory + - root of current repository + - user's home directory + - Make use of this file by executing command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` + +##### Using CLI's `init` command: + +`asyncapi config context init [CONTEXT-FILE-PATH] [CONTEXT-NAME] [SPEC-FILE-PATH]` + +Where `[CONTEXT-FILE-PATH]` instructs CLI what directory should the context file be created in: + - current directory: `asyncapi config context init . [CONTEXT-NAME] [SPEC-FILE-PATH]` + - root of current repository: `asyncapi config context init ./ [CONTEXT-NAME] [SPEC-FILE-PATH]` + - user's home directory: `asyncapi config context init ~ [CONTEXT-NAME] [SPEC-FILE-PATH]` + +The only mandatory switch is `[CONTEXT-FILE-PATH]`, additional switches can be omitted. In case of `asyncapi config context init .|./|~` the [minimal empty context file](#minimalEmptyContextFile) will be created in predefined location. ### Context File structure @@ -30,7 +43,7 @@ You can also store your custom `.asyncapi-cli` file in your project with custom Field Name | Type | Description ---|:---:|--- -current | `string` | An optional string value representing one of context names used as default in CLI. +current | `string` | An optional string value representing one of context names, which is used as default in CLI. Default means you can run CLI commands without providing context name, like `asyncapi validate`, and it will run against the default - `current` - context. store | [Store Object](#storeObject) | **REQUIRED**. Map of filesystem paths to target AsyncAPI documents. #### Store Object @@ -41,7 +54,19 @@ Map of filesystem paths to target AsyncAPI documents. Field Pattern | Type | Description ---|:---:|--- -{contextName} | `string` | **REQUIRED**. A filesystem path to the target AsyncAPI document. +{contextName} | `string` | An optional string value representing filesystem path to the target AsyncAPI document. + +##### Minimal Empty Context File +Raw JSON: +``` +{ + "store": {} +} +``` +Stringified JSON: +``` +{"store":{}} +``` ##### Context File Example diff --git a/src/commands/config/context/add.ts b/src/commands/config/context/add.ts index 80358c63484..26ea6635c60 100644 --- a/src/commands/config/context/add.ts +++ b/src/commands/config/context/add.ts @@ -1,24 +1,48 @@ -import {Flags} from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../../base'; import { addContext } from '../../../models/Context'; +import { + MissingContextFileError, + ContextFileWrongFormatError, +} from '../../../errors/context-error'; export default class ContextAdd extends Command { - static description='Add a context to the store'; + static description = 'Add a context to the store'; static flags = { - help: Flags.help({char: 'h'}) + help: Flags.help({ char: 'h' }), }; static args = [ - {name: 'context-name', description: 'context name', required: true}, - {name: 'spec-file-path', description: 'file path of the spec file', required: true} + { name: 'context-name', description: 'context name', required: true }, + { + name: 'spec-file-path', + description: 'file path of the spec file', + required: true, + }, ]; async run() { - const {args} = await this.parse(ContextAdd); + const { args } = await this.parse(ContextAdd); const contextName = args['context-name']; const specFilePath = args['spec-file-path']; - await addContext(contextName, specFilePath); - this.log(`Added context "${contextName}".\n\nYou can set it as your current context: asyncapi config context use ${contextName}\nYou can use this context when needed by passing ${contextName} as a parameter: asyncapi validate ${contextName}`); + try { + await addContext(contextName, specFilePath); + this.log( + `Added context "${contextName}".\n\nYou can set it as your current context: asyncapi config context use ${contextName}\nYou can use this context when needed by passing ${contextName} as a parameter: asyncapi validate ${contextName}` + ); + } catch (e) { + if ( + e instanceof (MissingContextFileError || ContextFileWrongFormatError) + ) { + this.log( + 'You have no context file configured. Run "asyncapi config context init" to initialize it.' + ); + return; + } + { + throw e; + } + } } } diff --git a/src/commands/config/context/current.ts b/src/commands/config/context/current.ts index bd6ccc4e3b2..6b28b7184de 100644 --- a/src/commands/config/context/current.ts +++ b/src/commands/config/context/current.ts @@ -1,9 +1,10 @@ import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { getCurrentContext } from '../../../models/Context'; +import { getCurrentContext, CONTEXT_FILE_PATH } from '../../../models/Context'; import { MissingContextFileError, ContextFileWrongFormatError, + ContextFileEmptyError, ContextNotFoundError, } from '../../../errors/context-error'; @@ -20,15 +21,25 @@ export default class ContextCurrent extends Command { fileContent = await getCurrentContext(); } catch (e) { if ( - e instanceof MissingContextFileError || - ContextFileWrongFormatError || - ContextNotFoundError || + e instanceof (MissingContextFileError || ContextFileWrongFormatError) + ) { + this.log( + 'You have no context file configured. Run "asyncapi config context init" to initialize it.' + ); + return; + } else if (e instanceof ContextFileEmptyError) { + this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); + return; + } else if ( + e instanceof ContextNotFoundError || (fileContent && !fileContent.current) ) { this.log( 'No context is set as current. Run "asyncapi config context" to see all available options.' ); - } else { + return; + } + { throw e; } } diff --git a/src/commands/config/context/edit.ts b/src/commands/config/context/edit.ts index dd78c1c750a..413e254e322 100644 --- a/src/commands/config/context/edit.ts +++ b/src/commands/config/context/edit.ts @@ -1,24 +1,52 @@ -import {Flags} from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { editContext } from '../../../models/Context'; +import { editContext, CONTEXT_FILE_PATH } from '../../../models/Context'; +import { + MissingContextFileError, + ContextFileWrongFormatError, + ContextFileEmptyError, +} from '../../../errors/context-error'; export default class ContextEdit extends Command { - static description='Edit a context in the store'; + static description = 'Edit a context in the store'; static flags = { - help: Flags.help({char: 'h'}) + help: Flags.help({ char: 'h' }), }; static args = [ - {name: 'context-name', description: 'context name', required: true}, - {name: 'new-spec-file-path', description: 'new file path of the spec file', required: true} + { name: 'context-name', description: 'context name', required: true }, + { + name: 'new-spec-file-path', + description: 'new file path of the spec file', + required: true, + }, ]; async run() { - const {args} = await this.parse(ContextEdit); + const { args } = await this.parse(ContextEdit); const contextName = args['context-name']; const newSpecFilePath = args['new-spec-file-path']; - await editContext(contextName, newSpecFilePath); - this.log(`Edited context "${contextName}".\n\nYou can set it as your current context: asyncapi config context use ${contextName}\nYou can use this context when needed by passing ${contextName} as a parameter: asyncapi validate ${contextName}`); + try { + await editContext(contextName, newSpecFilePath); + this.log( + `Edited context "${contextName}".\n\nYou can set it as your current context: asyncapi config context use ${contextName}\nYou can use this context when needed by passing ${contextName} as a parameter: asyncapi validate ${contextName}` + ); + } catch (e) { + if ( + e instanceof (MissingContextFileError || ContextFileWrongFormatError) + ) { + this.log( + 'You have no context file configured. Run "asyncapi config context init" to initialize it.' + ); + return; + } else if (e instanceof ContextFileEmptyError) { + this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); + return; + } + { + throw e; + } + } } } diff --git a/src/commands/config/context/index.ts b/src/commands/config/context/index.ts index 32c3b85d42b..0abf5da0b03 100644 --- a/src/commands/config/context/index.ts +++ b/src/commands/config/context/index.ts @@ -2,8 +2,9 @@ import { loadHelpClass } from '@oclif/core'; import Command from '../../../base'; export default class Context extends Command { - static description = 'Manage short aliases for full paths to AsyncAPI documents'; - + static description = + 'Manage short aliases for full paths to AsyncAPI documents'; + async run() { const Help = await loadHelpClass(this.config); const help = new Help(this.config); diff --git a/src/commands/config/context/init.ts b/src/commands/config/context/init.ts new file mode 100644 index 00000000000..e6cc0a34648 --- /dev/null +++ b/src/commands/config/context/init.ts @@ -0,0 +1,49 @@ +import os from 'os'; +import { Flags } from '@oclif/core'; +import Command from '../../../base'; +import { initContext } from '../../../models/Context'; + +export default class ContextInit extends Command { + static description = 'Initialize context'; + static flags = { + help: Flags.help({ char: 'h' }), + }; + + static contextFilePathMessage = `Specify directory in which context file should be created: + - current directory : asyncapi config context init . + - root of current repository : asyncapi config context init ./ + - user's home directory : asyncapi config context init ~`; + + static args = [ + { + name: 'context-file-path', + description: `${ContextInit.contextFilePathMessage}`, + required: true, + }, + { name: 'context-name', description: 'Context name', required: false }, + { + name: 'spec-file-path', + description: 'Filesystem path to the target AsyncAPI document', + required: false, + }, + ]; + + async run() { + const { args } = await this.parse(ContextInit); + const contextFilePath = args['context-file-path']; + const contextName = args['context-name']; + const specFilePath = args['spec-file-path']; + + if (!['.', './', os.homedir()].includes(contextFilePath)) { + this.log(`${ContextInit.contextFilePathMessage}`); + return; + } + + const contextWritePath = await initContext( + contextFilePath, + contextName, + specFilePath + ); + this.log(`Initialized context ${contextWritePath}`); + } +} diff --git a/src/commands/config/context/list.ts b/src/commands/config/context/list.ts index 2d281f741d2..47fee998e6e 100644 --- a/src/commands/config/context/list.ts +++ b/src/commands/config/context/list.ts @@ -1,6 +1,10 @@ import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { loadContextFile, CONTEXT_FILE_PATH } from '../../../models/Context'; +import { + loadContextFile, + isContextFileEmpty, + CONTEXT_FILE_PATH, +} from '../../../models/Context'; import { MissingContextFileError, ContextFileWrongFormatError, @@ -13,34 +17,32 @@ export default class ContextList extends Command { }; async run() { - let fileContent; - try { - fileContent = await loadContextFile(); + const fileContent = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); + return; + } + + if (fileContent) { + for (const [contextName, filePath] of Object.entries( + fileContent.store + )) { + this.log(`${contextName}: ${filePath}`); + } + } } catch (e) { - if (e instanceof MissingContextFileError || ContextFileWrongFormatError) { + if ( + e instanceof (MissingContextFileError || ContextFileWrongFormatError) + ) { this.log( - 'You have no context configured. Run "asyncapi config context" to see all available options.' + 'You have no context file configured. Run "asyncapi config context init" to initialize it.' ); - } else { - throw e; + return; } - } - - // If context file contains only one empty property `store` then the whole - // context file is considered empty. - if ( - fileContent && - Object.keys(fileContent).length === 1 && - Object.keys(fileContent.store).length === 0 - ) { - this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); - return; - } - - if (fileContent) { - for (const [contextName, filePath] of Object.entries(fileContent.store)) { - this.log(`${contextName}: ${filePath}`); + { + throw e; } } } diff --git a/src/commands/config/context/remove.ts b/src/commands/config/context/remove.ts index 0df8d28e960..8c5ebb501d6 100644 --- a/src/commands/config/context/remove.ts +++ b/src/commands/config/context/remove.ts @@ -1,24 +1,48 @@ -import {Flags} from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { removeContext } from '../../../models/Context'; +import { removeContext, CONTEXT_FILE_PATH } from '../../../models/Context'; +import { + MissingContextFileError, + ContextFileWrongFormatError, + ContextFileEmptyError, +} from '../../../errors/context-error'; export default class ContextRemove extends Command { static description = 'Delete a context from the store'; static flags = { - help: Flags.help({ char: 'h' }) + help: Flags.help({ char: 'h' }), }; + static args = [ - { name: 'context-name', description: 'Name of the context to delete', required: true } + { + name: 'context-name', + description: 'Name of the context to delete', + required: true, + }, ]; + async run() { const { args } = await this.parse(ContextRemove); const contextName = args['context-name']; - + try { await removeContext(contextName); this.log(`${contextName} successfully deleted`); - } catch (err) { - this.error(err as Error); + } catch (e) { + if ( + e instanceof (MissingContextFileError || ContextFileWrongFormatError) + ) { + this.log( + 'You have no context file configured. Run "asyncapi config context init" to initialize it.' + ); + return; + } else if (e instanceof ContextFileEmptyError) { + this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); + return; + } + { + throw e; + } } } } diff --git a/src/commands/config/context/use.ts b/src/commands/config/context/use.ts index 5ddf9c36ccc..9ce72a97680 100644 --- a/src/commands/config/context/use.ts +++ b/src/commands/config/context/use.ts @@ -1,22 +1,48 @@ -import {Flags} from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { setCurrentContext } from '../../../models/Context'; +import { setCurrentContext, CONTEXT_FILE_PATH } from '../../../models/Context'; +import { + MissingContextFileError, + ContextFileWrongFormatError, + ContextFileEmptyError, +} from '../../../errors/context-error'; export default class ContextUse extends Command { static description = 'Set a context as current'; - static flags = { - help: Flags.help({ char: 'h' }) + help: Flags.help({ char: 'h' }), }; static args = [ - { name: 'context-name', description: 'name of the saved context', required: true } + { + name: 'context-name', + description: 'name of the saved context', + required: true, + }, ]; async run() { const { args } = await this.parse(ContextUse); const contextName = args['context-name']; - await setCurrentContext(contextName); - this.log(`${contextName} is set as current`); + + try { + await setCurrentContext(contextName); + this.log(`${contextName} is set as current`); + } catch (e) { + if ( + e instanceof (MissingContextFileError || ContextFileWrongFormatError) + ) { + this.log( + 'You have no context file configured. Run "asyncapi config context init" to initialize it.' + ); + return; + } else if (e instanceof ContextFileEmptyError) { + this.log(`Context file "${CONTEXT_FILE_PATH}" is empty.`); + return; + } + { + throw e; + } + } } } diff --git a/src/errors/context-error.ts b/src/errors/context-error.ts index aca02018ecf..b5114e3903d 100644 --- a/src/errors/context-error.ts +++ b/src/errors/context-error.ts @@ -5,11 +5,18 @@ export const NO_CONTEXTS_SAVED = `These are your options to specify in the CLI w - In case you did not specify a context that you want to use, the CLI checks if there is a default context and uses it. To set default context run: asyncapi config context use mycontext - In case you did not provide any reference to AsyncAPI file and there is no default context, the CLI detects if in your current working directory you have files like asyncapi.json, asyncapi.yaml, asyncapi.yml. Just rename your file accordingly. `; -const MISSING_CURRENT_CONTEXT = 'No context is set as current, please set a current context.'; -const CONTEXT_NOT_FOUND = (contextName: string) => `Context "${contextName}" does not exist.`; -const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => `Context with name "${contextName}" already exists in context file "${contextFileName}".`; -const CONTEXT_WRONG_FORMAT = (contextFileName: string) => `Context file "${contextFileName}" has wrong format. Make sure your context file follows the structure described in section "Context File structure" at https://www.asyncapi.com/docs/tools/cli/context#context-file-structure`; -const CONTEXT_WRITE_ERROR = (contextFileName: string) => `Error writing context file "${contextFileName}".`; +const MISSING_CURRENT_CONTEXT = + 'No context is set as current, please set a current context.'; +const CONTEXT_NOT_FOUND = (contextName: string) => + `Context "${contextName}" does not exist.`; +const CONTEXT_ALREADY_EXISTS = (contextName: string, contextFileName: string) => + `Context with name "${contextName}" already exists in context file "${contextFileName}".`; +const CONTEXT_FILE_WRONG_FORMAT = (contextFileName: string) => + `Context file "${contextFileName}" has wrong format. Make sure your context file follows the structure described in section "Context File structure" at https://www.asyncapi.com/docs/tools/cli/context#context-file-structure`; +const CONTEXT_FILE_EMPTY = (contextFileName: string) => + `Context file "${contextFileName}" is empty.`; +const CONTEXT_FILE_WRITE_ERROR = (contextFileName: string) => + `Error writing context file "${contextFileName}".`; class ContextError extends Error { constructor() { @@ -49,13 +56,20 @@ export class ContextAlreadyExistsError extends ContextError { export class ContextFileWrongFormatError extends ContextError { constructor(contextFileName: string) { super(); - this.message = CONTEXT_WRONG_FORMAT(contextFileName); + this.message = CONTEXT_FILE_WRONG_FORMAT(contextFileName); + } +} + +export class ContextFileEmptyError extends ContextError { + constructor(contextFileName: string) { + super(); + this.message = CONTEXT_FILE_EMPTY(contextFileName); } } export class ContextFileWriteError extends ContextError { constructor(contextFileName: string) { super(); - this.message = CONTEXT_WRITE_ERROR(contextFileName); + this.message = CONTEXT_FILE_WRITE_ERROR(contextFileName); } } diff --git a/src/models/Context.ts b/src/models/Context.ts index be57665e774..6122e0d15fd 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -9,11 +9,16 @@ import { MissingCurrentContextError, ContextFileWrongFormatError, ContextAlreadyExistsError, + ContextFileEmptyError, ContextFileWriteError, } from '../errors/context-error'; const { readFile, writeFile } = fs; +export const EMPTY_CONTEXT_FILE = { + store: {}, +}; + const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); export const DEFAULT_CONTEXT_FILE_PATH = path.resolve( @@ -68,6 +73,50 @@ export interface ICurrentContext { readonly context: string; } +export async function initContext( + contextFilePath: string, + contextName: string, + specFilePath: string +) { + let fileContent: IContextFile = EMPTY_CONTEXT_FILE; + let contextWritePath = ''; + + switch (contextFilePath) { + /* eslint-disable indent */ + case '.': + contextWritePath = process.cwd() + path.sep + CONTEXT_FILENAME; + break; + case './': + contextWritePath = repoRoot.path + path.sep + CONTEXT_FILENAME; + break; + case os.homedir(): + contextWritePath = os.homedir() + path.sep + CONTEXT_FILENAME; + break; + case '~': + contextWritePath = os.homedir() + path.sep + CONTEXT_FILENAME; + break; + default: + } + + if (contextName && specFilePath) { + fileContent = { + store: { + [String(contextName)]: String(specFilePath), + }, + }; + } + + try { + await writeFile(contextWritePath, JSON.stringify(fileContent), { + encoding: 'utf8', + }); + } catch (e) { + throw new ContextFileWriteError(contextWritePath); + } + + return contextWritePath; +} + export async function loadContext(contextName?: string): Promise { const fileContent: IContextFile = await loadContextFile(); if (contextName) { @@ -87,57 +136,45 @@ export async function loadContext(contextName?: string): Promise { } export async function addContext(contextName: string, pathToFile: string) { - let fileContent: IContextFile; + const fileContent: IContextFile = await loadContextFile(); - try { - fileContent = await loadContextFile(); - // If context file already has context name similar to the one specified as - // an argument, notify user about it (throw `ContextAlreadyExistsError` - // error) and exit. - if (fileContent.store.hasOwnProperty.call(fileContent.store, contextName)) { - throw new ContextAlreadyExistsError(contextName, CONTEXT_FILE_PATH); - } - } catch (e) { - if (e instanceof MissingContextFileError) { - fileContent = { - store: { - [contextName]: pathToFile, - }, - }; - } else { - throw e; - } + // If context file already has context name similar to the one specified as + // an argument, notify user about it (throw `ContextAlreadyExistsError` + // error) and exit. + if (fileContent.store.hasOwnProperty.call(fileContent.store, contextName)) { + throw new ContextAlreadyExistsError(contextName, CONTEXT_FILE_PATH); } - fileContent.store[String(contextName)] = pathToFile; - await saveContextFile(fileContent); -} - -export async function editContext(contextName: string, pathToFile: string) { - // The expression is not wrapped in a `try...catch` block and is allowed to - // throw automatically because it is assumed that `loadContextFile()` works - // with a 100%-existing valid file in this case, thus if it threw anyway - - // some REAL error happened and user should know about it. - const fileContent: IContextFile = await loadContextFile(); - fileContent.store[String(contextName)] = pathToFile; + fileContent.store[String(contextName)] = String(pathToFile); await saveContextFile(fileContent); } export async function removeContext(contextName: string) { const fileContent: IContextFile = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + throw new ContextFileEmptyError(CONTEXT_FILE_PATH); + } if (!fileContent.store[String(contextName)]) { throw new ContextNotFoundError(contextName); } if (fileContent.current === contextName) { delete fileContent.current; } + delete fileContent.store[String(contextName)]; await saveContextFile(fileContent); } export async function getCurrentContext(): Promise { const fileContent: IContextFile = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + throw new ContextFileEmptyError(CONTEXT_FILE_PATH); + } + const context = await loadContext(); + return { current: fileContent.current as string, context, @@ -146,10 +183,31 @@ export async function getCurrentContext(): Promise { export async function setCurrentContext(contextName: string) { const fileContent: IContextFile = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + throw new ContextFileEmptyError(CONTEXT_FILE_PATH); + } + if (!fileContent.store[String(contextName)]) { throw new ContextNotFoundError(contextName); } - fileContent.current = contextName; + + fileContent.current = String(contextName); + await saveContextFile(fileContent); +} + +export async function editContext(contextName: string, pathToFile: string) { + // The expression is not wrapped in a `try...catch` block and is allowed to + // throw automatically because it is assumed that `loadContextFile()` works + // with a 100%-existing valid file in this case, thus if it threw anyway - + // some REAL error happened and user should know about it. + const fileContent: IContextFile = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + throw new ContextFileEmptyError(CONTEXT_FILE_PATH); + } + + fileContent.store[String(contextName)] = String(pathToFile); await saveContextFile(fileContent); } @@ -250,3 +308,15 @@ async function isContextFileValid(fileContent: IContextFile): Promise { ) ); } + +export async function isContextFileEmpty( + fileContent: IContextFile +): Promise { + // If context file contains only one empty property `store` then the whole + // context file is considered empty. + return ( + fileContent && + Object.keys(fileContent).length === 1 && + Object.keys(fileContent.store).length === 0 + ); +} diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 03bed6517cd..70547a68703 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -144,8 +144,8 @@ describe('config:context, negative scenario', () => { 'should throw error on empty file saying that context file has wrong format.', (ctx, done) => { expect(ctx.stdout).toEqual(''); - expect(ctx.stderr).toEqual( - `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + expect(ctx.stderr).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` ); done(); } @@ -163,8 +163,8 @@ describe('config:context, negative scenario', () => { 'should throw error on file with empty object saying that context file has wrong format.', (ctx, done) => { expect(ctx.stdout).toEqual(''); - expect(ctx.stderr).toEqual( - `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + expect(ctx.stderr).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` ); done(); } @@ -182,8 +182,8 @@ describe('config:context, negative scenario', () => { 'should throw error on file with empty array saying that context file has wrong format.', (ctx, done) => { expect(ctx.stdout).toEqual(''); - expect(ctx.stderr).toEqual( - `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + expect(ctx.stderr).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` ); done(); } @@ -206,8 +206,8 @@ describe('config:context, negative scenario', () => { 'should throw error on file with object having three root properties, saying that context file has wrong format.', (ctx, done) => { expect(ctx.stdout).toEqual(''); - expect(ctx.stderr).toEqual( - `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.\n` + expect(ctx.stderr).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` ); done(); } @@ -229,7 +229,7 @@ describe('config:context, negative scenario', () => { .it( 'should output info message (to stdout, NOT stderr) about absence of context file.', (ctx, done) => { - expect(ctx.stdout).toContain('You have no context configured. Run "asyncapi config context" to see all available options.'); + expect(ctx.stdout).toContain('You have no context file configured.'); expect(ctx.stderr).toEqual(''); done(); } From ed9cc5ea77d587c1e06ebc9a48ccdbe8034295fb Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 18/22] feat: support context file location in repository --- docs/context.md | 10 +++++----- src/commands/config/context/init.ts | 14 +------------- src/models/Context.ts | 10 +++++----- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/docs/context.md b/docs/context.md index 5d1ee8f2a82..2542ffdb215 100644 --- a/docs/context.md +++ b/docs/context.md @@ -28,14 +28,14 @@ If your use case is that you work with multiple repositories, you might want to ##### Using CLI's `init` command: -`asyncapi config context init [CONTEXT-FILE-PATH] [CONTEXT-NAME] [SPEC-FILE-PATH]` +`asyncapi config context init [CONTEXT-FILE-PATH]` Where `[CONTEXT-FILE-PATH]` instructs CLI what directory should the context file be created in: - - current directory: `asyncapi config context init . [CONTEXT-NAME] [SPEC-FILE-PATH]` - - root of current repository: `asyncapi config context init ./ [CONTEXT-NAME] [SPEC-FILE-PATH]` - - user's home directory: `asyncapi config context init ~ [CONTEXT-NAME] [SPEC-FILE-PATH]` + - current directory: `asyncapi config context init . (default)` + - root of current repository: `asyncapi config context init ./` + - user's home directory: `asyncapi config context init ~` -The only mandatory switch is `[CONTEXT-FILE-PATH]`, additional switches can be omitted. In case of `asyncapi config context init .|./|~` the [minimal empty context file](#minimalEmptyContextFile) will be created in predefined location. +If `[CONTEXT-FILE-PATH]` is omitted, the context file is created in current directory. ### Context File structure diff --git a/src/commands/config/context/init.ts b/src/commands/config/context/init.ts index e6cc0a34648..fb53bb8a895 100644 --- a/src/commands/config/context/init.ts +++ b/src/commands/config/context/init.ts @@ -1,4 +1,3 @@ -import os from 'os'; import { Flags } from '@oclif/core'; import Command from '../../../base'; import { initContext } from '../../../models/Context'; @@ -10,7 +9,7 @@ export default class ContextInit extends Command { }; static contextFilePathMessage = `Specify directory in which context file should be created: - - current directory : asyncapi config context init . + - current directory : asyncapi config context init . (default) - root of current repository : asyncapi config context init ./ - user's home directory : asyncapi config context init ~`; @@ -18,12 +17,6 @@ export default class ContextInit extends Command { { name: 'context-file-path', description: `${ContextInit.contextFilePathMessage}`, - required: true, - }, - { name: 'context-name', description: 'Context name', required: false }, - { - name: 'spec-file-path', - description: 'Filesystem path to the target AsyncAPI document', required: false, }, ]; @@ -34,11 +27,6 @@ export default class ContextInit extends Command { const contextName = args['context-name']; const specFilePath = args['spec-file-path']; - if (!['.', './', os.homedir()].includes(contextFilePath)) { - this.log(`${ContextInit.contextFilePathMessage}`); - return; - } - const contextWritePath = await initContext( contextFilePath, contextName, diff --git a/src/models/Context.ts b/src/models/Context.ts index 6122e0d15fd..e20674922ae 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -15,10 +15,6 @@ import { const { readFile, writeFile } = fs; -export const EMPTY_CONTEXT_FILE = { - store: {}, -}; - const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); export const DEFAULT_CONTEXT_FILE_PATH = path.resolve( @@ -78,9 +74,12 @@ export async function initContext( contextName: string, specFilePath: string ) { - let fileContent: IContextFile = EMPTY_CONTEXT_FILE; + let fileContent: IContextFile = { + store: {}, + }; let contextWritePath = ''; + // prettier-ignore switch (contextFilePath) { /* eslint-disable indent */ case '.': @@ -96,6 +95,7 @@ export async function initContext( contextWritePath = os.homedir() + path.sep + CONTEXT_FILENAME; break; default: + contextWritePath = process.cwd() + path.sep + CONTEXT_FILENAME; } if (contextName && specFilePath) { From a2eeb7a5ac45ac33c862b4a7c80cbb0239d1ddd8 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 19/22] feat: support context file location in repository --- docs/context.md | 13 ++-- src/commands/config/context/init.ts | 8 +- src/models/Context.ts | 20 ++--- test/commands/context.test.ts | 111 +++++++++++++++++++++------- 4 files changed, 99 insertions(+), 53 deletions(-) diff --git a/docs/context.md b/docs/context.md index 2542ffdb215..fdf4359f576 100644 --- a/docs/context.md +++ b/docs/context.md @@ -19,23 +19,26 @@ If your use case is that you work with multiple repositories, you might want to ### How to add context to a project -##### Using previously created context file: +##### Manually: - Create file `.asyncapi-cli` containing [minimal empty context file](#minimalEmptyContextFile) in: - current directory - root of current repository - user's home directory - - Make use of this file by executing command `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` ##### Using CLI's `init` command: `asyncapi config context init [CONTEXT-FILE-PATH]` -Where `[CONTEXT-FILE-PATH]` instructs CLI what directory should the context file be created in: - - current directory: `asyncapi config context init . (default)` +Where `[CONTEXT-FILE-PATH]` instructs CLI what directory should the file `.asyncapi-cli` containing [minimal empty context file](#minimalEmptyContextFile) be created in: + - current directory: `asyncapi config context init .` (default) - root of current repository: `asyncapi config context init ./` - user's home directory: `asyncapi config context init ~` + +(if `[CONTEXT-FILE-PATH]` is omitted, empty context file is created in current directory) -If `[CONTEXT-FILE-PATH]` is omitted, the context file is created in current directory. +Make use of newly created `.asyncapi-cli` by executing command: + +`asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` ### Context File structure diff --git a/src/commands/config/context/init.ts b/src/commands/config/context/init.ts index fb53bb8a895..0b8d5172d0b 100644 --- a/src/commands/config/context/init.ts +++ b/src/commands/config/context/init.ts @@ -24,14 +24,8 @@ export default class ContextInit extends Command { async run() { const { args } = await this.parse(ContextInit); const contextFilePath = args['context-file-path']; - const contextName = args['context-name']; - const specFilePath = args['spec-file-path']; - const contextWritePath = await initContext( - contextFilePath, - contextName, - specFilePath - ); + const contextWritePath = await initContext(contextFilePath); this.log(`Initialized context ${contextWritePath}`); } } diff --git a/src/models/Context.ts b/src/models/Context.ts index e20674922ae..28661102595 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -69,12 +69,8 @@ export interface ICurrentContext { readonly context: string; } -export async function initContext( - contextFilePath: string, - contextName: string, - specFilePath: string -) { - let fileContent: IContextFile = { +export async function initContext(contextFilePath: string) { + const fileContent: IContextFile = { store: {}, }; let contextWritePath = ''; @@ -88,6 +84,10 @@ export async function initContext( case './': contextWritePath = repoRoot.path + path.sep + CONTEXT_FILENAME; break; + // There are two variants of `~` case because tilde expansion in UNIX + // systems is not a guaranteed feature - sometimes `~` can return just `~` + // instead of home directory path. + // https://stackoverflow.com/questions/491877/how-to-find-a-users-home-directory-on-linux-or-unix#comment17161699_492669 case os.homedir(): contextWritePath = os.homedir() + path.sep + CONTEXT_FILENAME; break; @@ -98,14 +98,6 @@ export async function initContext( contextWritePath = process.cwd() + path.sep + CONTEXT_FILENAME; } - if (contextName && specFilePath) { - fileContent = { - store: { - [String(contextName)]: String(specFilePath), - }, - }; - } - try { await writeFile(contextWritePath, JSON.stringify(fileContent), { encoding: 'utf8', diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 70547a68703..5ff6c8c0356 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -28,24 +28,32 @@ describe('config:context, positive scenario', () => { .stdout() .command(['config:context:current']) .it('should show current context', (ctx, done) => { - expect(ctx.stdout).toEqual(`${testHelper.context.current}: ${testHelper.context.store['home']}\n`); + expect(ctx.stdout).toEqual( + `${testHelper.context.current}: ${testHelper.context.store['home']}\n` + ); expect(ctx.stderr).toEqual(''); done(); }); }); - + describe('config:context:list', () => { test .stderr() .stdout() .command(['config:context:list']) - .it('should list contexts prints list if context file is present', (ctx, done) => { - expect(ctx.stdout).toEqual( - `home: ${path.resolve(__dirname, '../specification.yml')}\ncode: ${path.resolve(__dirname, '../specification.yml')}\n` - ); - expect(ctx.stderr).toEqual(''); - done(); - }); + .it( + 'should list contexts prints list if context file is present', + (ctx, done) => { + expect(ctx.stdout).toEqual( + `home: ${path.resolve( + __dirname, + '../specification.yml' + )}\ncode: ${path.resolve(__dirname, '../specification.yml')}\n` + ); + expect(ctx.stderr).toEqual(''); + done(); + } + ); }); describe('config:context:add', () => { @@ -67,15 +75,18 @@ describe('config:context, positive scenario', () => { .stderr() .stdout() .command(['config:context:add', 'test', './test/specification.yml']) - .it('should NOT add new context with already existing in context file name "test"', (ctx, done) => { - expect(ctx.stdout).toEqual( - '' - ); - expect(ctx.stderr).toEqual(`ContextError: Context with name "test" already exists in context file "${CONTEXT_FILE_PATH}".\n`); - done(); - }); + .it( + 'should NOT add new context with already existing in context file name "test"', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toEqual( + `ContextError: Context with name "test" already exists in context file "${CONTEXT_FILE_PATH}".\n` + ); + done(); + } + ); }); - + describe('config:context:edit', () => { test .stderr() @@ -94,9 +105,7 @@ describe('config:context, positive scenario', () => { .stdout() .command(['config:context:use', 'code']) .it('should update the current context', (ctx, done) => { - expect(ctx.stdout).toEqual( - 'code is set as current\n' - ); + expect(ctx.stdout).toEqual('code is set as current\n'); expect(ctx.stderr).toEqual(''); done(); }); @@ -112,16 +121,62 @@ describe('config:context, positive scenario', () => { .stdout() .command(['config:context:remove', 'code']) .it('should remove existing context', (ctx, done) => { - expect(ctx.stdout).toEqual( - 'code successfully deleted\n' - ); + expect(ctx.stdout).toEqual('code successfully deleted\n'); + expect(ctx.stderr).toEqual(''); + done(); + }); + }); + + describe('config:context:init', () => { + test + .stderr() + .stdout() + .command(['config:context:init']) + .it('should initialize new empty context file without a switch', (ctx, done) => { + expect(ctx.stdout).toContain('Initialized context'); + expect(ctx.stderr).toEqual(''); + done(); + }); + }); + + describe('config:context:init', () => { + test + .stderr() + .stdout() + .command(['config:context:init', '.']) + .it('should initialize new empty context file with switch "."', (ctx, done) => { + expect(ctx.stdout).toContain('Initialized context'); + expect(ctx.stderr).toEqual(''); + done(); + }); + }); + + describe('config:context:init', () => { + test + .stderr() + .stdout() + .command(['config:context:init', './']) + .it('should initialize new empty context file with switch "./"', (ctx, done) => { + expect(ctx.stdout).toContain('Initialized context'); + expect(ctx.stderr).toEqual(''); + done(); + }); + }); + + describe('config:context:init', () => { + test + .stderr() + .stdout() + .command(['config:context:init', '~']) + .it('should initialize new empty context file with switch "~"', (ctx, done) => { + expect(ctx.stdout).toContain('Initialized context'); expect(ctx.stderr).toEqual(''); done(); }); }); }); -describe('config:context, negative scenario', () => { +describe('config:context, negative scenario', () => { beforeAll(() => { // Any context file needs to be created before starting test suite, // otherwise a totally legitimate context file will be created automatically @@ -141,7 +196,7 @@ describe('config:context, negative scenario', () => { .stdout() .command(['config:context:add', 'home', './test/specification.yml']) .it( - 'should throw error on empty file saying that context file has wrong format.', + 'should throw error on zero-sized file saying that context file has wrong format.', (ctx, done) => { expect(ctx.stdout).toEqual(''); expect(ctx.stderr).toContain( @@ -189,7 +244,7 @@ describe('config:context, negative scenario', () => { } ); }); - + // Totally correct (and considered correct by `@oclif/core`) format of the // context file // `{"current":"home","store":{"home":"homeSpecFile","code":"codeSpecFile"}}` @@ -197,7 +252,9 @@ describe('config:context, negative scenario', () => { // scenarios coding. describe('config:context:add', () => { testHelper.deleteDummyContextFile(); - testHelper.createDummyContextFileWrong('{"current":"home","current2":"test","store":{"home":"homeSpecFile","code":"codeSpecFile"}}'); + testHelper.createDummyContextFileWrong( + '{"current":"home","current2":"test","store":{"home":"homeSpecFile","code":"codeSpecFile"}}' + ); test .stderr() .stdout() From 6d89ea673e388ecfc8264724c3c70d8227fd2fbb Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 20/22] feat: support context file location in repository --- package.json | 1 - src/models/Context.ts | 70 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dafb0a24019..88aef2940a0 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "@oclif/plugin-not-found": "^2.3.22", "@stoplight/spectral-cli": "6.6.0", "ajv": "^8.12.0", - "app-root-path": "^3.1.0", "chalk": "^4.1.0", "chokidar": "^3.5.2", "fs-extra": "^11.1.0", diff --git a/src/models/Context.ts b/src/models/Context.ts index 28661102595..57cf54a63a1 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -1,7 +1,6 @@ -import { promises as fs } from 'fs'; +import { promises as fs, existsSync, lstatSync } from 'fs'; import * as path from 'path'; import * as os from 'os'; -import * as repoRoot from 'app-root-path'; import { ContextNotFoundError, @@ -15,6 +14,19 @@ import { const { readFile, writeFile } = fs; +// `repoRootPath` is optimistically assigned current working directory's +// filesystem path because chances are it will become 'official' repository root +// down the execution. +// +// `REPO_ROOT_PATH` will be converted to a real constant after migration of the +// codebase to ES2022 or higher and introduction of construction +// +// const REPO_ROOT_PATH = await getRepoRootPath(process.cwd()); +// +// See explanation of the situation with `CONTEXT_FILE_PATH` below. +let REPO_ROOT_PATH = process.cwd(); +getRepoRootPath(process.cwd()); + const DEFAULT_CONTEXT_FILENAME = '.asyncapi-cli'; const DEFAULT_CONTEXT_FILE_LOCATION = os.homedir(); export const DEFAULT_CONTEXT_FILE_PATH = path.resolve( @@ -82,7 +94,7 @@ export async function initContext(contextFilePath: string) { contextWritePath = process.cwd() + path.sep + CONTEXT_FILENAME; break; case './': - contextWritePath = repoRoot.path + path.sep + CONTEXT_FILENAME; + contextWritePath = REPO_ROOT_PATH + path.sep + CONTEXT_FILENAME; break; // There are two variants of `~` case because tilde expansion in UNIX // systems is not a guaranteed feature - sometimes `~` can return just `~` @@ -249,12 +261,60 @@ async function saveContextFile(fileContent: IContextFile) { } } +async function getRepoRootPath(repoRootPath: string): Promise { + // Asynchronous `fs.exists()` is deprecated, asynchronous `fs.stat()` + // introduces race condition, thus synchronous functions are used. + + let pathToCheck = `${repoRootPath}${path.sep}.git`; + + // If directory where `init` was requested in, happens to contain `.git` + // directory, then it surely is a root of repository, no need to search + // further and `REPO_ROOT_PATH` will remain as it was. + if (existsSync(pathToCheck) && lstatSync(pathToCheck).isDirectory()) { + return null; + } + + // Directory where `init` was requested in, did not happen to contain `.git` + // directory, so preparation for iterating through array of filesystem paths + // is started. + const repoRootPathArray = repoRootPath.split(path.sep); + + // Last element in array is thrown away because it is already known that it + // does not contain directory `.git`. + repoRootPathArray.pop(); + + // Backwards search of the array of filesystem paths will now be performed. + let i = repoRootPathArray.length - 1; + + while (i > 0) { + pathToCheck = `${repoRootPathArray.join(path.sep)}${path.sep}.git`; + + if (existsSync(pathToCheck) && lstatSync(pathToCheck).isDirectory()) { + REPO_ROOT_PATH = repoRootPathArray.join(path.sep); + return REPO_ROOT_PATH; + } + + // Last (`0th`) element is an empty string, so if directory `.git` was not + // found on 1st element (last actual directory in filesystem), the search + // does not need to continue and `REPO_ROOT_PATH` will remain having the + // value of current (where `init` was requested in) directory. + if (i === 1) { + return null; + } + + repoRootPathArray.pop(); + + i--; + } + return null; +} + async function getContextFilePath(): Promise { const currentPath = process .cwd() - .slice(repoRoot.path.length + 1) + .slice(REPO_ROOT_PATH.length + 1) .split(path.sep); - currentPath.unshift(repoRoot.path); + currentPath.unshift(REPO_ROOT_PATH); for (let i = currentPath.length; i >= 0; i--) { const currentPathString = currentPath[0] From d51bc4ed86398df7bcfefb71f0f5714a0cbdf54a Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 21/22] feat: support context file location in repository --- docs/context.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/context.md b/docs/context.md index fdf4359f576..99df78dc67f 100644 --- a/docs/context.md +++ b/docs/context.md @@ -19,13 +19,13 @@ If your use case is that you work with multiple repositories, you might want to ### How to add context to a project -##### Manually: +#### Manually: - Create file `.asyncapi-cli` containing [minimal empty context file](#minimalEmptyContextFile) in: - current directory - root of current repository - user's home directory -##### Using CLI's `init` command: +#### Using CLI's `init` command: `asyncapi config context init [CONTEXT-FILE-PATH]` @@ -40,6 +40,72 @@ Make use of newly created `.asyncapi-cli` by executing command: `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` +##### Setup example in a real project + +Below you can see an example of context setup for [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the [Amadeus Airline Platform](https://amadeus.com/en/industries/airlines/airline-platform), with multiple microservices and their AsyncAPI documents. + +```bash +# One-time initialization of '.asyncapi-cli' file +(main)$ asyncapi config context init +Initialized context /amadeus-async-flight-status/.asyncapi-cli + +# Adding first context +(main)$ asyncapi config context add subscriber subscriber/asyncapi.yaml +Added context "subscriber". +You can set it as your current context: asyncapi config context use subscriber +You can use this context when needed by passing subscriber as a parameter: asyncapi validate subscriber + +# Adding more contexts +(main)$ asyncapi config context add notifier notifier/asyncapi.yaml +Added context "notifier". +You can set it as your current context: asyncapi config context use notifier +You can use this context when needed by passing notifier as a parameter: asyncapi validate notifier + +(main)$ asyncapi config context add monitor monitor/asyncapi.yaml +Added context "monitor". +You can set it as your current context: asyncapi config context use monitor +You can use this context when needed by passing monitor as a parameter: asyncapi validate monitor + +# Setting monitor as default context +(main)$ asyncapi config context use monitor +monitor is set as current + +# Now you do not even have to remember the context name, and default 'monitor/asyncapi.yaml' will be validated +(main)$ asyncapi validate +File monitor/asyncapi.yaml is valid but has (itself and/or referenced documents) governance issues. +monitor/asyncapi.yaml + 1:1 warning asyncapi-defaultContentType AsyncAPI document should have "defaultContentType" field. + 1:1 warning asyncapi-id AsyncAPI document should have "id" field. + 1:1 warning asyncapi2-tags AsyncAPI object should have non-empty "tags" array. + 1:11 information asyncapi-latest-version The latest version of AsyncAPi is not used. It is recommended update to the "2.6.0" version. asyncapi + 2:6 warning asyncapi-info-contact Info object should have "contact" object. info + 19:15 warning asyncapi2-operation-operationId Operation should have an "operationId" field defined. channels.flight/update.subscribe + 26:13 warning asyncapi2-operation-operationId Operation should have an "operationId" field defined. channels.flight/queue.publish +✖ 7 problems (0 errors, 6 warnings, 1 info, 0 hints) + +# You can now use context name when running AsyncAPI commands, no need to remember file location like 'notifier/asyncapi.yaml' +(main)$ asyncapi validate notifier +File notifier/asyncapi.yaml is valid but has (itself and/or referenced documents) governance issues. +notifier/asyncapi.yaml + 1:1 warning asyncapi-defaultContentType AsyncAPI document should have "defaultContentType" field. + 1:1 warning asyncapi-id AsyncAPI document should have "id" field. + 1:1 warning asyncapi2-tags AsyncAPI object should have non-empty "tags" array. + 1:11 information asyncapi-latest-version The latest version of AsyncAPi is not used. It is recommended update to the "2.6.0" version. asyncapi + 2:6 warning asyncapi-info-contact Info object should have "contact" object. info + 18:13 warning asyncapi2-operation-operationId Operation should have an "operationId" field defined. channels.flight/update.publish +✖ 6 problems (0 errors, 5 warnings, 1 info, 0 hints) + +# Switch default context +(main)$ asyncapi config context use notifier +notifier is set as current + +# List all contexts +(main)$ asyncapi config context list +monitor: monitor/asyncapi.yaml +notifier: notifier/asyncapi.yaml +subscriber: subscriber/asyncapi.yaml +``` + ### Context File structure ##### Fixed Fields @@ -73,7 +139,7 @@ Stringified JSON: ##### Context File Example -Example of a context file utilizing [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the Amadeus Airline Platform: +Example of a context file for [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the [Amadeus Airline Platform](https://amadeus.com/en/industries/airlines/airline-platform), with multiple microservices and their AsyncAPI documents: ``` { "current": "monitor", From 4575a70b6949efb35b780dfef145d3f2563b8292 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Tue, 4 Jul 2023 23:16:01 +0000 Subject: [PATCH 22/22] feat: support context file location in repository --- docs/context.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/context.md b/docs/context.md index 99df78dc67f..95fbcc8e008 100644 --- a/docs/context.md +++ b/docs/context.md @@ -11,21 +11,21 @@ Event driven architecture involves multiple actors, subscribers and publishers. In short it means that for example instead of writing `asyncapi validate /some/folder/my-asyncapi.yml` you can create a context called `myasync` that will be an alias for and point to `/some/folder/my-asyncapi.yml`. This way next time you use the CLI you can do `asyncapi validate myasync`. -### Context File location +## Context File location You can have a global context for your workstation, and a project specific context. If your use case is that you work with multiple repositories, you might want to use a global context. The `.asyncapi-cli` context file is then located in your home directory. You can also store your custom `.asyncapi-cli` file in your project with custom configuration. This way when you run `asyncapi config context add` inside your project, the new context is added to the context file under your project. -### How to add context to a project +## How to add context to a project -#### Manually: +### Manually - Create file `.asyncapi-cli` containing [minimal empty context file](#minimalEmptyContextFile) in: - current directory - root of current repository - user's home directory -#### Using CLI's `init` command: +### Using CLI's `init` command `asyncapi config context init [CONTEXT-FILE-PATH]` @@ -40,7 +40,7 @@ Make use of newly created `.asyncapi-cli` by executing command: `asyncapi config context add [CONTEXT-NAME] [SPEC-FILE-PATH]` -##### Setup example in a real project +### Setup example in a real project Below you can see an example of context setup for [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the [Amadeus Airline Platform](https://amadeus.com/en/industries/airlines/airline-platform), with multiple microservices and their AsyncAPI documents. @@ -106,26 +106,26 @@ notifier: notifier/asyncapi.yaml subscriber: subscriber/asyncapi.yaml ``` -### Context File structure +## Context File structure -##### Fixed Fields +### Fixed Fields Field Name | Type | Description ---|:---:|--- current | `string` | An optional string value representing one of context names, which is used as default in CLI. Default means you can run CLI commands without providing context name, like `asyncapi validate`, and it will run against the default - `current` - context. store | [Store Object](#storeObject) | **REQUIRED**. Map of filesystem paths to target AsyncAPI documents. -#### Store Object +### Store Object Map of filesystem paths to target AsyncAPI documents. -##### Patterned Fields +**Patterned Fields** Field Pattern | Type | Description ---|:---:|--- {contextName} | `string` | An optional string value representing filesystem path to the target AsyncAPI document. -##### Minimal Empty Context File +### Minimal Empty Context File Raw JSON: ``` { @@ -137,7 +137,7 @@ Stringified JSON: {"store":{}} ``` -##### Context File Example +### Context File Example Example of a context file for [Event Driven Flight status notification service](https://github.com/amadeus4dev-examples/amadeus-async-flight-status/tree/ff433b6d320a3a6a2499976cbf0782353bc57c16) of the [Amadeus Airline Platform](https://amadeus.com/en/industries/airlines/airline-platform), with multiple microservices and their AsyncAPI documents: ``` @@ -151,6 +151,6 @@ Example of a context file for [Event Driven Flight status notification service]( } ``` -### How to work with context using CLI +## More context related CLI options All commands for managing contexts are available under `asyncapi config context` [CLI commands group](usage#asyncapi-config-context).