lib/client.js (183 lines of code) (raw):

/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict' const messages = require('./messages') const OpenWhiskError = require('./openwhisk_error') const needle = require('needle') const url = require('url') const http = require('http') const retry = require('async-retry') /** * This implements a request-promise-like facade over the needle * library. There are two gaps between needle and rp that need to be * bridged: 1) convert `qs` into a query string; and 2) convert * needle's non-excepting >=400 statusCode responses into exceptions * */ const rp = opts => { let url = opts.url if (opts.qs) { url += '?' + Object.keys(opts.qs) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(opts.qs[key])}`) .join('&') } // it appears that certain call paths from our code do not set the // opts.json field to true; rp is apparently more resilient to // this situation than needle opts.json = true return needle(opts.method.toLowerCase(), // needle takes e.g. 'put' not 'PUT' url, opts.body || opts.params, opts) .then(resp => { if (resp.statusCode >= 400) { // we turn >=400 statusCode responses into exceptions const error = new Error(resp.body.error || resp.statusMessage) error.statusCode = resp.statusCode // the http status code error.options = opts // the code below requires access to the input opts error.error = resp.body // the error body throw error } else { // otherwise, the response body is the expected return value return resp.body } }) .catch(err => { // map any network/nodejs internal exception to the error structure expected by handleErrors() err.error = err.error || { error: err.message } err.options = err.options || opts throw err }) } const rpWithRetry = opts => { return retry(bail => { // will retry on exception return rp(opts) }, opts.retry) } class Client { /** * @constructor * @param {Object} options - options of the Client * @param {string} [options.api] * @param {string} [options.api_key] * @param {string} [options.apihost] * @param {string} [options.apiversion] * @param {string} [options.namespace] * @param {boolean} [options.ignore_certs] * @param {string} [options.apigw_token] * @param {string} [options.apigw_space_guid] * @param {Function} [options.auth_handler] * @param {boolean} [options.noUserAgent] * @param {string} [options.cert] * @param {string} [options.key] * @param {object} [options.retry] * @param {number} [options.retry.retries] Number of retries on top of the initial request, default is 2. * @param {number} [options.retry.factor] Exponential factor, default is 2. * @param {number} [options.retry.minTimeout] Milliseconds before the first retry, default is 100. * @param {number} [options.retry.maxTimeout] Max milliseconds in between two retries, default is infinity. * @param {boolean} [options.retry.randomize] Randomizes the timeouts by multiplying with a factor between 1 to 2. Default is true. * @param {Function} [options.retry.onRetry] An optional function that is invoked after a new retry is performed. It's passed the Error that triggered it as a parameter. */ constructor (options) { this.options = this.parseOptions(options || {}) } parseOptions (options) { const apiKey = options.api_key || process.env['__OW_API_KEY'] const ignoreCerts = options.ignore_certs || (process.env['__OW_IGNORE_CERTS'] ? process.env['__OW_IGNORE_CERTS'].toLowerCase() === 'true' : false) // gather proxy settings if behind a firewall const proxy = options.proxy || process.env.PROXY || process.env.proxy || process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy // custom HTTP agent const agent = options.agent // if apiversion is available, use it const apiversion = options.apiversion || 'v1' // if apihost is available, parse this into full API url const api = options.api || this.urlFromApihost(options.apihost || process.env['__OW_API_HOST'], apiversion) // optional tokens for API GW service const apigwToken = options.apigw_token || process.env['__OW_APIGW_TOKEN'] let apigwSpaceGuid = options.apigw_space_guid || process.env['__OW_APIGW_SPACE_GUID'] // unless space is explicitly passed, default to using auth uuid. if (apigwToken && !apigwSpaceGuid) { apigwSpaceGuid = apiKey.split(':')[0] } if (!apiKey && !options.auth_handler) { throw new Error(`${messages.INVALID_OPTIONS_ERROR} Missing api_key parameter or token plugin.`) } else if (!api) { throw new Error(`${messages.INVALID_OPTIONS_ERROR} Missing either api or apihost parameters.`) } // gather retry options const retry = options.retry if (retry && typeof options.retry !== 'object') { throw new Error(`${messages.INVALID_OPTIONS_ERROR} 'retry' option must be an object, e.g. '{ retries: 2 }'.`) } if (retry) { // overwrite async-retry defaults, see https://github.com/vercel/async-retry#api for more details retry.retries = retry.retries || 2 retry.factor = retry.factor || 2 retry.minTimeout = retry.minTimeout || 100 retry.maxTimeout = retry.maxTimeout || Infinity retry.randomize = retry.randomize || true } return { apiKey: apiKey, api, apiVersion: apiversion, ignoreCerts: ignoreCerts, namespace: options.namespace, apigwToken: apigwToken, apigwSpaceGuid: apigwSpaceGuid, authHandler: options.auth_handler, noUserAgent: options.noUserAgent, cert: options.cert, key: options.key, proxy, agent, retry } } urlFromApihost (apihost, apiversion = 'v1') { if (!apihost) return apihost let url = `${apihost}/api/${apiversion}/` // if apihost does not the protocol, assume HTTPS if (!url.match(/http(s)?:\/\//)) { url = `https://${url}` } return url } request (method, path, options) { const params = this.params(method, path, options) return params.then(req => { if (req.retry) { return rpWithRetry(req) } return rp(req) }).catch(err => this.handleErrors(err)) } params (method, path, options) { return this.authHeader().then(header => { const parms = Object.assign({ json: true, method: method, url: this.pathUrl(path), rejectUnauthorized: !this.options.ignoreCerts }, options) parms.headers = Object.assign({ 'User-Agent': (options && options['User-Agent']) || process.env['__OW_USER_AGENT'] || 'openwhisk-client-js', Authorization: header }, parms.headers) if (this.options.cert && this.options.key) { parms.cert = this.options.cert parms.key = this.options.key } if (this.options.noUserAgent || parms.noUserAgent) { // caller asked for no user agent? parms.headers['User-Agent'] = undefined } if (typeof this.options.namespace === 'string') { // identify namespace targeting a public/shared entity parms.headers['x-namespace-id'] = this.options.namespace } if (process.env['__OW_TRANSACTION_ID']) { parms.headers['x-request-id'] = process.env['__OW_TRANSACTION_ID'] } if (this.options.proxy) { parms.proxy = this.options.proxy } if (this.options.agent) { parms.agent = this.options.agent } if (this.options.retry) { parms.retry = this.options.retry } return parms }) } pathUrl (urlPath) { const endpoint = this.apiUrl() endpoint.pathname = url.resolve(endpoint.pathname, urlPath) return url.format(endpoint) } apiUrl () { return url.parse( this.options.api.endsWith('/') ? this.options.api : this.options.api + '/' ) } authHeader () { if (this.options.authHandler) { return this.options.authHandler.getAuthHeader() } else { const apiKeyBase64 = Buffer.from(this.options.apiKey).toString('base64') return Promise.resolve(`Basic ${apiKeyBase64}`) } } handleErrors (reason) { let message = `Unknown Error From API: ${reason.message}` if (reason.hasOwnProperty('statusCode')) { const responseError = this.errMessage(reason.error) message = `${reason.options.method} ${reason.options.url} Returned HTTP ${reason.statusCode} (${http.STATUS_CODES[reason.statusCode]}) --> "${responseError}"` } throw new OpenWhiskError(message, reason.error, reason.statusCode) } // Error messages might be returned from platform or using custom // invocation result response from action. errMessage (error) { if (!error) return 'Response Missing Error Message.' if (typeof error.error === 'string') { return error.error } else if (error.response && error.response.result) { const result = error.response.result if (result.error) { if (typeof result.error === 'string') { return result.error } else if (typeof result.error.error === 'string') { return result.error.error } else if (result.error.statusCode) { return `application error, status code: ${result.error.statusCode}` } } } return 'Response Missing Error Message.' } } module.exports = Client