diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 00000000000..95fbcc8e008 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,156 @@ +--- +title: 'Context concept' +weight: 50 +--- + +## Overview + +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. + +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. 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 + +### 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 + +`asyncapi config context init [CONTEXT-FILE-PATH]` + +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) + +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 + +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 + +Map of filesystem paths to target AsyncAPI documents. + +**Patterned Fields** + +Field Pattern | Type | Description +---|:---:|--- +{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 + +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", + "store": { + "monitor": "monitor/asyncapi.yaml", + "notifier": "notifier/asyncapi.yaml", + "subscriber": "subscriber/asyncapi.yaml" + } +} +``` + +## More context related CLI options + +All commands for managing contexts are available under `asyncapi config context` [CLI commands group](usage#asyncapi-config-context). diff --git a/package.json b/package.json index 164ac37c023..d904c557f71 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,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-cli\" CUSTOM_CONTEXT_FILE_LOCATION=\"\" jest --coverage -i", "get-version": "echo $npm_package_version" }, "types": "lib/index.d.ts" diff --git a/src/commands/config/context/add.ts b/src/commands/config/context/add.ts index fbc06e8db8d..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 or modify a context in 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 f272b9fe240..6b28b7184de 100644 --- a/src/commands/config/context/current.ts +++ b/src/commands/config/context/current.ts @@ -1,15 +1,51 @@ 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'; 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) + ) { + 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.' + ); + return; + } + { + throw e; + } + } + + if (fileContent) { + this.log(`${fileContent.current}: ${fileContent.context}`); + } } } diff --git a/src/commands/config/context/edit.ts b/src/commands/config/context/edit.ts new file mode 100644 index 00000000000..413e254e322 --- /dev/null +++ b/src/commands/config/context/edit.ts @@ -0,0 +1,52 @@ +import { Flags } from '@oclif/core'; +import Command from '../../../base'; +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 flags = { + 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, + }, + ]; + + async run() { + const { args } = await this.parse(ContextEdit); + const contextName = args['context-name']; + const newSpecFilePath = args['new-spec-file-path']; + + 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 f386e90be33..0abf5da0b03 100644 --- a/src/commands/config/context/index.ts +++ b/src/commands/config/context/index.ts @@ -2,6 +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'; + 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..0b8d5172d0b --- /dev/null +++ b/src/commands/config/context/init.ts @@ -0,0 +1,31 @@ +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 . (default) + - 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: false, + }, + ]; + + async run() { + const { args } = await this.parse(ContextInit); + const contextFilePath = args['context-file-path']; + + const contextWritePath = await initContext(contextFilePath); + this.log(`Initialized context ${contextWritePath}`); + } +} diff --git a/src/commands/config/context/list.ts b/src/commands/config/context/list.ts index 576f4130f31..47fee998e6e 100644 --- a/src/commands/config/context/list.ts +++ b/src/commands/config/context/list.ts @@ -1,17 +1,49 @@ -import {Flags} from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../../base'; -import { loadContextFile } from '../../../models/Context'; +import { + loadContextFile, + isContextFileEmpty, + 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 context in the store'; + 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(); - for (const [contextName, filePath] of Object.entries(fileContent.store)) { - this.log(`${contextName}: ${filePath}`); + try { + 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) + ) { + 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/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 1839b3cb9d7..b5114e3903d 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 exists.`; -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,6 +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_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() { @@ -29,9 +39,37 @@ 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); } } + +export class ContextAlreadyExistsError extends ContextError { + constructor(contextName: string, contextFileName: string) { + super(); + this.message = CONTEXT_ALREADY_EXISTS(contextName, contextFileName); + } +} + +export class ContextFileWrongFormatError extends ContextError { + constructor(contextFileName: string) { + super(); + 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_FILE_WRITE_ERROR(contextFileName); + } +} diff --git a/src/models/Context.ts b/src/models/Context.ts index bcc8affa92c..57cf54a63a1 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -1,75 +1,184 @@ -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 { ContextNotFound, MissingContextFileError, MissingCurrentContextError } from '../errors/context-error'; +import { + ContextNotFoundError, + MissingContextFileError, + MissingCurrentContextError, + ContextFileWrongFormatError, + ContextAlreadyExistsError, + ContextFileEmptyError, + ContextFileWriteError, +} 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); + +// `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( + 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; + +// 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, + 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 initContext(contextFilePath: string) { + const fileContent: IContextFile = { + store: {}, + }; + let contextWritePath = ''; + + // prettier-ignore + switch (contextFilePath) { + /* eslint-disable indent */ + case '.': + contextWritePath = process.cwd() + path.sep + CONTEXT_FILENAME; + break; + case './': + 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 `~` + // 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; + case '~': + contextWritePath = os.homedir() + path.sep + CONTEXT_FILENAME; + break; + default: + contextWritePath = process.cwd() + path.sep + CONTEXT_FILENAME; + } + + 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 = await loadContextFile(); + 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(); } export async function addContext(contextName: string, pathToFile: string) { - let fileContent: IContextFile; + const fileContent: IContextFile = await loadContextFile(); - try { - fileContent = await loadContextFile(); - } catch (err) { - if (err instanceof MissingContextFileError) { - fileContent = { - store: { - [contextName]: pathToFile, - } - }; - } else { - throw err; - } + // 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; + + fileContent.store[String(contextName)] = String(pathToFile); await saveContextFile(fileContent); } export async function removeContext(contextName: string) { - const fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + throw new ContextFileEmptyError(CONTEXT_FILE_PATH); + } if (!fileContent.store[String(contextName)]) { - throw new ContextNotFound(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 = await loadContextFile(); + 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, @@ -77,30 +186,189 @@ export async function getCurrentContext(): Promise { } export async function setCurrentContext(contextName: string) { - const fileContent = await loadContextFile(); + const fileContent: IContextFile = await loadContextFile(); + + if (await isContextFileEmpty(fileContent)) { + throw new ContextFileEmptyError(CONTEXT_FILE_PATH); + } + if (!fileContent.store[String(contextName)]) { - throw new ContextNotFound(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); } export async function loadContextFile(): Promise { + let fileContent: IContextFile; + + // If the context file cannot be read then it's a 'MissingContextFileError' + // error. try { - return JSON.parse(await readFile(DEFAULT_CONTEXT_FILE_PATH, { 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 { + fileContent = JSON.parse( + await readFile(CONTEXT_FILE_PATH, { encoding: 'utf8' }) + ); + } catch (e) { + // 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 { - writeFile(DEFAULT_CONTEXT_FILE_PATH, JSON.stringify({ - current: fileContent.current, - store: fileContent.store - }), { encoding: 'utf8' }); - return fileContent; - } catch (error) { - return; + await writeFile( + CONTEXT_FILE_PATH, + JSON.stringify({ + current: fileContent.current, + store: fileContent.store, + }), + { encoding: 'utf8' } + ); + } catch (e) { + throw new ContextFileWriteError(CONTEXT_FILE_PATH); + } +} + +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(REPO_ROOT_PATH.length + 1) + .split(path.sep); + currentPath.unshift(REPO_ROOT_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; + + // 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. + try { + // 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 ( + fileContent && + (await isContextFileValid(fileContent as unknown as IContextFile)) + ) { + CONTEXT_FILE_PATH = currentPathString; + return CONTEXT_FILE_PATH; + } + } catch (e) {} // eslint-disable-line + + currentPath.pop(); + } + return null; +} + +async function isContextFileValid(fileContent: IContextFile): Promise { + // Validation of context file's format against interface `IContextFile`. + return ( + [1, 2].includes(Object.keys(fileContent).length) && + 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' + ) + ); +} + +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/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..5ff6c8c0356 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, positive scenario', () => { beforeAll(() => { testHelper.createDummyContextFile(); }); @@ -20,20 +28,42 @@ describe('config', () => { .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) => { + .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', () => { + test + .stderr() + .stdout() + .command(['config:context:add', 'test', './test/specification.yml']) + .it('should add new context called "test"', (ctx, done) => { expect(ctx.stdout).toEqual( - `home: ${path.resolve(__dirname, '../specification.yml')}\ncode: ${path.resolve(__dirname, '../specification.yml')}\n` + 'Added context "test".\n\nYou can set it as your current context: asyncapi config context use test\nYou can use this context when needed by passing test as a parameter: asyncapi validate test\n' ); expect(ctx.stderr).toEqual(''); done(); @@ -45,10 +75,25 @@ describe('config', () => { .stderr() .stdout() .command(['config:context:add', 'test', './test/specification.yml']) - .it('should add new context called "test"', (ctx, done) => { - expect(ctx.stdout).toEqual( - 'Added context "test".\n\nYou can set it as your current context: asyncapi config context use test\nYou can use this context when needed by passing test as a parameter: asyncapi validate test\n' - ); + .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() + .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(); }); @@ -60,25 +105,191 @@ describe('config', () => { .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(); }); }); + // 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() .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', () => { + 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 zero-sized file saying that context file has wrong format.', + (ctx, done) => { + expect(ctx.stdout).toEqual(''); + expect(ctx.stderr).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` + ); + 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).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` + ); + 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).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` + ); + done(); + } + ); + }); + + // 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).toContain( + `ContextError: Context file "${CONTEXT_FILE_PATH}" has wrong format.` + ); + done(); + } + ); + }); +}); + +describe('config:context, negative scenario', () => { + afterAll(() => { + testHelper.deleteDummyContextFile(); + }); + + describe('config:context:list', () => { + testHelper.deleteDummyContextFile(); + test + .stderr() + .stdout() + .command(['config:context:list']) + .it( + 'should output info message (to stdout, NOT stderr) about absence of context file.', + (ctx, done) => { + expect(ctx.stdout).toContain('You have no context file configured.'); + expect(ctx.stderr).toEqual(''); + 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..03d39bc4b10 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: string): void { + writeFileSync(CONTEXT_FILE_PATH, JSON.stringify(data)); } 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'; } } -