From 3e5e14d4cf1ba7c5e3cca58673e3ef89f7c68e53 Mon Sep 17 00:00:00 2001 From: Viacheslav Turovskyi Date: Mon, 22 May 2023 05:11:00 +0000 Subject: [PATCH] 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' + ) + ); }