Plugin.js

import Device from './Device.js'

import { BASE_URL, DELETE, checkResponse } from './Common.js'

/**
 * @classdesc CaptureBridge utilizes a plugin system for interacting with
 * hardware devices and vendor SDKs. Clients will interact with plugins via the
 * REST API using their unique plugin ID. This class provides basic methods
 * for working with these plugins such as obtaining a list of compatible devices
 * and managing the plugin's configuration.
 *
 * @see {@link CaptureBridge#plugins} Get all installed plugin.
 * @see {@link CaptureBridge#plugin} Get a single plugin by ID.
 */
class Plugin {
/**
 * @property {string} id - ID of the plugin, used when building REST endpoint paths.
 * @property {string} name - Human friendly name for the plugin.
 * @property {string} description - Human friendly description of plugin.
 * @property {string} version - {@link https://semver.org/|Semantic version} version of the plugin.
 * @property {string[]} methods - A plugin's list of supported RPC methods.
 * @property {boolean} ready - True if plugin has been loaded into memory and is ready to receive requests.
 * @property {object} defaultDevice - Default device to use for the plugin.
 * @property {string[]} configMethods - Methods required to implement configuration.
 */
  baseUrl
  id
  name
  description
  version
  methods = []
  ready = false
  defaultDevice = null
  configMethods = ['config_schema', 'get_config', 'set_config']

  /**
   * Instantiate a plugin.
   * @constructor
   * @param {object|string} plugin - plugin object as received from the API or a plugin ID string.
   * @param {string} plugin.id - ID of the plugin, used when building endpoint paths to call methods on the plugin.
   * @param {string} plugin.name - Human friendly name of plugin.
   * @param {string} plugin.description - Human friendly description of plugin.
   * @param {string} plugin.version - {@link https://semver.org/|Semantic version} version of the plugin.
   * @param {string[]} plugin.methods - plugin's capabilities.
   * @param {boolean} plugin.ready - True if plugin has been loaded into memory and is ready.
   * @param {string} [baseURL=BASE_URL] - Override the default protocol, domain, and port for the service.
   * @throws Will throw an Error the plugin argument is not a plugin ID (String) or an object.
   */
  constructor (plugin, baseUrl = BASE_URL) {
    this.baseUrl = baseUrl
    if (typeof plugin === 'string') {
      this.id = plugin
    } else if (typeof plugin === 'object') {
      Object.assign(this, {}, plugin)
    } else {
      throw new Error('Invalid properties supplied to Plugin constructor')
    }
  }

  /**
   * Get all devices for this plugin
   *
   * @method
   * @async
   * @see {@link https://local.capturebridge.net:9001/doc/#api-Plugin-GetPluginDevices|API Endpoint (/plugin/:pluginId/device)}
   * @returns {object[]} Array of {@link Device} objects.
   * @example
   * const plugin = await captureBridge.plugin('capture_camera_canon')
   * const devices = await plugin.devices()
   */
  async devices () {
    const response = await fetch(`${this.baseUrl}/plugin/${this.id}/device`)
    await checkResponse(response)
    const devices = await response.json()
    return devices.map(d => new Device(d, this, this.baseUrl))
  }

  /**
   * Get a device by ID for this plugin
   *
   * @method
   * @async
   * @see {@link https://local.capturebridge.net:9001/doc/#api-Plugin-GetPluginDevices|API Endpoint (/plugin/:pluginId/device)}
   * @param {string} id - Device ID
   * @returns {object?} Requested {@link Device} or null
   * @example
   * const plugin = await captureBridge.plugin('capture_camera_canon')
   * const device = await plugin.device('AWOOO56709')
   */
  async device (id) {
    const response = await fetch(`${this.baseUrl}/plugin/${this.id}/device`)
    const devices = await response.json()
    const device = devices.find(d => d.id === id)
    return device ? new Device(device, this, this.baseUrl) : null
  }

  async configSchema () {
    if (!await this.supportsConfig()) {
      throw new Error('Plugin does not support config')
    }
    const response = await fetch(`${this.baseUrl}/plugin/${this.id}/config/schema`)
    return await response.json()
  }

  async config () {
    if (!await this.supportsConfig()) {
      throw new Error('Plugin does not support config')
    }
    const response = await fetch(`${this.baseUrl}/plugin/${this.id}/config`)
    return await response.json()
  }

  async saveConfig (config) {
    if (!await this.supportsConfig()) {
      throw new Error('Plugin does not support config')
    }
    const options = {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(config)
    }
    const url = `${this.baseUrl}/plugin/${this.id}/config`
    const response = await fetch(url, options)
    return await response.json()
  }

  async supportsConfig () {
    await this.update()
    return this.configMethods.every(m => this.methods.includes(m))
  }

  /**
   * Shutdown a plugin
   * @method
   * @async
   * @see {@link https://local.capturebridge.net:9001/doc/#api-Plugin-ShutdownPlugin|API Endpoint (/plugin/:pluginId)}
   * @param {number} [timeout=10] - Timeout in seconds. Forcefully terminate
   * the plugin if it doesn't shutdown cleanly within the specified timeout. A
   * value of `0` will result in immediately terminating the plugin and should
   * be avoided unless the plugin is unresponsive.
   * @example
   * await plugin.shutdown()
   */
  async shutdown (timeout = 10) {
    const url = `${this.baseUrl}/plugin/${this.id}?timeout=${timeout}`
    const response = await fetch(url, DELETE)
    return await response.json()
  }

  async startup () {
    // See: ValidRD/wa_capture_bridge/issues/48
    // We need an explicit endpoint for this instead
    const devices = await this.devices()
    if (devices) {
      await this.update(true)
      return { status: 'starting' }
    }
    return { status: 'failed' }
  }

  /**
   * This class can be instantiated from either a plugin ID or a full plugin
   * object from an API request.
   * Because constructors cannot be async we use this method to hydrate the
   * plugin properties if needed.
   *
   * @private
   * @method
   */
  async update (force = false) {
    if (force || this.methods.length === 0) {
      const response = await fetch(`${this.baseUrl}/plugin`)
      const plugins = await response.json()
      const plugin = plugins.find(p => p.id === this.id)
      Object.assign(this, plugin)
    }
  }

  /**
   * Check if a plugin has an RPC method
   *
   * @param {string} method - An RPC method
   * @returns {boolean} Return true if plugin has the provided method.
   * @example
   * const canCapture = await plugin.hasMethod('capture')
   * @method
   */
  async hasMethod (method) {
    await this.update()
    return this.methods.includes(method)
  }

  async rpc (request) {
    const options = {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(request)
    }
    const url = `${this.baseUrl}/plugin/${this.id}/rpc`
    const response = await fetch(url, options)
    return await response.json()
  }

  async ping () {
    const response = await fetch(`${this.baseUrl}/plugin/${this.id}/ping`)
    return await response.json()
  }

  /**
   * Classes that extend this one will add their own methods to perform
   * operations on a device. They will call this method first to get a device
   * object to use for making calls to the appropriate endpoint.
   *
   * This method really should be "protected" in classical OOP terms but
   * Javascript does not currently have support for such scopes, and it's not
   * marked using the ES6 private modifier so that it can be inherited by it's
   * children.
   * @private
   * @method
   */
  async setupDevice (deviceOpt) {
    await this.update()

    if (deviceOpt instanceof Device) {
      this.defaultDevice = deviceOpt
      return this.defaultDevice
    }

    if (typeof deviceOpt === 'string') {
      this.defaultDevice = this.device(deviceOpt)
      return this.defaultDevice
    }

    if (!this.defaultDevice) {
      const devices = await this.devices()
      if (!devices.length) {
        throw new Error('No devices found for the plugin')
      }
      this.defaultDevice = devices[0]
      return this.defaultDevice
    }

    return this.defaultDevice
  }
}

export default Plugin