hal.js

'use strict'

const http = require('http')
const https = require('https')
const url = require('url')

const HALError = require('./hal-error')

/**
 * Represents a HAL ressource and simplifies its manipulation.
 */
class HAL {
  /**
   * Constructs.
   *
   * @param {(String|Url)} URL of HAL resource
   * @param {Object} [content={}] default content of resource
   * @return {HAL} created instance
   */
  constructor (URL, content) {
    this.URL = url.parse(URL)
    this.content = {}
  }

  /**
   * @param {String} credentials in basic auth 'USER:PASS' format
   * @return {HAL} instance
   */
  auth (credentials) {
    this.credentials = credentials
    return this
  }

  /**
   * @param {Object} body instance body
   * @return {HAL} instance
   */
  body (body) {
    this.content = body
    return this
  }

  /**
   * A simple wrapper around 'http.request' that returns a Promise. The host name
   * is set by default to 'localhost'.
   *
   * @param  {String} method desired HTTP method (defaults to 'GET')
   * @param  {Object} headers desired headers
   * @return {Promise} promise resolved with an containing body and the actual response
   */
  _request (method, headers) {
    return new Promise((resolve, reject) => {
      let body = ''
      let secure = this.URL.protocol.toUpperCase().startsWith('HTTPS')
      let client = secure ? https : http
      let defaultPort = secure ? 443 : 80

      let req = client.request({
        auth: this.credentials,
        protocol: this.URL.protocol,
        hostname: this.URL.hostname,
        port: this.URL.port || defaultPort,
        path: this.URL.path,
        method: method,
        headers: Object.assign(HAL.DEFAULTHEADERS, headers)
      }, res => {
        res.on('data', data => { body += data })
        res.on('end', () => {
          if (body === '') {
            this.content = {}
          } else {
            try {
              this.content = JSON.parse(body)
            } catch (e) {
              reject(new HALError('Could not parse response!', HALError.ERROR_CODES.RESPONSE))
            }
          }
          resolve({
            body: this.content,
            response: res
          })
        })
      })

      if ((HAL.MODMETHODS.indexOf(method) >= 0) && this.content) {
        req.write(JSON.stringify(this.content))
      }
      req.on('error', err => reject(new HALError(err.message, HALError.ERROR_CODES.REQUEST)))
      req.end()
    })
  }

  /**
   * Core of POST and PUT operations to create/update the resource
   *
   * @param {String} method either PUT, PATCH or POST
   * @param {Object} [headers] optional request headers
   * @return {Promise} promise resolved with an containing body and the actual response
   */
  _change (method, headers) {
    return new Promise((resolve, reject) => {
      if (HAL.MODMETHODS.indexOf(method.toUpperCase()) < 0) {
        return reject(new HALError(`Method ${method} is invalid! (only PUT and POST)`, HALError.ERROR_CODES.REQUEST))
      }

      this._request(method, headers)
        .then(result => {
          let statusCode = result.response.statusCode
          if (statusCode >= 400) {
            return reject(new HALError(`Server not succeeded! (error ${statusCode})`, HALError.ERROR_CODES.RESPONSE))
          }

          this.content = Object.assign(result.body, this.content)
          this.URL = url.parse(this.link('self').href)
          resolve(this)
        }).catch(reject)
    })
  }

  /**
   * Fetches the links and them as embedded resources.
   *
   * @param {Number} [depth=-1] recursion depth
   * @return {Promise} promise resolved when all links are fetched
   */
  _fetchEmbeddeds (depth) {
    return new Promise((resolve, reject) => {
      let d = depth || -1
      let linkNames = []

      let links = this.content['_links']
      // Do not follow links which point to self!
      linkNames = Object.keys(links).filter(linkName => links[linkName].href !== this.URL.href)

      Promise.all(
        linkNames
          .map(linkName => new HAL(links[linkName].href).auth(this.credentials))
          // Fetch that link
          .map(item => item.GET(d - 1))
      ).then(result => {
        let final = {}
        for (let i = 0; i < linkNames.length; i++) {
          final[linkNames[i]] = result[i].content
        }
        resolve(final)
      }).catch(reject)
    })
  }

  /**
   * Fetches a HAL resource and embeds its links recursively.
   * NOTE: to avoid circular references it is not recommended to have a
   * value higher than 0 for depth!
   *
   * @param  {Number} [depth=0] denotes levels of recursion (-1 for none)
   * @return {HAL} self instance with its content field filled
   */
  GET (depth) {
    return new Promise((resolve, reject) => {
      let d = depth || 0

      this._request('GET')
        .then(result => {
          // Save intermidiate results
          this.content = result.body

          if (d <= 0) {
            return null
          }

          return this._fetchEmbeddeds(d - 1)
        })
        .then(embeds => {
          if (embeds !== null) {
            this.content['_embedded'] = embeds
          }
          resolve(this)
        })
        .catch(reject)
    })
  }

  /**
   * Creates a new ressource from this instance
   *
   * @param {Object} [headers] optional headers
   * @return {Promise} promise resolves to this instance if succeeds
   */
  POST (headers) {
    return this._change('POST', headers)
  }

  /**
   * Updates the ressource
   *
   * @param {Object} [headers] optional headers
   * @return {Promise} promise resolves to this instance if succeeds
   */
  PUT (headers) {
    return this._change('PUT', headers)
  }

  /**
   * Patches the ressource
   *
   * @param {Object} [headers] optional headers
   * @return {Promise} promise resolves to this instance if succeeds
   */
  PATCH (headers) {
    return this._change('PATCH', headers)
  }

  /**
   * Deletes the resource
   *
   * @param {Object} [headers] optional headers
   * @return {Promise} promise resolving to this instance if succeeds
   */
  DELETE (headers) {
    return new Promise((resolve, reject) => {
      this._request('DELETE')
        .then(() => resolve(this))
        .catch(reject)
    })
  }

  /**
   * @param {String} key desired link name
   * @return {Object} link's value
   */
  link (key) {
    return this.content['_links'][key]
  }

  /**
   * Follows a link by name
   *
   * @param {String} link desired link
   * @param {int} [depth=0] embedded recursion link (as in #GET)
   * @return {Promise} promise resolved with instance of fetched link
   */
  follow (link, depth) {
    return new Promise((resolve, reject) => {
      let linkObject = this.link(link)
      if (!linkObject) return reject(new HALError(`Link ${link} does not exist!`))

      this.URL = url.parse(linkObject.href)
      this.GET(depth).then(resolve).catch(reject)
    })
  }

  /**
   * Associates this resource with given resource URL as given
   * resource name.
   *
   * NOTE: this method works properly with Spring data REST backends
   * and might not work properly with other backends.
   *
   * @param {String} resourceName desired resource name to associate
   * @param {String} resourceUrl localtion of resource to be associated
   * @return {Promise} promise resolved with instance of this resource
   */
  associate (resourceName, resourceUrl) {
    return this.body({[resourceName]: resourceUrl}).PATCH()
  }

  /**
   * Deep value retrieval from embedded items.
   *
   * The key is in dot notation, e.g. 'statuses.2.numericValue' would look
   * under embedded sensors, the second item, and finally its numericValue field.
   *
   * NOTE: if at any part of the chain, the given key is not found, the result
   * would explicitly be 'null' and no errors are thrown.
   *
   * @param {String} key of embedded item (in dot notation)
   * @return {Object} embedded item
   */
  embedded (key) {
    return key
      .split('.')
      .map(item => parseInt(item, 10) || item)
      .reduce((prev, cur) => prev ? prev[cur] : null, this.content['_embedded'])
  }
}
// Default headers passed along each request
Object.defineProperty(HAL, 'DEFAULTHEADERS', {
  enumerable: true,
  value: {
    Accept: 'application/hal+json',
    'Content-Type': 'application/hal+json'
  }
})
// List of HTTP methods which are meant for data modification
// These methods have the same logic in background
Object.defineProperty(HAL, 'MODMETHODS', {
  enumerable: false,
  value: ['POST', 'PUT', 'PATCH']
})

module.exports = HAL