diff --git a/lib/storage/bucket.js b/lib/storage/bucket.js index 248b4fc6b86..f00c317a417 100644 --- a/lib/storage/bucket.js +++ b/lib/storage/bucket.js @@ -315,13 +315,16 @@ Bucket.prototype.delete = function(callback) { * the different use cases you may have. * * @param {string} name - The name of the file in this bucket. + * @param {object=} options - Configuration options. + * @param {string|number} options.generation - Only use a specific revision of + * this file. * @return {module:storage/file} * * @example * var file = bucket.file('my-existing-file.png'); */ -Bucket.prototype.file = function(name) { - return new File(this, name); +Bucket.prototype.file = function(name, options) { + return new File(this, name, options); }; /** @@ -339,6 +342,8 @@ Bucket.prototype.file = function(name) { * return. * @param {string} query.pageToken - A previously-returned page token * representing part of the larger set of results to view. + * @param {bool} query.versions - If true, returns File objects scoped to their + * versions. * @param {function} callback - The callback function. * * @example @@ -363,27 +368,50 @@ Bucket.prototype.file = function(name) { * bucket.getFiles({ * maxResults: 5 * }, function(err, files, nextQuery, apiResponse) {}); + * + * //- + * // If your bucket has versioning enabled, you can get all of your files + * // scoped to their generation. + * //- + * bucket.getFiles({ + * versions: true + * }, function(err, files, nextQuery, apiResponse) { + * // Each file is scoped to its generation. + * }); */ Bucket.prototype.getFiles = function(query, callback) { - var that = this; + var self = this; + if (!callback) { callback = query; query = {}; } + this.makeReq_('GET', '/o', query, true, function(err, resp) { if (err) { callback(err, null, null, resp); return; } + var files = (resp.items || []).map(function(item) { - var file = that.file(item.name); + var options = {}; + + if (query.versions) { + options.generation = item.generation; + } + + var file = self.file(item.name, options); file.metadata = item; + return file; }); + var nextQuery = null; + if (resp.nextPageToken) { nextQuery = extend({}, query, { pageToken: resp.nextPageToken }); } + callback(null, files, nextQuery, resp); }); }; @@ -634,6 +662,15 @@ Bucket.prototype.makePublic = function(options, callback) { * notFoundPage: 'http://example.com/404.html' * } * }, function(err, metadata, apiResponse) {}); + * + * //- + * // Enable versioning for your bucket. + * //- + * bucket.setMetadata({ + * versioning: { + * enabled: true + * } + * }, function(err, metadata, apiResponse) {}); */ Bucket.prototype.setMetadata = function(metadata, callback) { var that = this; diff --git a/lib/storage/file.js b/lib/storage/file.js index 106ea06ce3c..e8644135fe8 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -54,9 +54,8 @@ var STORAGE_UPLOAD_BASE_URL = 'https://www.googleapis.com/upload/storage/v1/b'; * @param {module:storage/bucket} bucket - The Bucket instance this file is * attached to. * @param {string} name - The name of the remote file. - * @param {object=} metadata - Metadata to set on the object. This is useful - * when you are creating a file for the first time, to prevent making an - * extra call to `setMetadata`. + * @param {object=} options - Configuration object. + * @param {number} options.generation - Generation to scope the file to. */ /** * A File object is created from your Bucket object using @@ -65,14 +64,17 @@ var STORAGE_UPLOAD_BASE_URL = 'https://www.googleapis.com/upload/storage/v1/b'; * @alias module:storage/file * @constructor */ -function File(bucket, name, metadata) { +function File(bucket, name, options) { if (!name) { throw Error('A file name must be specified.'); } + options = options || {}; + this.bucket = bucket; + this.explicitGeneration = parseInt(options.generation, 10); this.makeReq_ = bucket.makeReq_.bind(bucket); - this.metadata = metadata || {}; + this.metadata = {}; Object.defineProperty(this, 'name', { enumerable: true, @@ -180,9 +182,11 @@ function File(bucket, name, metadata) { */ File.prototype.copy = function(destination, callback) { var noDestinationError = new Error('Destination file should have a name.'); + if (!destination) { throw noDestinationError; } + callback = callback || util.noop; var destBucket; @@ -209,11 +213,19 @@ File.prototype.copy = function(destination, callback) { destBucket: destBucket.name, destName: encodeURIComponent(destName) }); - this.makeReq_('POST', path, null, {}, function(err, resp) { + + var query = {}; + + if (this.explicitGeneration) { + query.sourceGeneration = this.explicitGeneration; + } + + this.makeReq_('POST', path, query, null, function(err, resp) { if (err) { callback(err, null, resp); return; } + callback(null, newFile || destBucket.file(destName), resp); }); }; @@ -331,6 +343,12 @@ File.prototype.createReadStream = function(options) { uri: uri }; + if (that.explicitGeneration) { + reqOpts.qs = { + generation: that.explicitGeneration + }; + } + if (rangeRequest) { var start = util.is(options.start, 'number') ? options.start : '0'; var end = util.is(options.end, 'number') ? options.end : ''; @@ -642,13 +660,21 @@ File.prototype.createWriteStream = function(options) { */ File.prototype.delete = function(callback) { callback = callback || util.noop; + var path = '/o/' + encodeURIComponent(this.name); - this.makeReq_('DELETE', path, null, true, function(err, resp) { + var query = {}; + + if (this.explicitGeneration) { + query.generation = this.explicitGeneration; + } + + this.makeReq_('DELETE', path, query, null, function(err, resp) { if (err) { callback(err, resp); return; } + callback(null, resp); }); }; @@ -720,13 +746,21 @@ File.prototype.download = function(options, callback) { File.prototype.getMetadata = function(callback) { var self = this; callback = callback || util.noop; + var path = '/o/' + encodeURIComponent(this.name); - this.makeReq_('GET', path, null, true, function(err, resp) { + var query = {}; + + if (this.explicitGeneration) { + query.generation = this.explicitGeneration; + } + + this.makeReq_('GET', path, query, null, function(err, resp) { if (err) { callback(err, null, resp); return; } + self.metadata = resp; callback(null, self.metadata, resp); }); @@ -816,11 +850,17 @@ File.prototype.getSignedUrl = function(options, callback) { * }, function(err, metadata, apiResponse) {}); */ File.prototype.setMetadata = function(metadata, callback) { + callback = callback || util.noop; + var that = this; var path = '/o/' + encodeURIComponent(this.name); - callback = callback || util.noop; + var query = {}; - this.makeReq_('PATCH', path, null, metadata, function(err, resp) { + if (this.explicitGeneration) { + query.generation = this.explicitGeneration; + } + + this.makeReq_('PATCH', path, query, metadata, function(err, resp) { if (err) { callback(err, null, resp); return; @@ -966,7 +1006,7 @@ File.prototype.startResumableUpload_ = function(stream, metadata) { headers['X-Upload-Content-Type'] = metadata.contentType; } - makeAuthorizedRequest({ + var reqOpts = { method: 'POST', uri: util.format('{base}/{bucket}/o', { base: STORAGE_UPLOAD_BASE_URL, @@ -978,7 +1018,13 @@ File.prototype.startResumableUpload_ = function(stream, metadata) { }, headers: headers, json: metadata - }, function(err, res, body) { + }; + + if (that.explicitGeneration) { + reqOpts.qs.ifGenerationMatch = that.explicitGeneration; + } + + makeAuthorizedRequest(reqOpts, function(err, res, body) { if (err) { handleError(err); return; @@ -1176,18 +1222,24 @@ File.prototype.startResumableUpload_ = function(stream, metadata) { File.prototype.startSimpleUpload_ = function(stream, metadata) { var that = this; + var reqOpts = { + qs: { + name: that.name + }, + uri: util.format('{base}/{bucket}/o', { + base: STORAGE_UPLOAD_BASE_URL, + bucket: that.bucket.name + }) + }; + + if (this.explicitGeneration) { + reqOpts.qs.ifGenerationMatch = this.explicitGeneration; + } + util.makeWritableStream(stream, { makeAuthorizedRequest: that.bucket.storage.makeAuthorizedRequest_, metadata: metadata, - request: { - qs: { - name: that.name - }, - uri: util.format('{base}/{bucket}/o', { - base: STORAGE_UPLOAD_BASE_URL, - bucket: that.bucket.name - }) - } + request: reqOpts }, function(data) { that.metadata = data; diff --git a/lib/storage/index.js b/lib/storage/index.js index 9db70aded8d..092e777e5a9 100644 --- a/lib/storage/index.js +++ b/lib/storage/index.js @@ -190,19 +190,31 @@ Storage.prototype.bucket = function(name) { * @param {function} callback - The callback function. * * @example - * storage.createBucket('new-bucket', function(err, bucket, apiResponse) { + * var callback = function(err, bucket, apiResponse) { * // `bucket` is a Bucket object. - * }); + * }; + * + * storage.createBucket('new-bucket', callback); * - * // Specifying metadata. + * //- + * // Specify metadata. + * //- * var metadata = { * mainPageSuffix: '/unknown/', * maxAgeSeconds: 90 * }; * - * var callback = function(err, bucket, apiResponse) { - * // `bucket` is a Bucket object. - * } + * storage.createBucket('new-bucket', metadata, callback); + * + * //- + * // Enable versioning on a new bucket. + * //- + * var metadata = { + * versioning: { + * enabled: true + * } + * }; + * * storage.createBucket('new-bucket', metadata, callback); */ Storage.prototype.createBucket = function(name, metadata, callback) { diff --git a/regression/storage.js b/regression/storage.js index e2947915710..28b012c3578 100644 --- a/regression/storage.js +++ b/regression/storage.js @@ -14,8 +14,6 @@ * limitations under the License. */ -/*global describe, it, before, after, beforeEach, afterEach */ - 'use strict'; var assert = require('assert'); @@ -44,6 +42,17 @@ var files = { } }; +function deleteVersionedFiles(bucket, callback) { + bucket.getFiles({ versions: true }, function(err, files) { + if (err) { + callback(err); + return; + } + + async.each(files, deleteFile, callback); + }); +} + function deleteFiles(bucket, callback) { bucket.getFiles(function(err, files) { if (err) { @@ -58,6 +67,13 @@ function deleteFile(file, callback) { file.delete(callback); } +function writeToFile(file, contents, callback) { + var writeStream = file.createWriteStream(); + writeStream.once('error', callback); + writeStream.once('complete', callback.bind(null, null)); + writeStream.end(contents); +} + function generateBucketName() { return 'gcloud-test-bucket-temp-' + uuid.v1(); } @@ -214,12 +230,7 @@ describe('storage', function() { }); function createFileWithContent(content, callback) { - bucket.file(uuid() + '.txt').createWriteStream() - .on('error', callback) - .on('complete', function() { - callback(); - }) - .end(content); + writeToFile(bucket.file(uuid() + '.txt'), content, callback); } function isFilePublic(file, callback) { @@ -274,12 +285,7 @@ describe('storage', function() { }); function createFileWithContent(content, callback) { - bucket.file(uuid() + '.txt').createWriteStream() - .on('error', callback) - .on('complete', function() { - callback(); - }) - .end(content); + writeToFile(bucket.file(uuid() + '.txt'), content, callback); } function isFilePrivate(file, callback) { @@ -670,11 +676,8 @@ describe('storage', function() { }); }); - function createFile(fileObject, cb) { - var ws = fileObject.file.createWriteStream(); - ws.on('error', cb); - ws.on('complete', cb.bind(null, null)); - ws.end(fileObject.contents); + function createFile(fileObject, callback) { + writeToFile(fileObject.file, fileObject.contents, callback); } }); }); @@ -732,6 +735,89 @@ describe('storage', function() { }); }); + describe('file generations', function() { + var VERSIONED_BUCKET_NAME = generateBucketName(); + var versionedBucket; + + before(function(done) { + var opts = { versioning: { enabled: true } }; + + storage.createBucket(VERSIONED_BUCKET_NAME, opts, function(err, bucket) { + assert.ifError(err); + versionedBucket = bucket; + done(); + }); + }); + + afterEach(function(done) { + deleteVersionedFiles(versionedBucket, done); + }); + + after(function(done) { + versionedBucket.delete(done); + }); + + it('should overwrite file, then get older version', function(done) { + var VERSIONED_FILE_NAME = Date.now(); + var versionedFile = versionedBucket.file(VERSIONED_FILE_NAME); + + writeToFile(versionedFile, 'a', function(err) { + assert.ifError(err); + + versionedFile.getMetadata(function(err, metadata) { + assert.ifError(err); + + var initialGeneration = metadata.generation; + + writeToFile(versionedFile, 'b', function(err) { + assert.ifError(err); + + var firstGenFile = versionedBucket.file(VERSIONED_FILE_NAME, { + generation: initialGeneration + }); + + firstGenFile.download(function(err, contents) { + assert.ifError(err); + assert.equal(contents, 'a'); + done(); + }); + }); + }); + }); + + }); + + it('should get all files scoped to their version', function(done) { + var filesToCreate = [ + { file: versionedBucket.file('file-one.txt'), contents: '123' }, + { file: versionedBucket.file('file-one.txt'), contents: '456' } + ]; + + async.each(filesToCreate, createFile, function(err) { + assert.ifError(err); + + versionedBucket.getFiles({ versions: true }, function(err, files) { + assert.ifError(err); + + // same file. + assert.equal(files[0].name, files[1].name); + + // different generations. + assert.notEqual( + files[0].metadata.generation, + files[1].metadata.generation + ); + + done(); + }); + }); + + function createFile(fileObject, callback) { + writeToFile(fileObject.file, fileObject.contents, callback); + } + }); + }); + describe('sign urls', function() { var localFile = fs.readFileSync(files.logo.path); var file; diff --git a/test/storage/bucket.js b/test/storage/bucket.js index 9e185308c32..e1dee8d6617 100644 --- a/test/storage/bucket.js +++ b/test/storage/bucket.js @@ -14,8 +14,6 @@ * limitations under the License. */ -/*global describe, it, beforeEach, before, after */ - 'use strict'; var assert = require('assert'); @@ -27,12 +25,17 @@ var request = require('request'); var stream = require('stream'); var util = require('../../lib/common/util.js'); -function FakeFile(bucket, name, metadata) { +function FakeFile(bucket, name) { + var self = this; + + this.calledWith_ = arguments; + this.bucket = bucket; this.name = name; - this.metadata = metadata || {}; + this.metadata = {}; + this.createWriteStream = function(options) { - this.metadata = options.metadata; + self.metadata = options.metadata; var ws = new stream.Writable(); ws.write = function() { ws.emit('complete'); @@ -336,18 +339,26 @@ describe('Bucket', function() { describe('file', function() { var FILE_NAME = 'remote-file-name.jpg'; var file; - var metadata = { a: 'b' }; + var options = { a: 'b', c: 'd' }; beforeEach(function() { - file = bucket.file(FILE_NAME, metadata); + file = bucket.file(FILE_NAME, options); }); it('should return a File object', function() { assert(file instanceof FakeFile); }); + it('should pass bucket to File object', function() { + assert.deepEqual(file.calledWith_[0], bucket); + }); + it('should pass filename to File object', function() { - assert.equal(file.name, FILE_NAME); + assert.equal(file.calledWith_[1], FILE_NAME); + }); + + it('should pass configuration object to File', function() { + assert.deepEqual(file.calledWith_[2], options); }); }); @@ -394,11 +405,29 @@ describe('Bucket', function() { it('should return File objects', function(done) { bucket.makeReq_ = function(method, path, query, body, callback) { - callback(null, { items: [{ name: 'fake-file-name' }] }); + callback(null, { + items: [{ name: 'fake-file-name', generation: 1 }] + }); }; bucket.getFiles(function(err, files) { assert.ifError(err); assert(files[0] instanceof FakeFile); + assert.equal(typeof files[0].calledWith_[2].generation, 'undefined'); + done(); + }); + }); + + it('should return versioned Files if queried for versions', function(done) { + bucket.makeReq_ = function(method, path, query, body, callback) { + callback(null, { + items: [{ name: 'fake-file-name', generation: 1 }] + }); + }; + + bucket.getFiles({ versions: true }, function(err, files) { + assert.ifError(err); + assert(files[0] instanceof FakeFile); + assert.equal(files[0].calledWith_[2].generation, 1); done(); }); }); diff --git a/test/storage/file.js b/test/storage/file.js index a1dd7ae5ee8..42d4e89322e 100644 --- a/test/storage/file.js +++ b/test/storage/file.js @@ -14,8 +14,6 @@ * limitations under the License. */ -/*global describe, it, beforeEach, before, after */ - 'use strict'; var assert = require('assert'); @@ -137,10 +135,9 @@ describe('File', function() { assert.equal(file.name, FILE_NAME); }); - it('should assign metadata if provided', function() { - var metadata = { a: 'b', c: 'd' }; - var newFile = new File(bucket, FILE_NAME, metadata); - assert.deepEqual(newFile.metadata, metadata); + it('should accept specifying a generation', function() { + var file = new File(bucket, 'name', { generation: 2 }); + assert.equal(file.explicitGeneration, 2); }); }); @@ -169,6 +166,18 @@ describe('File', function() { directoryFile.copy(newFile); }); + it('should send query.sourceGeneration if File has one', function(done) { + var versionedFile = new File(bucket, 'name', { generation: 1 }); + var newFile = new File(bucket, 'new-file'); + + versionedFile.makeReq_ = function(method, path, query) { + assert.equal(query.sourceGeneration, 1); + done(); + }; + + versionedFile.copy(newFile, assert.ifError); + }); + describe('destination types', function() { function assertPathEquals(file, expectedPath, callback) { file.makeReq_ = function(method, path) { @@ -448,6 +457,17 @@ describe('File', function() { }); }); + it('should send query.generation if File has one', function(done) { + var versionedFile = new File(bucket, 'file.txt', { generation: 1 }); + + versionedFile.bucket.storage.makeAuthorizedRequest_ = function(reqOpts) { + assert.equal(reqOpts.qs.generation, 1); + done(); + }; + + versionedFile.createReadStream(); + }); + it('should accept a start range', function(done) { var startOffset = 100; @@ -783,7 +803,7 @@ describe('File', function() { file.makeReq_ = function(method, path, query, body) { assert.equal(method, 'DELETE'); assert.equal(path, '/o/' + FILE_NAME); - assert.strictEqual(query, null); + assert.deepEqual(query, {}); assert.strictEqual(body, true); done(); }; @@ -799,6 +819,17 @@ describe('File', function() { directoryFile.delete(); }); + it('should send query.generation if File has one', function(done) { + var versionedFile = new File(bucket, 'new-file.txt', { generation: 1 }); + + versionedFile.makeReq_ = function(method, path, query) { + assert.equal(query.generation, 1); + done(); + }; + + versionedFile.delete(); + }); + it('should execute callback', function(done) { file.makeReq_ = function(method, path, query, body, callback) { callback(); @@ -957,8 +988,8 @@ describe('File', function() { file.makeReq_ = function(method, path, query, body) { assert.equal(method, 'GET'); assert.equal(path, '/o/' + FILE_NAME); - assert.strictEqual(query, null); - assert.strictEqual(body, true); + assert.deepEqual(query, {}); + assert.strictEqual(body, null); done(); }; file.getMetadata(); @@ -973,6 +1004,17 @@ describe('File', function() { directoryFile.getMetadata(); }); + it('should send query.generation if File has one', function(done) { + var versionedFile = new File(bucket, 'new-file.txt', { generation: 1 }); + + versionedFile.makeReq_ = function(method, path, query) { + assert.equal(query.generation, 1); + done(); + }; + + versionedFile.getMetadata(); + }); + it('should execute callback', function(done) { file.makeReq_ = function(method, path, query, body, callback) { callback(); @@ -1094,6 +1136,17 @@ describe('File', function() { directoryFile.setMetadata(metadata); }); + it('should send query.generation if File has one', function(done) { + var versionedFile = new File(bucket, 'new-file.txt', { generation: 1 }); + + versionedFile.makeReq_ = function(method, path, query) { + assert.equal(query.generation, 1); + done(); + }; + + versionedFile.setMetadata(); + }); + it('should execute callback', function(done) { file.makeReq_ = function(method, path, query, body, callback) { callback(); @@ -1204,6 +1257,17 @@ describe('File', function() { file.startResumableUpload_(duplexify(), { contentType: 'custom' }); }); + it('should send query.ifGenerationMatch if File has one', function(done) { + var versionedFile = new File(bucket, 'new-file.txt', { generation: 1 }); + + versionedFile.bucket.storage.makeAuthorizedRequest_ = function(rOpts) { + assert.equal(rOpts.qs.ifGenerationMatch, 1); + done(); + }; + + versionedFile.startResumableUpload_(duplexify(), {}); + }); + it('should upload file', function(done) { var requestCount = 0; file.bucket.storage.makeAuthorizedRequest_ = function(reqOpts, cb) { @@ -1351,6 +1415,17 @@ describe('File', function() { file.startSimpleUpload_(duplexify(), metadata); }); + it('should send query.ifGenerationMatch if File has one', function(done) { + var versionedFile = new File(bucket, 'new-file.txt', { generation: 1 }); + + makeWritableStream_Override = function(stream, options) { + assert.equal(options.request.qs.ifGenerationMatch, 1); + done(); + }; + + versionedFile.startSimpleUpload_(duplexify(), {}); + }); + it('should finish stream and set metadata', function(done) { var metadata = { a: 'b', c: 'd' };