diff --git a/package-lock.json b/package-lock.json index 7c35167..2138725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4977,6 +4977,12 @@ "universalify": "^0.1.0" } }, + "fs-monkey": { + "version": "1.0.3", + "resolved": "https://nexus.dc.ifood.com.br/repository/ifood-npm/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7870,6 +7876,15 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, + "memfs": { + "version": "3.2.2", + "resolved": "https://nexus.dc.ifood.com.br/repository/ifood-npm/memfs/-/memfs-3.2.2.tgz", + "integrity": "sha512-RE0CwmIM3CEvpcdK3rZ19BC4E6hv9kADkMN5rPduRak58cNArWLi/9jFLsa4rhsjfVxMP3v0jO7FHXq7SvFY5Q==", + "dev": true, + "requires": { + "fs-monkey": "1.0.3" + } + }, "meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", diff --git a/package.json b/package.json index a4f4e39..f790410 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "cz-conventional-changelog": "^3.3.0", "husky": "^4.2.5", "jest": "^26.6.3", + "memfs": "^3.2.2", "mock-fs": "^4.12.0", "prettier": "2.0.4", "pretty-quick": "^2.0.1", diff --git a/source/interfaces/ServerOptions.ts b/source/interfaces/ServerOptions.ts index b160c5f..ff921ff 100644 --- a/source/interfaces/ServerOptions.ts +++ b/source/interfaces/ServerOptions.ts @@ -3,6 +3,7 @@ import ProxyProperties from './ProxyProperties'; import Throttling from './Throttling'; import MethodOverride from './MethodOverride'; import { RequestHandler } from 'express'; +import { FileStorageOptions } from '../storage'; export default interface ServerOptions { basePath?: string; @@ -13,4 +14,5 @@ export default interface ServerOptions { pagination?: PaginationProperties; proxies: Array; throttlings: Array; + fileStorageOptions?: FileStorageOptions; } diff --git a/source/overrides.spec.ts b/source/overrides.spec.ts index 91e7e69..ad83e92 100644 --- a/source/overrides.spec.ts +++ b/source/overrides.spec.ts @@ -1,5 +1,6 @@ import { getMockReq, getMockRes } from '@jest-mock/express'; import { mocked } from 'ts-jest/utils'; +import { fs as inMemoryFileSystem } from 'memfs'; import { Response, RouteProperties } from './interfaces'; @@ -12,6 +13,7 @@ import { import { OverrideManager } from './overrides'; import { RouteManager } from './routes'; import { Middleware } from './types'; +import { FileStorage } from './storage'; jest.mock('../source/prompts'); @@ -222,16 +224,7 @@ describe('source/override.ts', () => { describe('when selecting a method override', () => { beforeEach(() => { routeManager.setAll(routes); - - mocked(promptRoutePath).mockImplementation(async () => ({ - url: '/dogs', - })); - mocked(promptRouteMethodType).mockImplementation(async () => ({ - type: 'GET', - })); - mocked(promptRouteMethodOverride).mockImplementation(async () => ({ - name: 'Doggernaut', - })); + mockOverridePrompts('GET', '/dogs', 'Doggernaut'); }); it('prompts and changes a route method override', async () => { @@ -357,5 +350,71 @@ describe('source/override.ts', () => { }); }); }); + + describe('OverrideManager with FileStorage', () => { + const override = { + routePath: '/dogs', + methodType: 'get', + name: 'Doggernaut', + }; + + function createOverrideManager(fileStorage: FileStorage<'overrides'>) { + const overrideManager = new OverrideManager(routeManager, fileStorage); + overrideManager.applyExternalOverrides(); + } + + it('loads overrides from file storage', () => { + const fileStorage = createFileStorage(); + fileStorage.setItem('overrides', [override]); + createOverrideManager(fileStorage); + expect(overrideManager.getAllSelected()).toContainEqual(override); + }); + + it('persists an override to file storage', async () => { + const fileStorage = createFileStorage(); + mockOverridePrompts('GET', '/dogs', 'Doggernaut'); + createOverrideManager(fileStorage); + await overrideManager.choose(); + expect(fileStorage.getItem('overrides')).toContainEqual(override); + }); + + it('properly clears and apply external (file) overrides', async () => { + const fileStorage = createFileStorage(); + fileStorage.setItem('overrides', [override]); + const overrideManager = new OverrideManager(routeManager, fileStorage); + overrideManager.applyExternalOverrides(); + expect(fileStorage.getItem('overrides')).toContainEqual(override); + + fileStorage.clear(); + expect(fileStorage.isEmpty()).toBeTruthy(); + + overrideManager.applyExternalOverrides(); + expect(fileStorage.getItem('overrides')).toContainEqual(override); + }); + }); }); }); + +function mockOverridePrompts( + methodType: string, + routePath: string, + name: string +) { + mocked(promptRoutePath).mockImplementation(async () => ({ + url: routePath, + })); + mocked(promptRouteMethodType).mockImplementation(async () => ({ + type: methodType, + })); + mocked(promptRouteMethodOverride).mockImplementation(async () => ({ + name: name, + })); +} + +function createFileStorage() { + return new FileStorage<'overrides'>({ + enabled: true, + path: '/.storage', + fs: inMemoryFileSystem as any, + }); +} diff --git a/source/overrides.ts b/source/overrides.ts index b913c37..b7d9280 100644 --- a/source/overrides.ts +++ b/source/overrides.ts @@ -22,6 +22,7 @@ import { formatMethodType, RouteManager, } from './routes'; +import { FileStorage } from './storage'; import { Middleware } from './types'; const OVERRIDE_DEFAULT_OPTION = 'Default'; @@ -62,16 +63,42 @@ function findSelectedMethodOverride(method: Method) { return method.overrides?.find(propSatisfies(equals(true), 'selected')); } -export class OverrideManager { - private routeManager: RouteManager; +const FILE_STORAGE_KEY = 'overrides'; +export class OverrideManager { /** * Creates a new override manager. * * @param routeManager An instance of route manager */ - constructor(routeManager: RouteManager) { - this.routeManager = routeManager; + constructor( + private routeManager: RouteManager, + private fileStorage?: FileStorage + ) {} + + applyExternalOverrides() { + if (!this.fileStorage?.options.enabled || !this.fileStorage.isInitialized()) + return; + + if (this.fileStorage.isEmpty()) { + this.fileStorage?.setItem(FILE_STORAGE_KEY, this.getAllSelected()); + } else { + const persistedOverrides = this.fileStorage.getItem( + FILE_STORAGE_KEY + ); + + persistedOverrides?.forEach((override) => { + const overridableRoutes = this.getAll(); + const url = override.routePath; + const route = findRouteByUrl(overridableRoutes, url); + const type = override.methodType; + const overrides = getMethodOverridesByType(route, type.toLowerCase()); + const name = override.name; + overrides.forEach((override) => { + override.selected = override.name === name; + }); + }); + } } /** @@ -128,6 +155,8 @@ export class OverrideManager { override.selected = override.name === name; }); + this.fileStorage?.setItem('overrides', this.getAllSelected()); + return { routePath: url, methodType: type, name }; } diff --git a/source/server.ts b/source/server.ts index 4779952..10f3bde 100644 --- a/source/server.ts +++ b/source/server.ts @@ -19,6 +19,7 @@ import { ThrottlingManager } from './throttling'; import { UIManager } from './ui'; import { GraphQLManager } from './graphql'; import { join } from 'path'; +import { FileStorage } from './storage'; export function createServer(options = {} as ServerOptions): Server { const { @@ -29,16 +30,19 @@ export function createServer(options = {} as ServerOptions): Server { overrides, proxies, throttlings, + fileStorageOptions, } = options; + const fileStorage = new FileStorage(fileStorageOptions); const routeManager = new RouteManager(overrides); - const overrideManager = new OverrideManager(routeManager); + const overrideManager = new OverrideManager(routeManager, fileStorage); const proxyManager = new ProxyManager(routeManager, proxies, basePath); const throttlingManager = new ThrottlingManager(throttlings); const uiManager = new UIManager( proxyManager, throttlingManager, - overrideManager + overrideManager, + fileStorage ); const expressServer: express.Application = express(); @@ -121,6 +125,7 @@ export function createServer(options = {} as ServerOptions): Server { routeManager.setAll(routes); routeManager.addDocsRoute(basePath, docsRoute); routeManager.getAll().forEach(createRoute); + overrideManager.applyExternalOverrides(); }, /** @@ -162,11 +167,20 @@ export function createServer(options = {} as ServerOptions): Server { ); } + async function onResetFileStorage() { + fileStorage.clear(); + uiManager.drawDashboard(); + } + inputManager.addListener('c', onConnection); inputManager.addListener('t', onThrottling); inputManager.addListener('o', onOverride); inputManager.addListener('p', onRouteProxy); + if (fileStorage.options.enabled) { + inputManager.addListener('x', onResetFileStorage); + } + expressServer.listen(port); }, }; diff --git a/source/storage.spec.ts b/source/storage.spec.ts new file mode 100644 index 0000000..172ab4d --- /dev/null +++ b/source/storage.spec.ts @@ -0,0 +1,63 @@ +import { FileStorage } from './storage'; +import { fs as inMemoryFileSystem, vol } from 'memfs'; + +describe('FileStorage', () => { + beforeEach(() => { + vol.reset(); + }); + + it('throws an error when file is invalid', () => { + inMemoryFileSystem.writeFileSync('/.storage', 'abc'); + createFileStorage(); + expect(() => { + createFileStorage().getItem('foo'); + }).toThrow('Invalid file storage content'); + }); + + it('throws an error when path is missing', () => { + expect(() => { + new FileStorage({ enabled: true }); + }).toThrow('FileStorage path option is missing'); + }); + + it('does not initializes store if disabled', () => { + expect(() => { + new FileStorage({ enabled: false, path: '/.storage' }); + inMemoryFileSystem.readFileSync('/.storage', 'utf-8'); + }).toThrowError("ENOENT: no such file or directory, open '/.storage'"); + }); + + it('properly creates an empty storage when the storage file does not exist', () => { + createFileStorage(); + expect(inMemoryFileSystem.readFileSync('/.storage', 'utf-8')).toEqual('{}'); + }); + + it('properly creates a storage with the content of a existing file', () => { + inMemoryFileSystem.writeFileSync( + '/.storage', + '{ "foo": { "key": "value" } }' + ); + createFileStorage(); + expect(createFileStorage().getItem('foo')).toEqual({ key: 'value' }); + }); + + it('sets and gets item from storage', () => { + expect(() => { + createFileStorage().setItem('foo', { key: 'value' }); + }).not.toThrow(); + + expect(createFileStorage().getItem('foo')).toEqual({ key: 'value' }); + }); + + it('gets an unexisting item from storage', () => { + expect(createFileStorage().getItem('foo')).toEqual(undefined); + }); +}); + +function createFileStorage() { + return new FileStorage<'foo'>({ + enabled: true, + path: '/.storage', + fs: inMemoryFileSystem as any, + }); +} diff --git a/source/storage.ts b/source/storage.ts new file mode 100644 index 0000000..7497458 --- /dev/null +++ b/source/storage.ts @@ -0,0 +1,100 @@ +import * as fs from 'fs'; + +type PartialFileSystem = { + readFileSync(path: string, options: string): string; + writeFileSync(path: string, data: string): void; +}; + +export interface FileStorageOptions { + enabled: boolean; + path?: string; + fs?: PartialFileSystem; +} + +const EMPTY_STORAGE = {}; + +const DEFAULT_OPTIONS = { enabled: false }; + +export class FileStorage { + private fs: PartialFileSystem; + + constructor(public options: FileStorageOptions = DEFAULT_OPTIONS) { + this.fs = this.options.fs ?? fs; + + if (options.enabled) { + this.initializeStorage(); + } + } + + public isEmpty() { + return Object.keys(this.getStorage()).length === 0; + } + + public isInitialized() { + try { + if (!this.options.enabled || !this.options.path) return false; + this.fs.readFileSync(this.options.path, 'utf-8'); + return true; + } catch (e) { + return false; + } + } + + public getItem(key: TKeys): TData | undefined { + if (!this.isInitialized()) return; + + const storage = this.getStorage(); + return storage[key]; + } + + public setItem>(key: TKeys, data: TData) { + if (!this.isInitialized()) return; + + const storage = this.getStorage(); + storage[key] = data; + + if (this.options.path) { + this.persist(storage); + } + } + + public clear() { + this.persist(EMPTY_STORAGE); + } + + private persist(data: Record) { + if (this.options.path) { + this.fs.writeFileSync(this.options.path, JSON.stringify(data, null, 2)); + } + } + + private getStorage(): Record { + const fileContent = this.getSerializedStorage(); + try { + return JSON.parse(fileContent); + } catch (e) { + throw new Error('Invalid file storage content'); + } + } + + private initializeStorage(): void { + if (!this.options.path) { + throw new Error('FileStorage path option is missing'); + } + + if (!this.isInitialized()) { + this.persist(EMPTY_STORAGE); + } + } + + private getSerializedStorage(): string { + try { + if (!this.options.path) return '{}'; + return this.fs.readFileSync(this.options.path, 'utf-8'); + } catch (e) { + if (e.code !== 'ENOENT') throw e; + this.initializeStorage(); + return this.getSerializedStorage(); + } + } +} diff --git a/source/ui.ts b/source/ui.ts index c113d7a..3a7f6e7 100644 --- a/source/ui.ts +++ b/source/ui.ts @@ -6,6 +6,7 @@ import { OverrideManager } from './overrides'; import { ProxyManager } from './proxy'; import { ThrottlingManager } from './throttling'; import { formatMethodType } from './routes'; +import { FileStorage } from './storage'; function formatEndpoint(routePath: string, methodType: string) { return `${formatMethodType(methodType)} ${routePath}`; @@ -13,9 +14,6 @@ function formatEndpoint(routePath: string, methodType: string) { export class UIManager { private display: ReadLine; - private proxyManager: ProxyManager; - private overrideManager: OverrideManager; - private throttlingManager: ThrottlingManager; /** * Write a text to the user screen. @@ -131,6 +129,15 @@ export class UIManager { `- ${chalk.bold.white('p')} to toggle the connection to an endpoint` ); this.paragraph(`- ${chalk.bold.white('q')} to stop and quit the service`); + + if (this.fileStorage?.options.enabled) { + this.paragraph( + `- ${chalk.bold.white( + 'x' + )} to reset file storage permanently (restart to apply changes)` + ); + } + this.linebreak(); } @@ -143,9 +150,10 @@ export class UIManager { * @return The UI manager */ constructor( - proxyManager: ProxyManager, - throttlingManager: ThrottlingManager, - overrideManager: OverrideManager + public proxyManager: ProxyManager, + public throttlingManager: ThrottlingManager, + public overrideManager: OverrideManager, + private fileStorage?: FileStorage ) { this.display = readline.createInterface({ input: process.stdin,