diff --git a/lib/storage/bucket.js b/lib/storage/bucket.js index ea7c1e32fca..25347b2416e 100644 --- a/lib/storage/bucket.js +++ b/lib/storage/bucket.js @@ -74,8 +74,6 @@ var RESUMABLE_THRESHOLD = 5000000; * @constructor * @alias module:storage/bucket * - * @throws {Error} if a bucket name isn't provided. - * * @param {object} options - Configuration options. * @param {string} options.bucketName - Name of the bucket. * @param {string=} options.keyFilename - Full path to the JSON key downloaded @@ -94,7 +92,6 @@ var RESUMABLE_THRESHOLD = 5000000; * * var bucket = gcs.bucket('albums'); */ - function Bucket(storage, name) { var methods = { /** @@ -429,6 +426,71 @@ Bucket.prototype.combine = function(sources, destination, callback) { } }; +/** + * Create a channel that will be notified when objects in this bucket changes. + * + * @throws {Error} If an ID is not provided. + * @throws {Error} If an address is not provided. + * + * @resource [Objects: watchAll API Documentation]{@link https://cloud.google.com/storage/docs/json_api/v1/objects/watchAll} + * + * @param {string} id - The ID of the channel to create. + * @param {object} config - See a + * [Objects: watchAll request body](https://cloud.google.com/storage/docs/json_api/v1/objects/watchAll). + * @param {string} config.address - The address where notifications are + * delivered for this channel. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request + * @param {module:storage/channel} callback.channel - The created Channel + * object. + * @param {object} callback.apiResponse - The full API response. + * + * @example + * var id = 'new-channel-id'; + * + * var config = { + * address: 'https://...' + * }; + * + * bucket.createChannel(id, config, function(err, channel, apiResponse) { + * if (!err) { + * // Channel created successfully. + * } + * }); + */ +Bucket.prototype.createChannel = function(id, config, callback) { + var self = this; + + if (!is.string(id)) { + throw new Error('An ID is required to create a channel.'); + } + + if (!is.string(config.address)) { + throw new Error('An address is required to create a channel.'); + } + + this.request({ + method: 'POST', + uri: '/o/watch', + json: extend({ + id: id, + type: 'web_hook' + }, config) + }, function(err, apiResponse) { + if (err) { + callback(err, null, apiResponse); + return; + } + + var resourceId = apiResponse.resourceId; + var channel = self.storage.channel(id, resourceId); + + channel.metadata = apiResponse; + + callback(null, channel, apiResponse); + }); +}; + /** * Iterate over the bucket's files, calling `file.delete()` on each. * diff --git a/lib/storage/channel.js b/lib/storage/channel.js new file mode 100644 index 00000000000..93659dca259 --- /dev/null +++ b/lib/storage/channel.js @@ -0,0 +1,106 @@ +/*! + * Copyright 2015 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 storage/channel + */ + +'use strict'; + +var nodeutil = require('util'); + +/** + * @type {module:common/serviceObject} + * @private + */ +var ServiceObject = require('../common/service-object.js'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/*! Developer Documenation + * + * @param {module:storage} storage - The Storage instance. + */ +/** + * Create a channel object to interact with a Google Cloud Storage channel. + * + * @resource [Object Change Notification]{@link https://cloud.google.com/storage/docs/object-change-notification} + * + * @constructor + * @alias module:storage/bucket + * + * @param {string} id - The ID of the channel. + * @param {string} resourceId - The resource ID of the channel. + * + * @example + * var gcloud = require('gcloud'); + * + * var gcs = gcloud.storage({ + * keyFilename: '/path/to/keyfile.json', + * projectId: 'grape-spaceship-123' + * }); + * + * var channel = gcs.channel('id', 'resource-id'); + */ +function Channel(storage, id, resourceId) { + var config = { + parent: storage, + baseUrl: '/channels', + id: id, + methods: { + // Only need `request`. + } + }; + + ServiceObject.call(this, config); + + this.metadata.id = id; + this.metadata.resourceId = resourceId; +} + +nodeutil.inherits(Channel, ServiceObject); + +/** + * Stop this channel. + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request. + * @param {object} callback.apiResponse - The full API response. + * + * @example + * channel.stop(function(err, apiResponse) { + * if (!err) { + * // Channel stopped successfully. + * } + * }); + */ +Channel.prototype.stop = function(callback) { + callback = callback || util.noop; + + this.request({ + method: 'POST', + uri: '/stop', + json: this.metadata + }, function(err, apiResponse) { + callback(err, apiResponse); + }); +}; + +module.exports = Channel; diff --git a/lib/storage/index.js b/lib/storage/index.js index 62247da20b3..22df730023f 100644 --- a/lib/storage/index.js +++ b/lib/storage/index.js @@ -30,6 +30,12 @@ var nodeutil = require('util'); */ var Bucket = require('./bucket.js'); +/** + * @type {module:storage/channel} + * @private + */ +var Channel = require('./channel.js'); + /** * @type {module:common/service} * @private @@ -177,6 +183,20 @@ Storage.prototype.bucket = function(name) { return new Bucket(this, name); }; +/** + * Reference a channel to receive notifications about changes to your bucket. + * + * @param {string} id - The ID of the channel. + * @param {string} resourceId - The resource ID of the channel. + * @return {module:storage/channel} + * + * @example + * var channel = gcs.channel('id', 'resource-id'); + */ +Storage.prototype.channel = function(id, resourceId) { + return new Channel(this, id, resourceId); +}; + /** * Create a bucket. * diff --git a/system-test/storage.js b/system-test/storage.js index 5c46a55f6ae..e42abdcaa58 100644 --- a/system-test/storage.js +++ b/system-test/storage.js @@ -696,6 +696,25 @@ describe('storage', function() { }); }); + describe('channels', function() { + it('should create a channel', function(done) { + var config = { + address: 'https://yahoo.com' + }; + + bucket.createChannel('new-channel', config, function(err) { + // Actually creating a channel is pretty complicated. This will at least + // let us know we hit the right endpoint and it received "yahoo.com". + assert.strictEqual( + err.message, + 'Unauthorized WebHook callback channel: ' + config.address + ); + + done(); + }); + }); + }); + describe('combine files', function() { it('should combine multiple files into one', function(done) { var files = [ diff --git a/test/storage/bucket.js b/test/storage/bucket.js index 0f3132b2161..cfc1d08109f 100644 --- a/test/storage/bucket.js +++ b/test/storage/bucket.js @@ -393,6 +393,104 @@ describe('Bucket', function() { }); }); + describe('createChannel', function() { + var ID = 'id'; + var CONFIG = { + address: 'https://...' + }; + + it('should throw if an ID is not provided', function() { + assert.throws(function() { + bucket.createChannel(); + }, 'An ID is required to create a channel.'); + }); + + it('should throw if an address is not provided', function() { + assert.throws(function() { + bucket.createChannel(ID, {}); + }, 'An address is required to create a channel.'); + }); + + it('should make the correct request', function(done) { + var config = extend({}, CONFIG, { + a: 'b', + c: 'd' + }); + var originalConfig = extend({}, config); + + bucket.request = function(reqOpts) { + assert.strictEqual(reqOpts.method, 'POST'); + assert.strictEqual(reqOpts.uri, '/o/watch'); + + var expectedJson = extend({}, config, { + id: ID, + type: 'web_hook' + }); + assert.deepEqual(reqOpts.json, expectedJson); + assert.deepEqual(config, originalConfig); + + done(); + }; + + bucket.createChannel(ID, config, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = {}; + + beforeEach(function() { + bucket.request = function(reqOpts, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error & API response', function(done) { + bucket.createChannel(ID, CONFIG, function(err, channel, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(channel, null); + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + }); + + describe('success', function() { + var apiResponse = { + resourceId: 'resource-id' + }; + + beforeEach(function() { + bucket.request = function(reqOpts, callback) { + callback(null, apiResponse); + }; + }); + + it('should exec a callback with Channel & API response', function(done) { + var channel = {}; + + bucket.storage.channel = function(id, resourceId) { + assert.strictEqual(id, ID); + assert.strictEqual(resourceId, apiResponse.resourceId); + + return channel; + }; + + bucket.createChannel(ID, CONFIG, function(err, channel_, apiResponse_) { + assert.ifError(err); + + assert.strictEqual(channel_, channel); + assert.strictEqual(channel_.metadata, apiResponse); + + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + }); + }); + describe('deleteFiles', function() { it('should get files from the bucket', function(done) { var query = { a: 'b', c: 'd' }; diff --git a/test/storage/channel.js b/test/storage/channel.js new file mode 100644 index 00000000000..8cc6ee000ef --- /dev/null +++ b/test/storage/channel.js @@ -0,0 +1,121 @@ +/*! + * Copyright 2015 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 storage/channel + */ + +'use strict'; + +var assert = require('assert'); +var mockery = require('mockery'); +var nodeutil = require('util'); + +var ServiceObject = require('../../lib/common/service-object.js'); + +function FakeServiceObject() { + this.calledWith_ = arguments; + ServiceObject.apply(this, arguments); +} + +nodeutil.inherits(FakeServiceObject, ServiceObject); + +describe('Channel', function() { + var STORAGE = {}; + var ID = 'channel-id'; + var RESOURCE_ID = 'resource-id'; + + var Channel; + var channel; + + before(function() { + mockery.registerMock('../common/service-object.js', FakeServiceObject); + + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + + Channel = require('../../lib/storage/channel.js'); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); + + beforeEach(function() { + channel = new Channel(STORAGE, ID, RESOURCE_ID); + }); + + describe('initialization', function() { + it('should inherit from ServiceObject', function() { + assert(channel instanceof ServiceObject); + + var calledWith = channel.calledWith_[0]; + + assert.strictEqual(calledWith.parent, STORAGE); + assert.strictEqual(calledWith.baseUrl, '/channels'); + assert.strictEqual(calledWith.id, ID); + assert.deepEqual(calledWith.methods, {}); + }); + + it('should set the default metadata', function() { + assert.deepEqual(channel.metadata, { + id: ID, + resourceId: RESOURCE_ID + }); + }); + }); + + describe('stop', function() { + it('should make the correct request', function(done) { + channel.request = function(reqOpts) { + assert.strictEqual(reqOpts.method, 'POST'); + assert.strictEqual(reqOpts.uri, '/stop'); + assert.strictEqual(reqOpts.json, channel.metadata); + + done(); + }; + + channel.stop(assert.ifError); + }); + + it('should execute callback with error & API response', function(done) { + var error = {}; + var apiResponse = {}; + + channel.request = function(reqOpts, callback) { + callback(error, apiResponse); + }; + + channel.stop(function(err, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + + it('should not require a callback', function(done) { + channel.request = function(reqOpts, callback) { + assert.doesNotThrow(callback); + done(); + }; + + channel.stop(); + }); + }); +});