diff --git a/ghost/adapter-manager/.eslintrc.js b/ghost/adapter-manager/.eslintrc.js new file mode 100644 index 0000000000..6a5eab530d --- /dev/null +++ b/ghost/adapter-manager/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node', + ] +}; diff --git a/ghost/adapter-manager/LICENSE b/ghost/adapter-manager/LICENSE new file mode 100644 index 0000000000..a8ebdea81d --- /dev/null +++ b/ghost/adapter-manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2020 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/adapter-manager/README.md b/ghost/adapter-manager/README.md new file mode 100644 index 0000000000..bfae0e9fdc --- /dev/null +++ b/ghost/adapter-manager/README.md @@ -0,0 +1,62 @@ +# Adapter Manager + +A manager for retrieving custom "adapters" - can be used to abstract away from custom implementations + +## Install + +`npm install @tryghost/adapter-manager --save` + +or + +`yarn add @tryghost/adapter-manager` + + +## Usage + +```js +const AdapterManager = require('@tryghost/adapter-manager'); + +const adapterManager = new AdapterManager({ + pathsToAdapters: [ + '/path/to/custom/adapters', + '/path/to/default/adapters' + ] +}); + +class MailAdapterBase { + someMethod() {} +} + +adapterManager.register('mail', MailAdapterBase); + +const mailAdapterInstance = adapterManager.getAdapter('mail', 'direct', mailConfig); + +mailAdapterInstance.someMethod(); +``` + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2020 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/adapter-manager/index.js b/ghost/adapter-manager/index.js new file mode 100644 index 0000000000..0ab3d648c4 --- /dev/null +++ b/ghost/adapter-manager/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/AdapterManager'); diff --git a/ghost/adapter-manager/lib/AdapterManager.js b/ghost/adapter-manager/lib/AdapterManager.js new file mode 100644 index 0000000000..97143c4d6b --- /dev/null +++ b/ghost/adapter-manager/lib/AdapterManager.js @@ -0,0 +1,138 @@ +const path = require('path'); +const errors = require('@tryghost/errors'); + +/** + * @typedef { function(new: Adapter, object) } AdapterConstructor + */ + +/** + * @typedef {object} Adapter + * @prop {string[]} requiredFns + */ + +module.exports = class AdapterManager { + /** + * @param {object} config + * @param {string[]} config.pathsToAdapters The paths to check, e.g. ['content/adapters', 'core/server/adapters'] + * @param {(path: string) => AdapterConstructor} config.loadAdapterFromPath A function to load adapters, e.g. global.require + */ + constructor({pathsToAdapters, loadAdapterFromPath}) { + /** + * @private + * @type {Object.} + */ + this.baseClasses = {}; + + /** + * @private + * @type {Object.>} + */ + this.instanceCache = {}; + + /** + * @private + * @type {string[]} + */ + this.pathsToAdapters = pathsToAdapters; + + /** + * @private + * @type {(path: string) => AdapterConstructor} + */ + this.loadAdapterFromPath = loadAdapterFromPath; + } + + /** + * Register an adapter type and the corresponding base class. Must be called before requesting adapters of that type + * + * @param {string} type The name for the type of adapter + * @param {AdapterConstructor} BaseClass The class from which all adapters of this type must extend + */ + registerAdapter(type, BaseClass) { + this.instanceCache[type] = {}; + this.baseClasses[type] = BaseClass; + } + + /** + * getAdapter + * + * @param {string} adapterType The type of adapter, e.g. "storage" or "scheduling" + * @param {string} adapterName The active adapter, e.g. "LocalFileStorage" + * @param {object} config The config the adapter should be instantiated with + * + * @returns {Adapter} The resolved and instantiated adapter + */ + getAdapter(adapterType, adapterName, config) { + if (!adapterType || !adapterName) { + throw new errors.IncorrectUsageError({ + message: 'getAdapter must be called with a adapterType and a name.' + }); + } + + const adapterCache = this.instanceCache[adapterType]; + + if (!adapterCache) { + throw new errors.NotFoundError({ + message: `Unknown adapter type ${adapterType}. Please register adapter.` + }); + } + + if (adapterCache[adapterName]) { + return adapterCache[adapterName]; + } + + /** @type AdapterConstructor */ + let Adapter; + for (const pathToAdapters of this.pathsToAdapters) { + const pathToAdapter = path.join(pathToAdapters, adapterType, adapterName); + try { + Adapter = this.loadAdapterFromPath(pathToAdapter); + if (Adapter) { + break; + } + } catch (err) { + // Catch runtime errors + if (err.code !== 'MODULE_NOT_FOUND') { + throw new errors.IncorrectUsageError({err}); + } + + // Catch missing dependencies BUT NOT missing adapter + if (!err.message.includes(pathToAdapter)) { + throw new errors.IncorrectUsageError({err}); + } + } + } + + if (!Adapter) { + throw new errors.IncorrectUsageError({ + message: `Unable to find ${adapterType} adapter ${adapterName} in ${this.pathsToAdapters}.` + }); + } + + const adapter = new Adapter(config); + + if (!(adapter instanceof this.baseClasses[adapterType])) { + throw new errors.IncorrectUsageError({ + message: `${adapterType} adapter ${adapterName} does not inherit from the base class.` + }); + } + + if (!adapter.requiredFns) { + throw new errors.IncorrectUsageError({ + message: `${adapterType} adapter ${adapterName} does not have the requiredFns.` + }); + } + + for (const requiredFn of adapter.requiredFns) { + if (typeof adapter[requiredFn] !== 'function') { + throw new errors.IncorrectUsageError({ + message: `${adapterType} adapter ${adapterName} is missing the ${requiredFn} method.` + }); + } + } + + adapterCache[adapterName] = adapter; + + return adapter; + } +}; diff --git a/ghost/adapter-manager/package.json b/ghost/adapter-manager/package.json new file mode 100644 index 0000000000..5c89cb1af8 --- /dev/null +++ b/ghost/adapter-manager/package.json @@ -0,0 +1,35 @@ +{ + "name": "@tryghost/adapter-manager", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost-Utils/tree/master/packages/adapter-manager", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "types": "types/index.d.ts", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint", + "pretest": "yarn types", + "types": "rm -r types && yarn tsc" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/mocha": "^7.0.2", + "@types/sinon": "^9.0.0", + "mocha": "7.1.1", + "should": "13.2.3", + "sinon": "9.0.1", + "typescript": "^3.8.3" + }, + "dependencies": { + "@tryghost/errors": "^0.1.1" + } +} diff --git a/ghost/adapter-manager/test/.eslintrc.js b/ghost/adapter-manager/test/.eslintrc.js new file mode 100644 index 0000000000..edb3308632 --- /dev/null +++ b/ghost/adapter-manager/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test', + ] +}; diff --git a/ghost/adapter-manager/test/AdapterManager.test.js b/ghost/adapter-manager/test/AdapterManager.test.js new file mode 100644 index 0000000000..2d1d644bc9 --- /dev/null +++ b/ghost/adapter-manager/test/AdapterManager.test.js @@ -0,0 +1,80 @@ +const sinon = require('sinon'); +const should = require('should'); +const AdapterManager = require('../'); + +class BaseMailAdapter { + constructor() { + this.requiredFns = ['someMethod']; + } +} + +class IncompleteMailAdapter extends BaseMailAdapter {} + +class CustomMailAdapter extends BaseMailAdapter { + someMethod() {} +} + +class DefaultMailAdapter extends BaseMailAdapter { + someMethod() {} +} + +describe('AdapterManager', function () { + it('Loads registered adapters in the order defined by the paths', function () { + const pathsToAdapters = [ + 'first/path', + 'second/path', + 'third/path' + ]; + + const loadAdapterFromPath = sinon.stub(); + loadAdapterFromPath.withArgs('first/path/mail/incomplete') + .returns(IncompleteMailAdapter); + loadAdapterFromPath.withArgs('second/path/mail/custom') + .returns(CustomMailAdapter); + loadAdapterFromPath.withArgs('third/path/mail/default') + .returns(DefaultMailAdapter); + loadAdapterFromPath.withArgs('first/path/mail/broken') + .throwsException('SHIT_GOT_REAL'); + + const adapterManager = new AdapterManager({ + loadAdapterFromPath, + pathsToAdapters + }); + + adapterManager.registerAdapter('mail', BaseMailAdapter); + + try { + const customAdapter = adapterManager.getAdapter('mail', 'custom', {}); + + should.ok(customAdapter instanceof BaseMailAdapter); + should.ok(customAdapter instanceof CustomMailAdapter); + } catch (err) { + should.fail(err, null, 'Should not have errored'); + } + + try { + const incompleteAdapter = adapterManager.getAdapter('mail', 'incomplete', {}); + should.fail(incompleteAdapter, null, 'Should not have created'); + } catch (err) { + should.exist(err); + should.equal(err.errorType, 'IncorrectUsageError'); + } + + try { + const defaultAdapter = adapterManager.getAdapter('mail', 'default', {}); + + should.ok(defaultAdapter instanceof BaseMailAdapter); + should.ok(defaultAdapter instanceof DefaultMailAdapter); + } catch (err) { + should.fail(err, null, 'Should not have errored'); + } + + try { + const brokenAdapter = adapterManager.getAdapter('mail', 'broken', {}); + should.fail(brokenAdapter, null, 'Should not have created'); + } catch (err) { + should.exist(err); + should.equal(err.errorType, 'IncorrectUsageError'); + } + }); +}); diff --git a/ghost/adapter-manager/tsconfig.json b/ghost/adapter-manager/tsconfig.json new file mode 100644 index 0000000000..03f41e871e --- /dev/null +++ b/ghost/adapter-manager/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "types", + "allowJs": true, + "checkJs": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es6" + }, + "exclude": [ + "node_modules" + ] +} diff --git a/ghost/adapter-manager/types/index.d.ts b/ghost/adapter-manager/types/index.d.ts new file mode 100644 index 0000000000..db6227abd1 --- /dev/null +++ b/ghost/adapter-manager/types/index.d.ts @@ -0,0 +1,2 @@ +declare const _exports: typeof import("./lib/AdapterManager"); +export = _exports; diff --git a/ghost/adapter-manager/types/lib/AdapterManager.d.ts b/ghost/adapter-manager/types/lib/AdapterManager.d.ts new file mode 100644 index 0000000000..3d195ef646 --- /dev/null +++ b/ghost/adapter-manager/types/lib/AdapterManager.d.ts @@ -0,0 +1,56 @@ +export = AdapterManager; +declare class AdapterManager { + /** + * @param {object} config + * @param {string[]} config.pathsToAdapters The paths to check, e.g. ['content/adapters', 'core/server/adapters'] + * @param {(path: string) => AdapterConstructor} config.loadAdapterFromPath A function to load adapters, e.g. global.require + */ + constructor({ pathsToAdapters, loadAdapterFromPath }: { + pathsToAdapters: string[]; + loadAdapterFromPath: (path: string) => new (arg1: any) => Adapter; + }); + /** + * @private + * @type {Object.} + */ + private baseClasses; + /** + * @private + * @type {Object.>} + */ + private instanceCache; + /** + * @private + * @type {string[]} + */ + private pathsToAdapters; + /** + * @private + * @type {(path: string) => AdapterConstructor} + */ + private loadAdapterFromPath; + /** + * Register an adapter type and the corresponding base class. Must be called before requesting adapters of that type + * + * @param {string} type The name for the type of adapter + * @param {AdapterConstructor} BaseClass The class from which all adapters of this type must extend + */ + registerAdapter(type: string, BaseClass: new (arg1: any) => Adapter): void; + /** + * getAdapter + * + * @param {string} adapterType The type of adapter, e.g. "storage" or "scheduling" + * @param {string} adapterName The active adapter, e.g. "LocalFileStorage" + * @param {object} config The config the adapter should be instantiated with + * + * @returns {Adapter} The resolved and instantiated adapter + */ + getAdapter(adapterType: string, adapterName: string, config: any): Adapter; +} +declare namespace AdapterManager { + export { AdapterConstructor, Adapter }; +} +type Adapter = { + requiredFns: string[]; +}; +type AdapterConstructor = new (arg1: any) => Adapter; diff --git a/ghost/adapter-manager/types/test/AdapterManager.test.d.ts b/ghost/adapter-manager/types/test/AdapterManager.test.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/ghost/adapter-manager/types/test/AdapterManager.test.d.ts @@ -0,0 +1 @@ +export {};