diff --git a/lib/common/service-object.js b/lib/common/service-object.js index 45bd5649415..f85e23e37bf 100644 --- a/lib/common/service-object.js +++ b/lib/common/service-object.js @@ -66,6 +66,7 @@ function ServiceObject(config) { this.id = config.id; // Name or ID (e.g. dataset ID, bucket name, etc.) this.createMethod = config.createMethod; this.methods = config.methods || {}; + this.interceptors = []; if (config.methods) { var allMethodNames = Object.keys(ServiceObject.prototype); @@ -312,6 +313,8 @@ ServiceObject.prototype.request = function(reqOpts, callback) { }) .join('/'); + reqOpts.interceptors_ = [].slice.call(this.interceptors); + this.parent.request(reqOpts, callback); }; diff --git a/lib/common/service.js b/lib/common/service.js index 68dccf8b9c8..ad27b599999 100644 --- a/lib/common/service.js +++ b/lib/common/service.js @@ -20,6 +20,8 @@ 'use strict'; +var arrify = require('arrify'); + /** * @type {module:common/util} * @private @@ -48,6 +50,8 @@ function Service(config, options) { this.authClient = this.makeAuthenticatedRequest.authClient; this.baseUrl = config.baseUrl; this.getCredentials = this.makeAuthenticatedRequest.getCredentials; + this.globalInterceptors = arrify(options.interceptors_); + this.interceptors = []; this.projectId = options.projectId; this.projectIdRequired = config.projectIdRequired !== false; } @@ -84,6 +88,19 @@ Service.prototype.request = function(reqOpts, callback) { // Good: https://.../projects:list .replace(/\/:/g, ':'); + // Interceptors should be called in the order they were assigned. + var combinedInterceptors = [].slice.call(this.globalInterceptors) + .concat(this.interceptors) + .concat(arrify(reqOpts.interceptors_)); + + var interceptor; + + while ((interceptor = combinedInterceptors.shift()) && interceptor.request) { + reqOpts = interceptor.request(reqOpts); + } + + delete reqOpts.interceptors_; + this.makeAuthenticatedRequest(reqOpts, callback); }; diff --git a/lib/common/util.js b/lib/common/util.js index 1cb9bc08d9e..c547a80f683 100644 --- a/lib/common/util.js +++ b/lib/common/util.js @@ -50,58 +50,6 @@ var missingProjectIdError = new Error([ util.missingProjectIdError = missingProjectIdError; -/** - * 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} - * - * @example - * // globalConfig = { - * // credentials: {...} - * // } - * Datastore.prototype.dataset = function(options) { - * // options = { - * // keyFilename: 'keyfile.json' - * // } - * return extendGlobalConfig(this.config, options); - * // returns: - * // { - * // keyFilename: 'keyfile.json' - * // } - * }; - */ -function extendGlobalConfig(globalConfig, overrides) { - var options = extend({}, globalConfig); - var hasGlobalConnection = options.credentials || options.keyFilename; - - overrides = overrides || {}; - var isOverridingConnection = overrides.credentials || overrides.keyFilename; - - if (hasGlobalConnection && isOverridingConnection) { - delete options.credentials; - delete options.keyFilename; - } - - var defaults = {}; - - if (process.env.GCLOUD_PROJECT) { - defaults.projectId = process.env.GCLOUD_PROJECT; - } - - return extend(true, defaults, options, overrides); -} - -util.extendGlobalConfig = extendGlobalConfig; - /** * No op. * @@ -492,22 +440,68 @@ function decorateRequest(reqOpts) { util.decorateRequest = decorateRequest; /** - * Merges and validates API configurations + * Extend a global configuration object with user options provided at the time + * of sub-module instantiation. * - * @throws {Error} If projectId is missing + * 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} globalContext - api level context, this is where the - * gloabl configuration should live - * @param {?object} localConfig - api level configurations - * @return {object} config - merged and validated configurations + * @param {object} globalConfig - The global configuration object. + * @param {object=} overrides - The instantiation-time configuration object. + * @return {object} */ -function normalizeArguments(globalContext, localConfig, options) { - var globalConfig = globalContext && globalContext.config_ || {}; - var config = util.extendGlobalConfig(globalConfig, localConfig); +function extendGlobalConfig(globalConfig, overrides) { + globalConfig = globalConfig || {}; + overrides = overrides || {}; + + var defaultConfig = {}; + + if (process.env.GCLOUD_PROJECT) { + defaultConfig.projectId = process.env.GCLOUD_PROJECT; + } + var options = extend({}, globalConfig); + + var hasGlobalConnection = options.credentials || options.keyFilename; + var isOverridingConnection = overrides.credentials || overrides.keyFilename; + + if (hasGlobalConnection && isOverridingConnection) { + delete options.credentials; + delete options.keyFilename; + } + + var 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. + * + * @throws {Error} If a projectId is not specified. + * + * @param {object} globalContext - gcloud-level context. + * @param {object} globalContext.config_ - gcloud-level configuration. + * @param {object} localConfig - Service-level configurations. + * @param {object=} options - Configuration object. + * @param {boolean} options.projectIdRequired - Whether to throw if a project ID + * is required, but not provided by the user. (Default: true) + * @return {object} config - Merged and validated configuration. + */ +function normalizeArguments(globalContext, localConfig, options) { options = options || {}; - if (!config.projectId && options.projectIdRequired !== false) { + var config = util.extendGlobalConfig(globalContext.config_, localConfig); + + if (options.projectIdRequired !== false && !config.projectId) { throw util.missingProjectIdError; } diff --git a/lib/index.js b/lib/index.js index 71644c2918b..aa381e510f4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -238,6 +238,17 @@ var scopedApis = { * See our [Authentication Guide](#/authentication) for how to obtain the * necessary credentials for connecting to your project. * + * ### Advanced Usage + * + * #### Interceptors + * + * All of the returned modules hold a special `interceptors` array you can use + * to have control over the flow of the internal operations of this library. As + * of now, we support a request interceptor, allowing you to tweak all of the + * API request options before the HTTP request is sent. + * + * See the example below for more. + * * @alias module:gcloud * @constructor * @@ -284,9 +295,59 @@ var scopedApis = { * //- * // `gcs` and `otherGcs` will use their respective credentials for all future * // API requests. + * // + * //

Interceptors

+ * // + * // Use a `request` interceptor to set a custom HTTP header on your requests. + * //- + * gcloud.interceptors.push({ + * request: function(requestOptions) { + * requestOptions.headers = requestOptions.headers || {}; + * requestOptions.headers['X-Cloud-Trace-Context'] = 'I will be overridden'; + * return requestOptions; + * } + * }); + * + * //- + * // You can also set an interceptor on the service level, like a Storage + * // object. * //- + * gcs.interceptors.push({ + * request: function(requestOptions) { + * requestOptions.headers = requestOptions.headers || {}; + * requestOptions.headers['X-Cloud-Trace-Context'] = 'I will be overridden'; + * return requestOptions; + * } + * }); + * + * //- + * // Additionally, set one on the service object level, such as a Bucket. + * //- + * bucket.interceptors.push({ + * request: function(requestOptions) { + * requestOptions.headers = requestOptions.headers || {}; + * requestOptions.headers['X-Cloud-Trace-Context'] = 'I win!'; + * return requestOptions; + * } + * }); + * + * //- + * // The following request will combine all of the headers, executed in the + * // order from when they were assigned, respecting the hierarchy: + * // global before service before service object. + * //- + * bucket.getMetadata(function() { + * // This HTTP request was sent with the 'I win!' header specified above. + * }); */ function gcloud(config) { + config = extend(true, { interceptors_: [] }, config); + + var gcloudExposedApi = { + config_: config, + interceptors: config.interceptors_ + }; + return Object.keys(apis).reduce(function(gcloudExposedApi, apiName) { var Class = apis[apiName]; @@ -297,7 +358,7 @@ function gcloud(config) { } return gcloudExposedApi; - }, { config_: config }); + }, gcloudExposedApi); } module.exports = extend(gcloud, apis); diff --git a/test/common/service-object.js b/test/common/service-object.js index 3ea29a79633..58efc44d0ce 100644 --- a/test/common/service-object.js +++ b/test/common/service-object.js @@ -650,5 +650,23 @@ describe('ServiceObject', function() { serviceObject.request(reqOpts, assert.ifError); }); + + it('should pass a clone of the interceptors', function(done) { + serviceObject.interceptors.push({ + request: function(reqOpts) { + reqOpts.one = true; + return reqOpts; + } + }); + + serviceObject.parent.request = function(reqOpts) { + var serviceObjectInterceptors = serviceObject.interceptors; + assert.deepEqual(reqOpts.interceptors_, serviceObjectInterceptors); + assert.notStrictEqual(reqOpts.interceptors_, serviceObjectInterceptors); + done(); + }; + + serviceObject.request({ uri: '' }, assert.ifError); + }); }); }); diff --git a/test/common/service.js b/test/common/service.js index f7aed52c760..414bca50612 100644 --- a/test/common/service.js +++ b/test/common/service.js @@ -118,6 +118,24 @@ describe('Service', function() { assert.strictEqual(service.getCredentials, getCredentials); }); + it('should default globalInterceptors to an empty array', function() { + assert.deepEqual(service.globalInterceptors, []); + }); + + it('should preserve the original global interceptors', function() { + var globalInterceptors = []; + + var options = extend({}, OPTIONS); + options.interceptors_ = globalInterceptors; + + var service = new Service({}, options); + assert.strictEqual(service.globalInterceptors, globalInterceptors); + }); + + it('should default interceptors to an empty array', function() { + assert.deepEqual(service.interceptors, []); + }); + it('should localize the projectId', function() { assert.strictEqual(service.projectId, OPTIONS.projectId); }); @@ -240,5 +258,109 @@ describe('Service', function() { }); }); }); + + describe('request interceptors', function() { + it('should call the request interceptors in order', function(done) { + var reqOpts = { + uri: '', + interceptors_: [] + }; + + // Called first. + service.globalInterceptors.push({ + request: function(reqOpts) { + reqOpts.order = '1'; + return reqOpts; + } + }); + + // Called third. + service.interceptors.push({ + request: function(reqOpts) { + reqOpts.order += '3'; + return reqOpts; + } + }); + + // Called second. + service.globalInterceptors.push({ + request: function(reqOpts) { + reqOpts.order += '2'; + return reqOpts; + } + }); + + // Called fifth. + reqOpts.interceptors_.push({ + request: function(reqOpts) { + reqOpts.order += '5'; + return reqOpts; + } + }); + + // Called fourth. + service.interceptors.push({ + request: function(reqOpts) { + reqOpts.order += '4'; + return reqOpts; + } + }); + + // Called sixth. + reqOpts.interceptors_.push({ + request: function(reqOpts) { + reqOpts.order += '6'; + return reqOpts; + } + }); + + service.makeAuthenticatedRequest = function(reqOpts) { + assert.strictEqual(reqOpts.order, '123456'); + done(); + }; + + service.request(reqOpts, assert.ifError); + }); + + it('should not affect original interceptor arrays', function(done) { + function request(reqOpts) { return reqOpts; } + + var globalInterceptors = [{ request: request }]; + var localInterceptors = [{ request: request }]; + var requestInterceptors = [{ request: request }]; + + var originalGlobalInterceptors = [].slice.call(globalInterceptors); + var originalLocalInterceptors = [].slice.call(localInterceptors); + var originalRequestInterceptors = [].slice.call(requestInterceptors); + + service.makeAuthenticatedRequest = function() { + assert.deepEqual(globalInterceptors, originalGlobalInterceptors); + assert.deepEqual(localInterceptors, originalLocalInterceptors); + assert.deepEqual(requestInterceptors, originalRequestInterceptors); + done(); + }; + + service.request({ + uri: '', + interceptors_: requestInterceptors + }, assert.ifError); + }); + + it('should not call unrelated interceptors', function(done) { + service.interceptors.push({ + anotherInterceptor: function() { + done(); // Will throw. + }, + request: function() { + setImmediate(done); + return {}; + } + }); + + service.makeAuthenticatedRequest = util.noop; + + service.request({ uri: '' }, assert.ifError); + }); + }); }); }); diff --git a/test/common/util.js b/test/common/util.js index 2bef7673f8c..feee7d1ce73 100644 --- a/test/common/util.js +++ b/test/common/util.js @@ -193,13 +193,13 @@ describe('common/util', function() { var options = util.extendGlobalConfig(globalConfig, { keyFilename: 'key.json' }); - assert.deepEqual(options, { keyFilename: 'key.json' }); + assert.strictEqual(options.credentials, undefined); }); it('should favor `credentials` when `keyFilename` is global', function() { var globalConfig = { keyFilename: 'key.json' }; var options = util.extendGlobalConfig(globalConfig, { credentials: {} }); - assert.deepEqual(options, { credentials: {} }); + assert.strictEqual(options.keyFilename, undefined); }); it('should honor the GCLOUD_PROJECT environment variable', function() { @@ -227,6 +227,13 @@ describe('common/util', function() { util.extendGlobalConfig(globalConfig, { credentials: {} }); assert.deepEqual(globalConfig, { keyFilename: 'key.json' }); }); + + it('should link the original interceptors_', function() { + var interceptors = []; + var globalConfig = { interceptors_: interceptors }; + util.extendGlobalConfig(globalConfig, {}); + assert.strictEqual(globalConfig.interceptors_, interceptors); + }); }); describe('handleResp', function() { @@ -983,7 +990,7 @@ describe('common/util', function() { var local = { a: 'b' }; var config; - util.extendGlobalConfig = function(globalConfig, localConfig) { + utilOverrides.extendGlobalConfig = function(globalConfig, localConfig) { assert.strictEqual(globalConfig, fakeContext.config_); assert.strictEqual(localConfig, local); return fakeContext.config_; @@ -993,27 +1000,26 @@ describe('common/util', function() { assert.strictEqual(config, fakeContext.config_); }); - it('should default the global config when missing', function() { - util.extendGlobalConfig = function(globalConfig, options) { - assert.deepEqual(globalConfig, {}); - return options; + describe('projectIdRequired', function() { + var fakeContextWithoutProjectId = { + config_: {} }; - util.normalizeArguments(null, fakeContext.config_); - }); - - describe('projectIdRequired', function() { it('should throw if true', function() { var errMsg = new RegExp(util.missingProjectIdError.message); assert.throws(function() { - util.normalizeArguments({ a: 'b' }, { c: 'd' }); + util.normalizeArguments(fakeContextWithoutProjectId, { c: 'd' }); }, errMsg); }); it('should not throw if false', function() { assert.doesNotThrow(function() { - util.normalizeArguments({}, {}, { projectIdRequired: false }); + util.normalizeArguments( + fakeContextWithoutProjectId, + {}, + { projectIdRequired: false } + ); }); }); }); diff --git a/test/index.js b/test/index.js index 33a3e01ac97..90c88abe5ae 100644 --- a/test/index.js +++ b/test/index.js @@ -17,6 +17,7 @@ 'use strict'; var assert = require('assert'); +var extend = require('extend'); var mockery = require('mockery'); function createFakeApi() { @@ -88,15 +89,37 @@ describe('gcloud', function() { assert.strictEqual(gcloud.storage, FakeStorage); }); - describe('localized auth', function() { + describe('localized configuration', function() { var localGcloud; var config = { a: 'b', c: 'd' }; var options = { e: 'f', g: 'h' }; + var expectedConfig = extend({}, config, { + interceptors_: [] + }); + beforeEach(function() { localGcloud = gcloud(config); }); + describe('initialization', function() { + it('should persist the provided configuration', function() { + assert.notStrictEqual(localGcloud.config_, config); + assert.deepEqual(localGcloud.config_, expectedConfig); + }); + + it('should define an empty interceptors array', function() { + assert.deepEqual(localGcloud.interceptors, []); + }); + + it('should link interceptors to the persisted config object', function() { + assert.strictEqual( + localGcloud.interceptors, + localGcloud.config_.interceptors_ + ); + }); + }); + describe('bigquery', function() { it('should create a new BigQuery', function() { var bigquery = localGcloud.bigquery(options); @@ -111,7 +134,7 @@ describe('gcloud', function() { var datastore = localGcloud.datastore; assert(datastore instanceof FakeDatastore); - assert.strictEqual(datastore.calledWith_[0], config); + assert.deepEqual(datastore.calledWith_[0], expectedConfig); }); });