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