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}`
}
)
}
}