call_builder.js

import uri from 'urijs'
import safeBase64 from 'base64-url'
import { Keypair, hash } from './base'
import isString from 'lodash/isString'
import isNumber from 'lodash/isNumber'
import isArray from 'lodash/isArray'
import isObject from 'lodash/isObject'

const REQUEST_TARGET_HEADER = '(request-target)'
const SIGNED_HEADERS = [REQUEST_TARGET_HEADER, 'x-date']

/**
 * Creates a new {@link CallBuilder}.
 *
 * @param {axios} axios Instance of axios.
 * @param {Wallet} wallet User's wallet.
 *
 * @class CallBuilder
 */
export class CallBuilder {
  /**
   * Creates a CallBuilder instance.
   *
   * @constructor
   * @param {Object} axios Axios.js instance.
   * @param {ShelfNetwork} [sdk] ShelfNetwork SDK instance.
   */
  constructor (axios, sdk) {
    this._axios = axios
    this._sdk = sdk
    this._urlSegments = []
    this._customTimeout = null
    this._customHeaders = {}
  }

  /**
   * Append URL segment.
   *
   * @param {(string|number|string[])} segment URL path segment(s).
   * @param {object} [opts] Options.
   * @param {boolean} [opts.base64=false] Treat segment as a base64-encoded value.
   *
   * @return {CallBuilder} Self.
   */
  appendUrlSegment (segment, { base64 = false } = {}) {
    if (isArray(segment)) {
      segment.forEach((s) => this._appendUrlSegment(s, { base64 }))
    } else {
      this._appendUrlSegment(segment, { base64 })
    }

    return this
  }

  /**
   * Append an account ID to the URL.
   * Uses wallet's account ID by default.
   *
   * @param {string} [accountId] Custom account ID.
   * @return {CallBuilder} Self.
   */
  appendAccountId (accountId = null) {
    if (accountId) {
      if (!Keypair.isValidPublicKey(accountId)) {
        throw new Error('Invalid account ID.')
      }
    } else if (this._sdk.token) {
      accountId = this._sdk.token.accountId
    } else if (this._sdk.wallet) {
      accountId = this._sdk.wallet.accountId
    }

    if (!accountId) {
      throw new Error('No account ID provided.')
    }

    return this.appendUrlSegment(accountId)
  }

  _appendUrlSegment (segment, { base64 = false } = {}) {
    if (isNumber(segment)) {
      this._urlSegments.push(segment)
    } else if (isString(segment)) {
      if (!base64 && segment.includes('/')) {
        // multiple segments in a single string e.g. "/foo/bar/x"
        let parsedLink = uri(segment)
        let segments = parsedLink.path()
          .split('/')
          .filter((x) => x.length > 0)
        segments.forEach((s) => this._urlSegments.push(s))
      } else if (base64) {
        this._urlSegments.push(safeBase64.escape(segment))
      } else {
        this._urlSegments.push(segment)
      }
    } else {
      throw new TypeError('Invalid segment.')
    }
  }

  /**
   * Attach token to this request.
   *
   * @param {Token} [opts.token] Use another token for the request.
   * @param {Wallet} [opts.wallet] Use another wallet for signature. Deprecated.
   *
   * @return {CallBuilder} Self.
   */
  withCredentials ({ wallet, token } = {}) {
    this._authRequired = true

    this._token = token || this._sdk.token
    this._wallet = wallet || this._sdk.wallet // deprecated

    return this
  }

  /**
   * Attach token to this request if any were provided.
   *
   * @param {Token} [opts.token] Use another token for the request.
   * @param {Wallet} [opts.wallet] Use another wallet for signature. Deprecated.
   *
   * @return {CallBuilder} Self.
   */
  withOptionalCredentials ({ token, wallet } = {}) {
    const hasToken = Boolean(token || this._sdk.token)
    const hasWallet = Boolean(wallet || this._sdk.wallet)

    if (hasToken || hasWallet) {
      return this.withCredentials(wallet)
    }

    return this
  }

  /**
   * Set a request timeout.
   *
   * @param {Number} timeout Request timeout.
   * @return {CallBuilder} Self.
   */
  withTimeout (timeout) {
    if (!isNumber(timeout)) {
      throw new TypeError('A timeout in milliseconds expected.')
    }

    this._customTimeout = timeout

    return this
  }

  /**
   * Set custom headers for the request.
   *
   * @param {object} headers Headers to send along the request.
   */
  withHeaders (headers) {
    if (!isObject(headers)) {
      throw new TypeError('Headers must be passed via an object.')
    }
    this._customHeaders = headers

    return this
  }

  /**
   * Perform a POST request.
   *
   * @param {Object} [data] Request body.
   * @return {Promise} Request result.
   */
  post (data) {
    let config = this._getRequestConfig({
      method: 'post',
      data
    })

    return this._axios(config)
  }

  /**
   * Perform a GET request.
   *
   * @param {Object} [query] Request body.
   * @return {Promise} Request result.
   */
  get (query) {
    let config = this._getRequestConfig({
      method: 'get',
      params: query
    })

    return this._axios(config)
  }

  /**
   * Perform a PUT request.
   *
   * @param {Object} [data] Request body.
   * @return {Promise} Request result.
   */
  put (data) {
    let config = this._getRequestConfig({
      method: 'put',
      data
    })

    return this._axios(config)
  }

  /**
   * Perform a PATCH request.
   *
   * @param {Object} [data] Request body.
   * @return {Promise} Request result.
   */
  patch (data) {
    let config = this._getRequestConfig({
      method: 'patch',
      data
    })

    return this._axios(config)
  }

  /**
   * Perform a DELETE request.
   *
   * @param {Object} [data] Request body.
   * @return {Promise} Request result.
   */
  delete (data) {
    let config = this._getRequestConfig({
      method: 'delete',
      data
    })

    return this._axios(config)
  }

  _getUrl () {
    return this._urlSegments.reduce((prev, next) => {
      return `${prev}/${encodeURIComponent(next)}`
    }, '')
  }

  _getRequestConfig (config) {
    config.url = this._getUrl()
    if (config.url === '') {
      config.url += '/'
    }

    if (this._authRequired) {
      if (this._token) {
        this._addBearerToken(config)
      } else if (this._wallet) {
        this._signRequest(config)
      } else {
        console.warn('This request requires authentication but no token were provided.')
      }
    }

    if (this._customTimeout) {
      config.timeout = this._customTimeout
    }

    config.headers = Object.assign(config.headers || {}, this._customHeaders)

    return config
  }

  _signRequest (config) {
    let date = new Date(this._getTimestamp() * 1000).toUTCString()
    let dateHeader = { 'x-date': date }
    config.headers = config.headers
      ? Object.assign(config.headers, dateHeader)
      : dateHeader

    let digest = hash(this._requestDigest(config))
    let signature = this._wallet.keypair.sign(digest).toString('base64')
    let keyId = this._wallet.accountId
    let algorithm = 'ed25519-sha256'
    config.headers.signature = `keyId="${keyId}",algorithm="${algorithm}",headers="${SIGNED_HEADERS.join(' ')}",signature="${signature}"`
  }

  _requestDigest (config) {
    let toSign = SIGNED_HEADERS.map(header => {
      header = header.toLowerCase()
      if (header === REQUEST_TARGET_HEADER) {
        let method = config.method.toLowerCase()
        let endpoint = decodeURI(config.url)

        return `${REQUEST_TARGET_HEADER}: ${method.toLowerCase()} ${endpoint}`
      };
      let value = config.headers[header]

      return `${header}: ${value}`
    })

    return toSign.join('\n')
  }

  _getTimestamp () {
    let now = Math.floor(new Date().getTime() / 1000)
    return now - this._sdk.clockDiff
  }

  _addBearerToken (config) {
    config.headers = Object.assign(
      config.headers || {},
      {
        authorization: `Bearer ${this._token.rawJWT}`
      }
    )
  }
}