Source: Plugin.js

'use strict'

/**
 * Dependencies
 * @ignore
 */
const _ = require('lodash')
const path = require('path')
const injector = require('./injectorInstance')
const callsite = require('callsite')

/**
 * Symbols
 * @ignore
 */
let init = Symbol()

/**
 * Plugin
 *
 * @class
 * Instances of Plugin expose an API which can be accessed by the developer to
 * define plugins, along with methods used by the Registry to manage plugin lifecycle.
 *
 * The developer API can be used to:
 *
 *    - register node modules as dependencies on the injector
 *    - include files in the plugin definition for managing large plugins
 *    - define dependencies using factory methods
 *    - alias dependencies
 *    - create adapters to determine which implementation of an interface to
 *      use at runtime
 *    - access dependencies for mutation without creating new dependencies
 *    - extend the plugin API
 *    - register callbacks for managing plugin lifecycle
 *
 * @example <caption>Metadata</caption>
 * {
 *   version: '0.0.1',
 *   dependencies: {
 *     '<PLUGIN NAME>': '>=1.2.3'
 *   }
 * }
 *
 */
class Plugin {

  /**
   * Constructor
   *
   * @description
   * Initialize a new Plugin instance.
   *
   * @param {string} name - The name of the plugin
   * @param {Object} metadata - The plugin metadata
   *
   */
  constructor (name, metadata) {
    this.name = name
    this.metadata = metadata
  }

  /**
   * Require
   *
   * @description
   * Adds dependencies to the injector by loading node modules. Accepts a string,
   * array or object. By passing an object you can alias the package name.
   *
   * @param {(string|Array|Object)} modules - Modules to require
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * app.plugin(<NAME>, <METADATA>)
   *   .initializer(function (plugin) {
   *     plugin.require('express')
   *
   *     plugin.require([
   *       'crypto',
   *       'ioredis'
   *     ])
   *
   *     plugin.require({
   *       '_': 'lodash',
   *       'fs': 'fs-extra',
   *       'myLibrary': './myLibrary'
   *     })
   *   })
   */
  require (modules) {
    if (typeof modules === 'string') {
      let name = modules
      modules = {}
      modules[name] = name
    }

    if (Array.isArray(modules)) {
      modules = _.zipObject(modules, modules)
    }

    Object.keys(modules).forEach(key => {
      this.module(key, modules[key])
    })

    return this
  }

  /**
   * Include
   *
   * @description
   * The bootstrapping process searches through the plugins directory looking for
   * plugins to load. To make a new plugin you simply create a sub-directory with an
   * index.js file. Additional files in the directory are incorporated using the
   * include method. This allows developers to separate out one plugin into an
   * arbitrary number of files.
   *
   * @param {string} filename - Relative path to included file.
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * // index.js
   * 'use strict'
   *
   * module.exports = function (sunstone) {
   *   app.plugin('MyResource', {
   *     version: '0.0.1',
   *     dependencies: {
   *       'Server': '0.0.1'
   *     }
   *   })
   *   .initializer(function (plugin) {
   *     plugin
   *       .include('./other')
   *       .include('./yetanother')
   *   })
   * }
   *
   * // other.js
   * 'use strict'
   *
   * module.exports = function (plugin) {
   *
   *   plugin
   *     .factory('MyModel', function (a, b) {
   *       // ...
   *     })
   *     .router('MyModelRouter', function (MyModel) {
   *       // ...
   *     })
   *
   * }
   */
  include (filename) {
    // prepend file path from call stack onto the given, possibly relative, filename
    let caller = callsite()[1]
    let callerpath = caller.getFileName()
    let filepath = path.join(path.dirname(callerpath), filename)

    require(filepath)(this)

    return this
  }

  /**
   * Module
   *
   * @description
   * This is used internally by `plugin.require()` to register modules with
   * the type "module". This is important to maintain a distinction between
   * components provided by plugins defined in the host (or extending applications)
   * and components that originate from node modules.
   *
   * @param {string} name - Dependency name
   * @param {string} node_module - Name of external node dependency
   * @private
   */
  module (name, node_module) {
    injector.register({
      name,
      type: 'module',
      plugin: this.name,
      fn: function () {
        return require(node_module)
      }
    })
  }

  /**
   * Factory
   *
   * @description
   * The factory method registers a new dependency on the injector, validates it, and
   * determines which other dependencies it requires.
   *
   * The first argument is the name of the new dependency and the second argument is a
   * function that returns the value of the dependency. However, this dependency is not
   * invoked at the time the dependency is registered.
   *
   * Getting a dependency from the Injector invokes the function and stores the return
   * value.
   *
   * @param {string} name - Dependency name
   * @param {function} fn - Factory function
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * plugin
   *   .factory('one', function () {
   *     return 1
   *   })
   *   .factory('two', function () {
   *     return 2
   *   })
   *   .factory('oneplustwo', function (one, two) {
   *     return one + two
   *   })
   *
   */
  factory (name, fn) {
    injector.register({
      name,
      type: 'factory',
      plugin: this.name,
      fn
    })

    return this
  }

  /**
   * Adapter
   *
   * @description
   * Create factories that determine which implementation
   * to use at injection time.
   *
   * @param {string} name - Dependency name
   * @param {function} fn - Factory function
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * plugin
   *   .factory('RedisResource', function () {})
   *   .factory('MongoResource', function () {})
   *   .adapter('Resource', function (injector, settings) {
   *      // where settings.property is 'RedisResource' or 'MongoResource'
   *      return injector.get(settings.property)
   *   })
   *   .factory('User', function (Resource) {})
   *
   */
  adapter (name, fn) {
    injector.register({
      name,
      type: 'adapter',
      plugin: this.name,
      fn
    })

    return this
  }

  /**
   * Alias
   *
   * @description
   * Alias creates a reference to another item on the injector
   * within its own injector object. When the alias is injected
   * through the use of the injector.get() method it calls the
   * aliased dependency and creates a reference to the instance
   * in the dependency.value field.
   *
   * @param {string} alias - New dependency name
   * @param {string} name - Existing dependency name
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * plugin
   *   .factory('myDependency', function () {
   *     // ...
   *   })
   *   .alias('myAlias', 'myDependency')
   */
  alias (alias, name) {
    injector.register({
      name: alias,
      type: 'alias',
      plugin: this.name,
      fn: () => {
        return injector.get(name)
      }
    })

    return this
  }

  /**
   * Extension
   *
   * @description
   * The idea of an extension is that you can access some component
   * to use it's API without registering anything on the injector.
   *
   * This is useful for things like modifying a model's schema or
   * registering event handlers on an event emitter.
   *
   * @param {string} name - Dependency name
   * @param {function} fn - Mutator function
   * @returns {this}
   *
   * @example <caption>Extending Data Schema</caption>
   *
   * // Given a plugin created as follows
   * app.plugin('Default API', <METADATA>)
   *   .initializer(function (plugin) {
   *     .factory('User', function (Resource) {
   *       class User extends Resource {
   *         static get schema () {
   *           return Object.assign({}, super.schema, {
   *             name: { type: 'string' },
   *             email: { type: 'string', format: 'email' }
   *           })
   *         }
   *       }
   *
   *       return User
   *     })
   *   })
   *
   * app.plugin('My Project', <METADATA>)
   *   .initializer(function (plugin) {
   *     plugin.extension('UserExtension', function (User) {
   *       User.extendSchema({
   *         domainSpecificAttribute: { type: 'whatever', ... }
   *       })
   *     })
   *   })
   *
   * @example <caption>Adding Event Handler</caption>
   *
   * app.plugin(<NAME>, <METADATA>)
   *   .initializer(function (plugin) {
   *     plugin.factory('emitter', function () {
   *       return new EventEmitter()
   *     })
   *   })
   *
   * app.plugin('My Project', <METADATA>)
   *   .initializer(function (plugin) {
   *     plugin.extension('CustomEventHandlers', function (emitter) {
   *       emitter.on('ready', function (event) {
   *         // do something
   *       })
   *     })
   *   })
   */
  extension (name, fn) {
    injector.register({
      name,
      type: 'extension',
      plugin: this.name,
      fn
    })

    return this
  }

  /**
   * Assembler
   *
   * @param {string} name - Dependency name
   * @param {function} fn - Assembler function
   * @returns {this}
   *
   * @description
   * This can be used to define new types of components. For example, the core
   * framework probably doesn't need any knowledge of Express routers, but if you
   * wanted to define a specialized factory registrar for routers, you could do it
   * like so:
   *
   * ```js
   * app.plugin('server', {
   *   version: '0.0.0'
   * })
   * .initializer(function (plugin) {
   *   plugin.assembler('router', function (injector) {
   *     let plugin = this
   *     return function (name, factory) {
   *       injector.register({
   *         name,
   *         type: 'router',
   *         plugin: plugin.name,
   *         factory
   *       })
   *     })
   *   })
   * })
   * ```
   *
   * This makes a new dependency registrar called 'router' that can be used as follows:
   *
   * ```js
   * app.plugin('other', {
   *   version: '0.0.0'
   * })
   * .initializer(function (plugin) {
   *   plugin.router('SomeRouter', function (Router, SomeResource) {
   *     let router = new Router()
   *
   *     router.get('endpoint', function () {
   *       SomeResource
   *         .list(req.query)
   *         .then(function (results) {
   *           res.json(results)
   *         })
   *         .catch(error => next(error))
   *     })
   *
   *     return router
   *   })
   * })
   * ```
   *
   * The dependency inject can then be queried by this new "type" value.
   *
   * ```js
   * injector.filter({ type: 'router' })
   * ```
   *
   * @todo there should possibly be a way to create a starter method automatically for an assembler to save that boilerplate
   */
  assembler (name, fn) {
    this.constructor.prototype[name] = fn(injector, this)
    return this
  }

  /**
   * Lifecycle Management
   *
   * These methods are used to register lifecycle methods that will be called
   * by the plugin manager
   */

  /**
   * Initializer
   *
   * @description
   * Register an initializer function.
   *
   * @param {callback} callback - Initializer function
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * module.exports = function (app) {
   *   app.plugin('MyResource', {
   *     version: '0.0.1',
   *     dependencies: {
   *       'Server': '0.0.1'
   *     }
   *   })
   *   .initializer(function (plugin) {
   *     .include('./other')
   *     .include('./yetanother')
   *   })
   * }
   *
   */
  initializer (callback) {
    this[init] = callback
    return this
  }

  /**
   * Initialize
   *
   * @description
   * Invoke the initializer function, if it exists.
   *
   * @returns {this}
   */
  initialize () {
    let fn = this[init]

    if (fn) {
      fn(this)
    }

    return this
  }

  /**
   * Starter
   *
   * @description
   * Register an starter function.
   *
   * @param {function} callback - Starter function
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * app.plugin(<NAME>, <METADATA>)
   *   .initializer(function (plugin) {
   *     plugin.starter(function (injector, server) {
   *       injector
   *         .filter({ plugin: this.name, type: 'router' })
   *         .values()
   *         .forEach(router => {
   *           router.mount(server)
   *         })
   *     })
   *   })
   *
   */
  starter (fn) {
    injector.register({
      name: `${this.name}:starter`,
      type: 'callback',
      plugin: this.name,
      fn
    })

    return this
  }

  /**
   * Start
   *
   * @description
   * Invoke the starter function, if it exists.
   *
   * @returns {this}
   */
  start () {
    injector.invoke(`${this.name}:starter`)
    return this
  }

  /**
   * Stopper
   *
   * @description
   * Register an initializer function.
   *
   * @param {function} callback - Stopper function
   * @returns {this}
   *
   * @example <caption>Usage</caption>
   *
   * app.plugin(<NAME>, <METADATA>)
   *   .initializer(function (plugin) {
   *     plugin.stopper(function (injector, server) {
   *       // code to disable plugin
   *     })
   *   })
   *
   */
  stopper (fn) {
    injector.register({
      name: `${this.name}:stopper`,
      type: 'callback',
      plugin: this.name,
      fn
    })

    return this
  }

  /**
   * Stop
   *
   * @description
   * Invoke the stopper function, if it exists.
   *
   * @returns {this}
   */
  stop () {
    injector.invoke(`${this.name}:stopper`)
    return this
  }

}

/**
 * Exports
 */
module.exports = Plugin