Skip to content

Commit

Permalink
feat: support context file location in repository
Browse files Browse the repository at this point in the history
  • Loading branch information
aeworxet committed May 26, 2023
1 parent 674692b commit e2f1bc2
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 20 deletions.
2 changes: 2 additions & 0 deletions src/commands/config/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/config/context/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'})
};
Expand Down
18 changes: 13 additions & 5 deletions src/errors/context-error.ts
Original file line number Diff line number Diff line change
@@ -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 <command> path/to/file/asyncapi.yml
Expand All @@ -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() {
Expand All @@ -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();
Expand All @@ -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);
}
}
108 changes: 94 additions & 14 deletions src/models/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,44 @@ 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;
getContextFilePath();

export interface IContextFile {
current?: string,
Expand Down Expand Up @@ -47,15 +74,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;
Expand Down Expand Up @@ -93,11 +126,29 @@ export async function setCurrentContext(contextName: string) {
}

export async function loadContextFile(): Promise<IContextFile> {
// 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) {
Expand All @@ -107,28 +158,57 @@ async function saveContextFile(fileContent: IContextFile) {
store: fileContent.store
}), { encoding: 'utf8' });
return fileContent;
} catch (error) {
} catch (e) {
return;
}
}

async function getContextFilePath(): Promise<string> {
const currentPath = process.cwd().slice(repoRoot.path.length + 1).split(path.sep);
async function getContextFilePath(): Promise<string | null> {
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;

// 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' })) === ''
) {
return (CONTEXT_FILE_PATH = currentPathString);
}
} catch (e) {}
} catch (e) {} // eslint-disable-line

currentPath.pop();
}
return null;
}

return '';
export async function isContextFileValid(
fileContent: IContextFile
): Promise<boolean> {
// 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'
)
);
}

0 comments on commit e2f1bc2

Please sign in to comment.