octoblu/node-meshblu-http

View on GitHub
src/meshblu-http.coffee

Summary

Maintainability
Test Coverage
_     = require 'lodash'
url   = require 'url'
debug = require('debug')('meshblu-http')
stableStringify = require 'json-stable-stringify'

class MeshbluHttp
  @SUBSCRIPTION_TYPES = [
    'broadcast'
    'sent'
    'received'
    'config'
    'broadcast.received'
    'broadcast.sent'
    'configure.received'
    'configure.sent'
    'message.received'
    'message.sent'
  ]

  constructor: (options={}, @dependencies={}) ->
    options = _.cloneDeep options
    {
      bearerToken
      uuid
      token
      hostname
      port
      protocol
      domain
      service
      secure
      resolveSrv
      auth
      @raw
      @keepAlive
      @gzip
      @timeout
      @serviceName
    } = options
    @keepAlive ?= true
    @gzip ?= true

    throw new Error 'cannot provide uuid, token, and bearerToken' if (uuid? || token?) && bearerToken?
    throw new Error 'a uuid is provided but the token is not' if uuid? && !token? && !bearerToken?
    throw new Error 'a token is provided but the uuid is not' if token? && !uuid? && !bearerToken?

    auth ?= {username: uuid, password: token} if uuid? && token?
    auth = { bearer: bearerToken } if bearerToken?

    {request, @MeshbluRequest, @NodeRSA} = @dependencies
    @MeshbluRequest ?= require './meshblu-request'
    @NodeRSA        ?= require 'node-rsa'
    @request = @_buildRequest {request, protocol, hostname, port, service, domain, secure, resolveSrv, auth}

  authenticate: (callback) =>
    options = @_getDefaultRequestOptions()

    @request.post "/authenticate", options, (error, response, body) =>
      debug "authenticate", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  createHook: (uuid, type, url, callback) =>
    error = new Error "Hook type not supported. supported types are: #{MeshbluHttp.SUBSCRIPTION_TYPES.join ', '}"
    return callback error unless type in MeshbluHttp.SUBSCRIPTION_TYPES

    updateRequest =
      $addToSet:
        "meshblu.forwarders.#{type}":
          type: 'webhook'
          url: url
          method: 'POST',
          generateAndForwardMeshbluCredentials: true

    @updateDangerously(uuid, updateRequest, callback)

  createSubscription: ({subscriberUuid, emitterUuid, type}, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback

    url = @_subscriptionUrl {subscriberUuid, emitterUuid, type}
    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.post url, options, (error, response, body) =>
      debug 'createSubscription', error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  deleteSubscriptions: ({subscriberUuid, emitterUuid, type}, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback

    url = "/v2/devices/#{subscriberUuid}/subscriptions"
    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers
    options.json = { emitterUuid, type }

    @request.delete url, options, (error, response, body) =>
      debug 'deleteSubscriptions', error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  deleteSubscription: ({subscriberUuid, emitterUuid, type}, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback

    url = @_subscriptionUrl {subscriberUuid, emitterUuid, type}
    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.delete url, options, (error, response, body) =>
      debug 'deleteSubscription', error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  device: (uuid, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}
    @_device uuid, metadata, callback

  _device: (uuid, metadata, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.get "/v2/devices/#{uuid}", options, (error, response, body) =>
      debug "device", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  devices: (query={}, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    @_devices query, metadata, callback

  findAndUpdate: (uuid, params, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    options = @_getDefaultRequestOptions()
    options.json = params
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.put "/v2/devices/#{uuid}/find-and-update", options, (error, response, body) =>
      debug "update", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  generateAndStoreToken: (uuid, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}
    params = true
    @_generateAndStoreToken uuid, params, metadata, callback

  generateAndStoreTokenWithOptions: (uuid, params, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    @_generateAndStoreToken uuid, params, metadata, callback

  _generateAndStoreToken: (uuid, params, metadata, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.json = params
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers
    @request.post "/devices/#{uuid}/tokens", options, (error, response, body) =>
      debug "generateAndStoreToken", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  generateKeyPair: =>
    key = new @NodeRSA()
    key.generateKeyPair()

    privateKey: key.exportKey('private'), publicKey: key.exportKey('public')

  getServerPublicKey: (callback) =>
    options = @_getDefaultRequestOptions()
    @request.get '/publickey', options, (error, response, body) =>
      body = _.get body, 'publicKey'
      @_handleResponse {error, response, body}, callback

  healthcheck: (callback=->) =>
    options = @_getDefaultRequestOptions()
    @request.get '/healthcheck', options, (error, response) =>
      return callback error if error?
      healthy = response.statusCode == 200
      return callback null, healthy, response.statusCode

  message: (message, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    @_message message, metadata, callback

  mydevices: (query={}, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.qs = query

    @request.get "/mydevices", options, (error, response, body) =>
      debug "mydevices", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  publicKey: (deviceUuid, callback=->) =>
    options = @_getDefaultRequestOptions()

    @request.get "/devices/#{deviceUuid}/publickey", options, (error, response, body) =>
      debug "publicKey", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  register: (device, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.json = device

    @request.post "/devices", options, (error, response, body={}) =>
      debug "register", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  resetToken: (deviceUuid, callback=->) =>
    options = @_getDefaultRequestOptions()
    url = "/devices/#{deviceUuid}/token"
    @request.post url, options, (error, response, body) =>
      debug 'resetToken', error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  revokeToken: (deviceUuid, deviceToken, callback=->) =>
    options = @_getDefaultRequestOptions()

    @request.delete "/devices/#{deviceUuid}/tokens/#{deviceToken}", options, (error, response, body={}) =>
      debug "revokeToken", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  revokeTokenByQuery: (deviceUuid, query, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.qs = query

    @request.delete "/devices/#{deviceUuid}/tokens", options, (error, response, body={}) =>
      debug "revokeToken", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  search: (query, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers
    options.json = query
    @request.post "/search/devices", options, (error, response, body) =>
      debug 'search', error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  searchTokens: (query, metadata, callback) =>
    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers
    options.json = query
    @request.post "/search/tokens", options, (error, response, body) =>
      debug 'searchTokens', error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  setPrivateKey: (privateKey) =>
    @privateKey = new @NodeRSA privateKey

  sign: (data) =>
    @privateKey.sign(stableStringify(data)).toString('base64')

  subscriptions: (uuid, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}
    @_subscriptions uuid, metadata, callback

  unregister: (device, callback=->) =>
    options = @_getDefaultRequestOptions()

    @request.delete "/devices/#{device.uuid}", options, (error, response, body) =>
      debug "unregister", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  update: (uuid, params, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    @_update uuid, params, metadata, callback

  updateDangerously: (uuid, params, rest...) =>
    [callback] = rest
    [metadata, callback] = rest if _.isPlainObject callback
    metadata ?= {}

    @_updateDangerously uuid, params, metadata, callback

  _updateDangerously: (uuid, params, metadata, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.json = params
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.put "/v2/devices/#{uuid}", options, (error, response, body) =>
      debug "updated", uuid, JSON.stringify(params)
      debug "update", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  verify: (message, signature) =>
    @privateKey.verify stableStringify(message), signature, 'utf8', 'base64'

  whoami: (callback=->) =>
    options = @_getDefaultRequestOptions()

    @request.get "/v2/whoami", options, (error, response, body) =>
      debug "whoami", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  _assertNoSrv: ({service, domain, secure}) =>
    throw new Error('domain property only applies when resolveSrv is true')  if domain?
    throw new Error('service property only applies when resolveSrv is true') if service?
    throw new Error('secure property only applies when resolveSrv is true')  if secure?

  _assertNoUrl: ({protocol, hostname, port}) =>
    throw new Error('protocol property only applies when resolveSrv is false') if protocol?
    throw new Error('hostname property only applies when resolveSrv is false') if hostname?
    throw new Error('port property only applies when resolveSrv is false')     if port?

  _buildRequest: ({request, protocol, hostname, port, service, domain, secure, resolveSrv, auth}) =>
    return request if request?

    return @_buildSrvRequest({protocol, hostname, port, service, domain, secure, auth}) if resolveSrv
    return @_buildUrlRequest({protocol, hostname, port, service, domain, secure, auth})

  _buildSrvRequest: ({protocol, hostname, port, service, domain, secure, auth}) =>
    @_assertNoUrl({protocol, hostname, port})
    service ?= 'meshblu'
    domain ?= 'octoblu.com'
    secure ?= true
    request = {}
    request.auth = auth if auth?
    request.timeout = @timeout if @timeout?
    return new @MeshbluRequest {resolveSrv: true, service, domain, secure, request}

  _buildUrlRequest: ({protocol, hostname, port, service, domain, secure, auth}) =>
    @_assertNoSrv({service, domain, secure})
    protocol ?= 'https'
    hostname ?= 'meshblu.octoblu.com'
    port     ?= 443
    try port = parseInt port
    request = {}
    request.auth = auth if auth?
    request.timeout = @timeout if @timeout?
    return new @MeshbluRequest {resolveSrv: false, protocol, hostname, port, request }

  _devices: (query, metadata, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.qs = query

    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.get "/v2/devices", options, (error, response, body) =>
      debug "devices", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  _getDefaultHeaders: =>
    return unless @serviceName?
    return {
      'x-meshblu-service-name': @serviceName
    }

  _getDefaultRequestOptions: =>
    headers = @_getDefaultHeaders()
    options = {
      json: true
      forever: @keepAlive
      gzip: @gzip
    }
    options.headers = headers if headers?
    return options

  _getMetadataHeaders: (metadata) =>
    _.transform metadata, (newMetadata, value, key) =>
      kebabKey = _.kebabCase key
      newMetadata["x-meshblu-#{kebabKey}"] = @_possiblySerializeHeaderValue value
      return true
    , {}

  _getRawRequestOptions: =>
    headers = @_getDefaultHeaders() ? {}
    headers['content-type'] = 'application/json'
    return {
      json: false
      headers
    }

  _handleError: ({message,code,response}, callback) =>
    message ?= 'Unknown Error Occurred'
    response = response?.toJSON?()
    debug 'handling error', JSON.stringify({ message, code, response }, null, 2)
    error = @_userError code, message, response
    callback error

  _handleResponse: ({error, response, body}, callback) =>
    return @_handleError { message: error.message, code: error?.code, response }, callback if error?
    code = response?.statusCode

    if response?.headers?['x-meshblu-error']?
      error = JSON.parse response.headers['x-meshblu-error']
      return @_handleError { message: error.message, response, code }, callback

    if body?.error?
      return @_handleError { message: body.error, response, code }, callback

    if code >= 400
      message = body if _.isString body
      message ?= "Invalid Response Code #{code}"
      return @_handleError { message, response, code }, callback

    callback null, body

  _message: (message, metadata, callback=->) =>
    if @raw
      options = @_getRawRequestOptions()
      options.body = message
    else
      options = @_getDefaultRequestOptions()
      options.json = message

    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.post "/messages", options, (error, response, body) =>
      debug "message", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  # because request doesn't serialize arrays correctly for headers.
  _possiblySerializeHeaderValue: (value) =>
    return value if _.isString value
    return value if _.isBoolean value
    return value if _.isNumber value
    return JSON.stringify value

  _subscriptions: (uuid, metadata, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.get "/v2/devices/#{uuid}/subscriptions", options, (error, response, body) =>
      debug "subscriptions", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  _subscriptionUrl: (options) =>
    {subscriberUuid, emitterUuid, type} = options
    "/v2/devices/#{subscriberUuid}/subscriptions/#{emitterUuid}/#{type}"

  _update: (uuid, params, metadata, callback=->) =>
    options = @_getDefaultRequestOptions()
    options.json = params
    options.headers = _.extend {}, @_getMetadataHeaders(metadata), options.headers

    @request.patch "/v2/devices/#{uuid}", options, (error, response, body) =>
      debug "update", error, JSON.stringify(body)
      @_handleResponse {error, response, body}, callback

  _userError: (code, message, response) =>
    error = new Error message
    error.code = code
    error.response = response if response?
    error

module.exports = MeshbluHttp