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 Jun 27, 2023
1 parent e299ed1 commit 93dd030
Show file tree
Hide file tree
Showing 7 changed files with 130 additions and 72 deletions.
7 changes: 1 addition & 6 deletions .sonarcloud.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
<<<<<<< HEAD
sonar.exclusions=test/**/*
=======
sonar.sources = src/
sonar.tests = test/
>>>>>>> 639d864 (feat: support context file location in repository)
sonar.exclusions=test/**/*
1 change: 0 additions & 1 deletion sonar-project.properties

This file was deleted.

15 changes: 12 additions & 3 deletions src/commands/config/context/list.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
Expand Down
30 changes: 19 additions & 11 deletions src/errors/context-error.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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
- You can provide URL to the AsyncAPI file: asyncapi <command> https://example.com/path/to/file/asyncapi.yml
- You can also pass a saved context that points to your AsyncAPI file: asyncapi <command> context-name
- 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() {
Expand All @@ -24,21 +25,14 @@ 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();
this.message = MISSING_CURRENT_CONTEXT;
}
}

export class ContextNotFound extends ContextError {
export class ContextNotFoundError extends ContextError {
constructor(contextName: string) {
super();
this.message = CONTEXT_NOT_FOUND(contextName);
Expand All @@ -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);
}
}
110 changes: 65 additions & 45 deletions src/models/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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<string> {
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();
}

Expand All @@ -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);
Expand All @@ -90,7 +102,7 @@ export async function addContext(contextName: string, pathToFile: string) {
fileContent = {
store: {
[contextName]: pathToFile,
}
},
};
} else {
throw e;
Expand All @@ -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;
Expand All @@ -124,47 +136,55 @@ export async function getCurrentContext(): Promise<ICurrentContext> {
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<IContextFile> {
// 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);
}
}

Expand All @@ -182,16 +202,19 @@ async function getContextFilePath(): Promise<string | null> {

// 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;
Expand All @@ -203,13 +226,10 @@ async function getContextFilePath(): Promise<string | null> {
return null;
}

export async function isContextFileValid(
fileContent: IContextFile
): Promise<boolean> {
async function isContextFileValid(fileContent: IContextFile): Promise<boolean> {
// 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'
Expand Down
35 changes: 31 additions & 4 deletions test/commands/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable sonarjs/no-duplicate-string */
import path from 'path';
import { test } from '@oclif/test';

Expand All @@ -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();
});
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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();
});
Expand Down
Loading

0 comments on commit 93dd030

Please sign in to comment.