diff --git a/lib/storage/bucket.js b/lib/storage/bucket.js index c7db7235a5c..88afcc86935 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 @@ -365,25 +370,38 @@ Bucket.prototype.file = function(name) { * }, function(err, files, nextQuery) {}); */ 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); 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); }); }; diff --git a/lib/storage/file.js b/lib/storage/file.js index c5d7ca3407c..c4521bbecdc 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -65,14 +65,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,39 +183,55 @@ 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; var destName; var newFile; + if (util.is(destination, 'string')) { destBucket = this.bucket; destName = destination; } + if (destination.constructor && destination.constructor.name === 'Bucket') { destBucket = destination; destName = this.name; } + if (destination instanceof File) { destBucket = destination.bucket; destName = destination.name; newFile = destination; } + if (!destName) { throw noDestinationError; } + var path = util.format('/o/{srcName}/copyTo/b/{destBucket}/o/{destName}', { srcName: encodeURIComponent(this.name), destBucket: destBucket.name, destName: encodeURIComponent(destName) }); - this.makeReq_('POST', path, null, {}, function(err) { + + var query = {}; + + if (this.explicitGeneration) { + query.sourceGeneration = this.explicitGeneration; + } + + this.makeReq_('POST', path, query, null, function(err) { if (err) { callback(err); return; } + callback(null, newFile || destBucket.file(destName)); }); }; @@ -330,6 +349,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 : ''; @@ -641,14 +666,22 @@ 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) { + var query = {}; + + if (this.explicitGeneration) { + query.generation = this.explicitGeneration; + } + + this.makeReq_('DELETE', path, query, true, function(err) { if (err) { callback(err); return; } + callback(); - }.bind(this)); + }); }; /** @@ -717,15 +750,24 @@ File.prototype.download = function(options, callback) { */ File.prototype.getMetadata = function(callback) { callback = callback || util.noop; + + var that = this; 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); return; } - this.metadata = resp; - callback(null, this.metadata); - }.bind(this)); + + that.metadata = resp; + callback(null, that.metadata); + }); }; /** @@ -811,11 +853,17 @@ File.prototype.getSignedUrl = function(options, callback) { * }, function(err, metadata) {}); */ 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 = {}; + + if (this.explicitGeneration) { + query.generation = this.explicitGeneration; + } - this.makeReq_('PATCH', path, null, metadata, function(err, resp) { + this.makeReq_('PATCH', path, query, metadata, function(err, resp) { if (err) { callback(err); return; @@ -961,7 +1009,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, @@ -973,7 +1021,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; @@ -1171,18 +1225,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/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 9333db5c63a..25b8825ddfe 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'); @@ -310,18 +313,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); }); }); @@ -368,11 +379,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 857478ab290..c83670c6f6d 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) { @@ -439,6 +448,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; @@ -774,7 +794,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(); }; @@ -790,6 +810,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(); @@ -937,8 +968,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(); @@ -953,6 +984,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(); @@ -1063,6 +1105,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(); @@ -1162,6 +1215,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) { @@ -1309,6 +1373,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' };