/** * Copyright 2014 Google Inc. All Rights Reserved. * * Licensed 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. */ /*! * @module common/util */ 'use strict'; const createErrorClass = require('create-error-class'); const duplexify = require('duplexify'); const ent = require('ent'); const extend = require('extend'); const googleAuth = require('google-auto-auth'); const is = require('is'); const request = require('request').defaults({ timeout: 60000, gzip: true, forever: true, pool: { maxSockets: Infinity, }, }); const retryRequest = require('retry-request'); const streamEvents = require('stream-events'); const through = require('through2'); const uniq = require('array-uniq'); const util = module.exports; /** * Custom error type for missing project ID errors. */ util.MissingProjectIdError = createErrorClass( 'MissingProjectIdError', function() { this.message = `Sorry, we cannot connect to Cloud Services without a project ID. You may specify one with an environment variable named "GOOGLE_CLOUD_PROJECT".`.replace(/ +/g, ' '); } ); /** * No op. * * @example * function doSomething(callback) { * callback = callback || noop; * } */ function noop() {} util.noop = noop; /** * Custom error type for API errors. * * @param {object} errorBody - Error object. */ util.ApiError = createErrorClass('ApiError', function(errorBody) { this.code = errorBody.code; this.errors = errorBody.errors; this.response = errorBody.response; try { this.errors = JSON.parse(this.response.body).error.errors; } catch (e) { this.errors = errorBody.errors; } const messages = []; if (errorBody.message) { messages.push(errorBody.message); } if (this.errors && this.errors.length === 1) { messages.push(this.errors[0].message); } else if (this.response && this.response.body) { messages.push(ent.decode(errorBody.response.body.toString())); } else if (!errorBody.message) { messages.push('Error during request.'); } this.message = uniq(messages).join(' - '); }); /** * Custom error type for partial errors returned from the API. * * @param {object} b - Error object. */ util.PartialFailureError = createErrorClass('PartialFailureError', function(b) { const errorObject = b; this.errors = errorObject.errors; this.response = errorObject.response; const defaultErrorMessage = 'A failure occurred during this request.'; this.message = errorObject.message || defaultErrorMessage; }); /** * Uniformly process an API response. * * @param {*} err - Error value. * @param {*} resp - Response value. * @param {*} body - Body value. * @param {function} callback - The callback function. */ function handleResp(err, resp, body, callback) { callback = callback || util.noop; const parsedResp = extend( true, {err: err || null}, resp && util.parseHttpRespMessage(resp), body && util.parseHttpRespBody(body) ); callback(parsedResp.err, parsedResp.body, parsedResp.resp); } util.handleResp = handleResp; /** * Sniff an incoming HTTP response message for errors. * * @param {object} httpRespMessage - An incoming HTTP response message from * `request`. * @return {object} parsedHttpRespMessage - The parsed response. * @param {?error} parsedHttpRespMessage.err - An error detected. * @param {object} parsedHttpRespMessage.resp - The original response object. */ function parseHttpRespMessage(httpRespMessage) { const parsedHttpRespMessage = { resp: httpRespMessage, }; if (httpRespMessage.statusCode < 200 || httpRespMessage.statusCode > 299) { // Unknown error. Format according to ApiError standard. parsedHttpRespMessage.err = new util.ApiError({ errors: [], code: httpRespMessage.statusCode, message: httpRespMessage.statusMessage, response: httpRespMessage, }); } return parsedHttpRespMessage; } util.parseHttpRespMessage = parseHttpRespMessage; /** * Parse the response body from an HTTP request. * * @param {object} body - The response body. * @return {object} parsedHttpRespMessage - The parsed response. * @param {?error} parsedHttpRespMessage.err - An error detected. * @param {object} parsedHttpRespMessage.body - The original body value provided * will try to be JSON.parse'd. If it's successful, the parsed value will be * returned here, otherwise the original value. */ function parseHttpRespBody(body) { const parsedHttpRespBody = { body: body, }; if (is.string(body)) { try { parsedHttpRespBody.body = JSON.parse(body); } catch (err) { parsedHttpRespBody.err = new util.ApiError('Cannot parse JSON response'); } } if (parsedHttpRespBody.body && parsedHttpRespBody.body.error) { // Error from JSON API. parsedHttpRespBody.err = new util.ApiError(parsedHttpRespBody.body.error); } return parsedHttpRespBody; } util.parseHttpRespBody = parseHttpRespBody; /** * Take a Duplexify stream, fetch an authenticated connection header, and create * an outgoing writable stream. * * @param {Duplexify} dup - Duplexify stream. * @param {object} options - Configuration object. * @param {module:common/connection} options.connection - A connection instance, * used to get a token with and send the request through. * @param {object} options.metadata - Metadata to send at the head of the * request. * @param {object} options.request - Request object, in the format of a standard * Node.js http.request() object. * @param {string=} options.request.method - Default: "POST". * @param {string=} options.request.qs.uploadType - Default: "multipart". * @param {string=} options.streamContentType - Default: * "application/octet-stream". * @param {function} onComplete - Callback, executed after the writable Request * stream has completed. */ function makeWritableStream(dup, options, onComplete) { onComplete = onComplete || util.noop; const writeStream = through(); dup.setWritable(writeStream); const defaultReqOpts = { method: 'POST', qs: { uploadType: 'multipart', }, }; const metadata = options.metadata || {}; const reqOpts = extend(true, defaultReqOpts, options.request, { multipart: [ { 'Content-Type': 'application/json', body: JSON.stringify(metadata), }, { 'Content-Type': metadata.contentType || 'application/octet-stream', body: writeStream, }, ], }); options.makeAuthenticatedRequest(reqOpts, { onAuthenticated: function(err, authenticatedReqOpts) { if (err) { dup.destroy(err); return; } request(authenticatedReqOpts, function(err, resp, body) { util.handleResp(err, resp, body, function(err, data) { if (err) { dup.destroy(err); return; } dup.emit('response', resp); onComplete(data); }); }); }, }); } util.makeWritableStream = makeWritableStream; /** * Returns true if the API request should be retried, given the error that was * given the first time the request was attempted. This is used for rate limit * related errors as well as intermittent server errors. * * @param {error} err - The API error to check if it is appropriate to retry. * @return {boolean} True if the API request should be retried, false otherwise. */ function shouldRetryRequest(err) { if (err) { if ([429, 500, 502, 503].indexOf(err.code) !== -1) { return true; } if (err.errors) { for (const i in err.errors) { const reason = err.errors[i].reason; if (reason === 'rateLimitExceeded') { return true; } if (reason === 'userRateLimitExceeded') { return true; } } } } return false; } util.shouldRetryRequest = shouldRetryRequest; /** * Get a function for making authenticated requests. * * @throws {Error} If a projectId is requested, but not able to be detected. * * @param {object} config - Configuration object. * @param {boolean=} config.autoRetry - Automatically retry requests if the * response is related to rate limits or certain intermittent server errors. * We will exponentially backoff subsequent requests by default. (default: * true) * @param {object=} config.credentials - Credentials object. * @param {boolean=} config.customEndpoint - If true, just return the provided * request options. Default: false. * @param {string=} config.email - Account email address, required for PEM/P12 * usage. * @param {number=} config.maxRetries - Maximum number of automatic retries * attempted before returning the error. (default: 3) * @param {string=} config.keyFile - Path to a .json, .pem, or .p12 keyfile. * @param {array} config.scopes - Array of scopes required for the API. */ function makeAuthenticatedRequestFactory(config) { config = config || {}; const googleAutoAuthConfig = extend({}, config); if (googleAutoAuthConfig.projectId === '{{projectId}}') { delete googleAutoAuthConfig.projectId; } const authClient = googleAuth(googleAutoAuthConfig); /** * The returned function that will make an authenticated request. * * @param {type} reqOpts - Request options in the format `request` expects. * @param {object|function} options - Configuration object or callback * function. * @param {function=} options.onAuthenticated - If provided, a request will * not be made. Instead, this function is passed the error & authenticated * request options. */ function makeAuthenticatedRequest(reqOpts, options) { let stream; const reqConfig = extend({}, config); let activeRequest_; if (!options) { stream = duplexify(); reqConfig.stream = stream; } function onAuthenticated(err, authenticatedReqOpts) { const autoAuthFailed = err && err.message.indexOf('Could not load the default credentials') > -1; if (autoAuthFailed) { // Even though authentication failed, the API might not actually care. authenticatedReqOpts = reqOpts; } if (!err || autoAuthFailed) { let projectId = authClient.projectId; if (config.projectId && config.projectId !== '{{projectId}}') { projectId = config.projectId; } try { authenticatedReqOpts = util.decorateRequest( authenticatedReqOpts, projectId ); err = null; } catch (e) { // A projectId was required, but we don't have one. // Re-use the "Could not load the default credentials error" if auto // auth failed. err = err || e; } } if (err) { if (stream) { stream.destroy(err); } else { (options.onAuthenticated || options)(err); } return; } if (options && options.onAuthenticated) { options.onAuthenticated(null, authenticatedReqOpts); } else { activeRequest_ = util.makeRequest( authenticatedReqOpts, reqConfig, options ); } } if (reqConfig.customEndpoint) { // Using a custom API override. Do not use `google-auto-auth` for // authentication. (ex: connecting to a local Datastore server) onAuthenticated(null, reqOpts); } else { authClient.authorizeRequest(reqOpts, onAuthenticated); } if (stream) { return stream; } return { abort: function() { if (activeRequest_) { activeRequest_.abort(); activeRequest_ = null; } }, }; } makeAuthenticatedRequest.getCredentials = authClient.getCredentials.bind( authClient ); makeAuthenticatedRequest.authClient = authClient; return makeAuthenticatedRequest; } util.makeAuthenticatedRequestFactory = makeAuthenticatedRequestFactory; /** * Make a request through the `retryRequest` module with built-in error handling * and exponential back off. * * @param {object} reqOpts - Request options in the format `request` expects. * @param {object=} config - Configuration object. * @param {boolean=} config.autoRetry - Automatically retry requests if the * response is related to rate limits or certain intermittent server errors. * We will exponentially backoff subsequent requests by default. (default: * true) * @param {number=} config.maxRetries - Maximum number of automatic retries * attempted before returning the error. (default: 3) * @param {function} callback - The callback function. */ function makeRequest(reqOpts, config, callback) { if (is.fn(config)) { callback = config; config = {}; } config = config || {}; const options = { request: request, retries: config.autoRetry !== false ? config.maxRetries || 3 : 0, shouldRetryFn: function(httpRespMessage) { const err = util.parseHttpRespMessage(httpRespMessage).err; return err && util.shouldRetryRequest(err); }, }; if (config.stream) { const dup = config.stream; let requestStream; const isGetRequest = (reqOpts.method || 'GET').toUpperCase() === 'GET'; if (isGetRequest) { requestStream = retryRequest(reqOpts, options); dup.setReadable(requestStream); } else { // Streaming writable HTTP requests cannot be retried. requestStream = request(reqOpts); dup.setWritable(requestStream); } // Replay the Request events back to the stream. requestStream .on('error', dup.destroy.bind(dup)) .on('response', dup.emit.bind(dup, 'response')) .on('complete', dup.emit.bind(dup, 'complete')); dup.abort = requestStream.abort; } else { return retryRequest(reqOpts, options, function(err, response, body) { util.handleResp(err, response, body, callback); }); } } util.makeRequest = makeRequest; /** * Decorate the options about to be made in a request. * * @param {object} reqOpts - The options to be passed to `request`. * @param {string} projectId - The project ID. * @return {object} reqOpts - The decorated reqOpts. */ function decorateRequest(reqOpts, projectId) { delete reqOpts.autoPaginate; delete reqOpts.autoPaginateVal; delete reqOpts.objectMode; if (is.object(reqOpts.qs)) { delete reqOpts.qs.autoPaginate; delete reqOpts.qs.autoPaginateVal; reqOpts.qs = util.replaceProjectIdToken(reqOpts.qs, projectId); } if (is.object(reqOpts.json)) { delete reqOpts.json.autoPaginate; delete reqOpts.json.autoPaginateVal; reqOpts.json = util.replaceProjectIdToken(reqOpts.json, projectId); } reqOpts.uri = util.replaceProjectIdToken(reqOpts.uri, projectId); return reqOpts; } util.decorateRequest = decorateRequest; /** * Populate the `{{projectId}}` placeholder. * * @throws {Error} If a projectId is required, but one is not provided. * * @param {*} - Any input value that may contain a placeholder. Arrays and * objects will be looped. * @param {string} projectId - A projectId. If not provided * @return {*} - The original argument with all placeholders populated. */ function replaceProjectIdToken(value, projectId) { if (is.array(value)) { value = value.map(function(val) { return replaceProjectIdToken(val, projectId); }); } if (is.object(value) && is.fn(value.hasOwnProperty)) { for (const opt in value) { if (value.hasOwnProperty(opt)) { value[opt] = replaceProjectIdToken(value[opt], projectId); } } } if (is.string(value) && value.indexOf('{{projectId}}') > -1) { if (!projectId || projectId === '{{projectId}}') { throw new util.MissingProjectIdError(); } value = value.replace(/{{projectId}}/g, projectId); } return value; } util.replaceProjectIdToken = replaceProjectIdToken; /** * Extend a global configuration object with user options provided at the time * of sub-module instantiation. * * Connection details currently come in two ways: `credentials` or * `keyFilename`. Because of this, we have a special exception when overriding a * global configuration object. If a user provides either to the global * configuration, then provides another at submodule instantiation-time, the * latter is preferred. * * @param {object} globalConfig - The global configuration object. * @param {object=} overrides - The instantiation-time configuration object. * @return {object} */ function extendGlobalConfig(globalConfig, overrides) { globalConfig = globalConfig || {}; overrides = overrides || {}; const defaultConfig = {}; if (process.env.GCLOUD_PROJECT) { defaultConfig.projectId = process.env.GCLOUD_PROJECT; } const options = extend({}, globalConfig); const hasGlobalConnection = options.credentials || options.keyFilename; const isOverridingConnection = overrides.credentials || overrides.keyFilename; if (hasGlobalConnection && isOverridingConnection) { delete options.credentials; delete options.keyFilename; } const extendedConfig = extend(true, defaultConfig, options, overrides); // Preserve the original (not cloned) interceptors. extendedConfig.interceptors_ = globalConfig.interceptors_; return extendedConfig; } util.extendGlobalConfig = extendGlobalConfig; /** * Merge and validate API configurations. * * @param {object} globalContext - gcloud-level context. * @param {object} globalContext.config_ - gcloud-level configuration. * @param {object} localConfig - Service-level configurations. * @return {object} config - Merged and validated configuration. */ function normalizeArguments(globalContext, localConfig) { const globalConfig = globalContext && globalContext.config_; return util.extendGlobalConfig(globalConfig, localConfig); } util.normalizeArguments = normalizeArguments; /** * Limit requests according to a `maxApiCalls` limit. * * @param {function} makeRequestFn - The function that will be called. * @param {object=} options - Configuration object. * @param {number} options.maxApiCalls - The maximum number of API calls to * make. * @param {object} options.streamOptions - Options to pass to the Stream * constructor. */ function createLimiter(makeRequestFn, options) { options = options || {}; const stream = streamEvents(through.obj(options.streamOptions)); let requestsMade = 0; let requestsToMake = -1; if (is.number(options.maxApiCalls)) { requestsToMake = options.maxApiCalls; } return { makeRequest: function() { requestsMade++; if (requestsToMake >= 0 && requestsMade > requestsToMake) { stream.push(null); return; } makeRequestFn.apply(null, arguments); return stream; }, stream: stream, }; } util.createLimiter = createLimiter; function isCustomType(unknown, module) { function getConstructorName(obj) { return obj.constructor && obj.constructor.name.toLowerCase(); } const moduleNameParts = module.split('/'); const parentModuleName = moduleNameParts[0] && moduleNameParts[0].toLowerCase(); const subModuleName = moduleNameParts[1] && moduleNameParts[1].toLowerCase(); if (subModuleName && getConstructorName(unknown) !== subModuleName) { return false; } let walkingModule = unknown; do { if (getConstructorName(walkingModule) === parentModuleName) { return true; } } while ((walkingModule = walkingModule.parent)); return false; } util.isCustomType = isCustomType; /** * Create a properly-formatted User-Agent string from a package.json file. * * @param {object} packageJson - A module's package.json file. * @return {string} userAgent - The formatted User-Agent string. */ function getUserAgentFromPackageJson(packageJson) { const hyphenatedPackageName = packageJson.name .replace('@google-cloud', 'gcloud-node') // For legacy purposes. .replace('/', '-'); // For UA spec-compliance purposes. return hyphenatedPackageName + '/' + packageJson.version; } util.getUserAgentFromPackageJson = getUserAgentFromPackageJson; /** * Wraps a callback style function to conditionally return a promise. * * @param {function} originalMethod - The method to promisify. * @param {object=} options - Promise options. * @param {boolean} options.singular - Resolve the promise with single arg * instead of an array. * @return {function} wrapped */ function promisify(originalMethod, options) { if (originalMethod.promisified_) { return originalMethod; } options = options || {}; const slice = Array.prototype.slice; const wrapper = function() { const context = this; let last; for (last = arguments.length - 1; last >= 0; last--) { const arg = arguments[last]; if (is.undefined(arg)) { continue; // skip trailing undefined. } if (!is.fn(arg)) { break; // non-callback last argument found. } return originalMethod.apply(context, arguments); } // peel trailing undefined. const args = slice.call(arguments, 0, last + 1); let PromiseCtor = Promise; // Because dedupe will likely create a single install of // @google-cloud/common to be shared amongst all modules, we need to // localize it at the Service level. if (context && context.Promise) { PromiseCtor = context.Promise; } return new PromiseCtor(function(resolve, reject) { args.push(function() { const callbackArgs = slice.call(arguments); const err = callbackArgs.shift(); if (err) { return reject(err); } if (options.singular && callbackArgs.length === 1) { resolve(callbackArgs[0]); } else { resolve(callbackArgs); } }); originalMethod.apply(context, args); }); }; wrapper.promisified_ = true; return wrapper; } util.promisify = promisify; /** * Promisifies certain Class methods. This will not promisify private or * streaming methods. * * @param {module:common/service} Class - Service class. * @param {object=} options - Configuration object. */ function promisifyAll(Class, options) { const exclude = (options && options.exclude) || []; const methods = Object.keys(Class.prototype).filter(function(methodName) { return ( is.fn(Class.prototype[methodName]) && // is it a function? !/(^_|(Stream|_)|promise$)/.test(methodName) && // is it promisable? exclude.indexOf(methodName) === -1 ); // is it blacklisted? }); methods.forEach(function(methodName) { const originalMethod = Class.prototype[methodName]; if (!originalMethod.promisified_) { Class.prototype[methodName] = util.promisify(originalMethod, options); } }); } util.promisifyAll = promisifyAll; /** * This will mask properties of an object from console.log. * * @param {object} object - The object to assign the property to. * @param {string} propName - Property name. * @param {*} value - Value. */ function privatize(object, propName, value) { Object.defineProperty(object, propName, {value, writable: true}); } util.privatize = privatize;