From 2520e7b6c620242deff651b0df50b9c2fbf610e3 Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Sat, 27 Feb 2016 16:33:34 -0800 Subject: [PATCH 1/8] Initial commit of Google Cloud Storage File Adapter --- package.json | 1 + src/Adapters/Files/GCSAdapter.js | 96 ++++++++++++++++++++++++++++++++ src/index.js | 14 +++-- 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 src/Adapters/Files/GCSAdapter.js diff --git a/package.json b/package.json index 9837376d53..22b11e1cdc 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "commander": "^2.9.0", "deepcopy": "^0.6.1", "express": "^4.13.4", + "gcloud": "^0.28.0", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js new file mode 100644 index 0000000000..a5ee40963d --- /dev/null +++ b/src/Adapters/Files/GCSAdapter.js @@ -0,0 +1,96 @@ +// GCSAdapter +// Store Parse Files in Google Cloud Storage: https://cloud.google.com/storage +import * as gcloud from 'gcloud'; +import { FilesAdapter } from './FilesAdapter'; + +export class GCSAdapter extends FilesAdapter { + // GCS Project ID and the name of a corresponding Keyfile are required. + // See https://googlecloudplatform.github.io/gcloud-node/#/docs/master/guides/authentication + // for more details. + constructor( + projectId, + keyFilename, + bucket, + { bucketPrefix = '', + directAccess = false } = {} + ) { + super(); + + this._bucket = bucket; + this._bucketPrefix = bucketPrefix; + this._directAccess = directAccess; + + let gcsOptions = { + projectId: projectId, + keyFilename: keyFilename + }; + + this._gcsClient = new gcloud.storage(gcsOptions); + } + + // For a given config object, filename, and data, store a file in GCS. + // Resolves the promise or fails with an error. + createFile(config, filename, data) { + return new Promise((resolve, reject) => { + let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // gcloud supports upload(file) not upload(bytes), so we need to stream. + var uploadStream = file.createWriteStream(options); + uploadStream.on('error', (err) => { + return reject(err); + }).on('finish', () => { + // Second call to set public read ACL after object is uploaded. + if (this._directAccess) { + file.makePublic((err, res) => { + if (err !== null) { + return reject(err); + } + resolve(); + }); + } + resolve(); + }); + uploadStream.write(data); + uploadStream.end(); + }); + } + + // Deletes a file with the given file name. + // Returns a promise that succeeds with the delete response, or fails with an error. + deleteFile(config, filename) { + return new Promise((resolve, reject) => { + let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + file.delete((err, res) => { + if(err !== null) { + return reject(err); + } + resolve(res); + }); + }); + } + + // Search for and return a file if found by filename. + // Returns a promise that succeeds with the buffer result from GCS, or fails with an error. + getFileData(config, filename) { + return new Promise((resolve, reject) => { + let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + file.download((err, data) => { + if (err !== null) { + return reject(err); + } + resolve(data); + }); + }); + } + + // Generates and returns the location of a file stored in GCS for the given request and filename. + // The location is the direct GCS link if the option is set, + // otherwise we serve the file through parse-server. + getFileLocation(config, filename) { + if (this._directAccess) { + return `https://${this._bucket}.storage.googleapis.com/${this._bucketPrefix + filename}`; + } + return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); + } +} + +export default GCSAdapter; diff --git a/src/index.js b/src/index.js index ad715902f5..6112a3ea7d 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ import cache from './cache'; import PromiseRouter from './PromiseRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { S3Adapter } from './Adapters/Files/S3Adapter'; +import { GCSAdapter } from './Adapters/Files/GCSAdapter'; import { FilesController } from './Controllers/FilesController'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; @@ -91,11 +92,11 @@ function ParseServer({ serverURL = requiredParameter('You must provide a serverURL!'), maxUploadSize = '20mb' }) { - + // Initialize the node client SDK automatically Parse.initialize(appId, javascriptKey || 'unused', masterKey); Parse.serverURL = serverURL; - + if (databaseAdapter) { DatabaseAdapter.setAdapter(databaseAdapter); } @@ -103,7 +104,7 @@ function ParseServer({ if (databaseURI) { DatabaseAdapter.setAppDatabaseURI(appId, databaseURI); } - + if (cloud) { addParseCloud(); if (typeof cloud === 'function') { @@ -125,7 +126,7 @@ function ParseServer({ const pushController = new PushController(pushControllerAdapter); const loggerController = new LoggerController(loggerControllerAdapter); const hooksController = new HooksController(appId, collectionPrefix); - + cache.apps[appId] = { masterKey: masterKey, collectionPrefix: collectionPrefix, @@ -185,7 +186,7 @@ function ParseServer({ if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { routers.push(require('./global_config')); } - + if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { routers.push(new HooksRouter()); } @@ -229,5 +230,6 @@ function getClassName(parseClass) { module.exports = { ParseServer: ParseServer, - S3Adapter: S3Adapter + S3Adapter: S3Adapter, + GCSAdapter: GCSAdapter }; From deafb680aef5befa52ba636c989d556167ec9144 Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Sat, 27 Feb 2016 16:47:26 -0800 Subject: [PATCH 2/8] Removed orphaned "options" --- src/Adapters/Files/GCSAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js index a5ee40963d..23f9ef0241 100644 --- a/src/Adapters/Files/GCSAdapter.js +++ b/src/Adapters/Files/GCSAdapter.js @@ -34,7 +34,7 @@ export class GCSAdapter extends FilesAdapter { return new Promise((resolve, reject) => { let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); // gcloud supports upload(file) not upload(bytes), so we need to stream. - var uploadStream = file.createWriteStream(options); + var uploadStream = file.createWriteStream(); uploadStream.on('error', (err) => { return reject(err); }).on('finish', () => { From 360cc3461d242c669d2ba6f315a4a5a930c2de9a Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Sat, 27 Feb 2016 21:27:32 -0800 Subject: [PATCH 3/8] Added tests! Note that tests won't run without a GCP Project, key, and GCS bucket --- spec/ParseFile+GCS.spec.js | 398 +++++++++++++++++++++++++++++++ src/Adapters/Files/GCSAdapter.js | 3 +- 2 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 spec/ParseFile+GCS.spec.js diff --git a/spec/ParseFile+GCS.spec.js b/spec/ParseFile+GCS.spec.js new file mode 100644 index 0000000000..850704a399 --- /dev/null +++ b/spec/ParseFile+GCS.spec.js @@ -0,0 +1,398 @@ +// This is a port of the test suite: +// hungry/js/test/parse_file_test.js + +"use strict"; + +var request = require('request'); +var GCSAdapter = require('../src/index').GCSAdapter; + +var str = "Hello World!"; +var data = []; +for (var i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); +} + +// Make sure that you fill these in, otherwise the tests won't run!!! +var GCP_PROJECT_ID = ""; +var GCP_KEYFILE_PATH = ""; +var GCS_BUCKET_NAME = ""; + +describe('Parse.File testing', () => { + describe('GCS directAccess: false', () => { + beforeEach(function(done){ + var port = 8378; + var GCSConfiguration = { + databaseURI: process.env.DATABASE_URI, + serverURL: 'http://localhost:' + port + '/1', + appId: 'test', + javascriptKey: 'test', + restAPIKey: 'rest', + masterKey: 'test', + fileKey: 'test', + filesAdapter: new GCSAdapter( + GCP_PROJECT_ID, + GCP_KEYFILE_PATH, + GCS_BUCKET_NAME, + { + bucketPrefix: 'private/', + directAccess: false + } + ) + }; + setServerConfiguration(GCSConfiguration); + done(); + }); + + it('works with Content-Type', done => { + var headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_file.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + + it('works without Content-Type', done => { + var headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_file.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + + it('supports REST end-to-end file create, read, delete, read', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/testfile.txt', + body: 'check one two', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_testfile.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('check one two'); + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(200); + request.get({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: b.url + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(404); + done(); + }); + }); + }); + }); + }); + + it('blocks file deletions with missing or incorrect master-key header', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/thefile.jpg', + body: 'the file body' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); + // missing X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b = JSON.parse(body); + expect(response.statusCode).toEqual(403); + expect(del_b.error).toMatch(/unauthorized/); + // incorrect X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'tryagain' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b2 = JSON.parse(body); + expect(response.statusCode).toEqual(403); + expect(del_b2.error).toMatch(/unauthorized/); + done(); + }); + }); + }); + }); + + it('handles other filetypes', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/file.jpg', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_file.jpg$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + }); + + describe('GCS directAccess: true', () => { + beforeEach(function(done){ + var port = 8378; + var GCSConfiguration = { + databaseURI: process.env.DATABASE_URI, + serverURL: 'http://localhost:' + port + '/1', + appId: 'test', + javascriptKey: 'test', + restAPIKey: 'rest', + masterKey: 'test', + fileKey: 'test', + filesAdapter: new GCSAdapter( + GCP_PROJECT_ID, + GCP_KEYFILE_PATH, + GCS_BUCKET_NAME, + { + bucketPrefix: 'public/', + directAccess: true + } + ) + }; + setServerConfiguration(GCSConfiguration); + done(); + }); + + it('works with Content-Type', done => { + var headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_file.txt$/); + var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*file.txt") + expect(b.url).toMatch(gcsRegex); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + + it('works without Content-Type', done => { + var headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_file.txt$/); + var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*file.txt") + expect(b.url).toMatch(gcsRegex); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + + it('supports REST end-to-end file create, read, delete, read', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + // Create the file + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/testfile.txt', + body: 'check one two', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_testfile.txt$/); + var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*testfile.txt") + expect(b.url).toMatch(gcsRegex); + // Read the file the first time + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('check one two'); + // Delete the file + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(200); + // Read the file the second time--expect it to be gone + // Note that we're reading from the public cloud storage URL + // This is different from the above test since it's assumed + // users are reading from the public URL + request.get({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: "https://" + GCS_BUCKET_NAME + ".storage.googleapis.com/public/.*testfile.txt" + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(404); + done(); + }); + }); + }); + }); + }); + + it('blocks file deletions with missing or incorrect master-key header', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/thefile.jpg', + body: 'the file body' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*thefile.jpg") + expect(b.url).toMatch(gcsRegex); + // missing X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b = JSON.parse(body); + expect(response.statusCode).toEqual(403); + expect(del_b.error).toMatch(/unauthorized/); + // incorrect X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'tryagain' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b2 = JSON.parse(body); + expect(response.statusCode).toEqual(403); + expect(del_b2.error).toMatch(/unauthorized/); + done(); + }); + }); + }); + }); + + it('handles other filetypes', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/file.jpg', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_file.jpg$/); + var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*file.jpg") + expect(b.url).toMatch(gcsRegex); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + }); +}); diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js index 23f9ef0241..b0f841e062 100644 --- a/src/Adapters/Files/GCSAdapter.js +++ b/src/Adapters/Files/GCSAdapter.js @@ -46,8 +46,9 @@ export class GCSAdapter extends FilesAdapter { } resolve(); }); + } else { + resolve(); } - resolve(); }); uploadStream.write(data); uploadStream.end(); From 3e41a21e27348ed737be3c46b06f17f44aedbc3d Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Sat, 27 Feb 2016 22:13:20 -0800 Subject: [PATCH 4/8] x-ing out the test suite for CI buildability. --- spec/ParseFile+GCS.spec.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/ParseFile+GCS.spec.js b/spec/ParseFile+GCS.spec.js index 850704a399..ae6543e230 100644 --- a/spec/ParseFile+GCS.spec.js +++ b/spec/ParseFile+GCS.spec.js @@ -17,7 +17,9 @@ var GCP_PROJECT_ID = ""; var GCP_KEYFILE_PATH = ""; var GCS_BUCKET_NAME = ""; -describe('Parse.File testing', () => { +// Note the 'xdescribe', make sure to delete the 'x' once the above vars +// are filled in to run the test suite +xdescribe('Parse.File GCS testing', () => { describe('GCS directAccess: false', () => { beforeEach(function(done){ var port = 8378; From 5b8ad9d4c6c727475155a53928aaa403cc24e0d8 Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Thu, 3 Mar 2016 22:50:19 -0800 Subject: [PATCH 5/8] No more .DS_Store --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index de88257b3b..8ea322c438 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ lib/ # cache folder .cache + +# Mac DS_Store files +.DS_Store From ce35b81cc6d6ab0b774f59b71ea0c235ebd73441 Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Mon, 7 Mar 2016 00:30:21 -0800 Subject: [PATCH 6/8] New things for GCS Adapter --- spec/FilesControllerTestFactory.js | 25 ++++++++++++------------ src/Adapters/AdapterLoader.js | 7 +++---- src/Adapters/Files/GCSAdapter.js | 27 ++++++++++++++++++++------ src/Controllers/AdaptableController.js | 18 ++++++++--------- src/Controllers/FilesController.js | 8 +++++--- 5 files changed, 50 insertions(+), 35 deletions(-) diff --git a/spec/FilesControllerTestFactory.js b/spec/FilesControllerTestFactory.js index 217a383a16..b467d031f5 100644 --- a/spec/FilesControllerTestFactory.js +++ b/spec/FilesControllerTestFactory.js @@ -1,35 +1,34 @@ - var FilesController = require('../src/Controllers/FilesController').FilesController; var Config = require("../src/Config"); var testAdapter = function(name, adapter) { // Small additional tests to improve overall coverage - + var config = new Config(Parse.applicationId); var filesController = new FilesController(adapter); describe("FilesController with "+name,()=>{ - + it("should properly expand objects", (done) => { - + var result = filesController.expandFilesInObject(config, function(){}); - + expect(result).toBeUndefined(); - + var fullFile = { type: '__type', url: "http://an.url" } - + var anObject = { aFile: fullFile } filesController.expandFilesInObject(config, anObject); expect(anObject.aFile.url).toEqual("http://an.url"); - + done(); - }) - + }) + it("should properly create, read, delete files", (done) => { var filename; filesController.createFile(config, "file.txt", "hello world").then( (result) => { @@ -51,14 +50,14 @@ var testAdapter = function(name, adapter) { console.error(err); done(); }).then((result) => { - + filesController.getFileData(config, filename).then((res) => { fail("the file should be deleted"); done(); }, (err) => { - done(); + done(); }); - + }, (err) => { fail("The adapter should delete the file"); console.error(err); diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index a9521f0b03..654948e96c 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,6 +1,5 @@ export function loadAdapter(adapter, defaultAdapter, options) { - - if (!adapter) + if (!adapter) { if (!defaultAdapter) { return options; @@ -20,7 +19,7 @@ export function loadAdapter(adapter, defaultAdapter, options) { if (adapter.default) { adapter = adapter.default; } - + return loadAdapter(adapter, undefined, options); } else if (adapter.module) { return loadAdapter(adapter.module, undefined, adapter.options); @@ -30,7 +29,7 @@ export function loadAdapter(adapter, defaultAdapter, options) { return loadAdapter(adapter.adapter, undefined, adapter.options); } // return the adapter as provided - return adapter; + return adapter; } export default loadAdapter; diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js index e749502d5e..065592eb6b 100644 --- a/src/Adapters/Files/GCSAdapter.js +++ b/src/Adapters/Files/GCSAdapter.js @@ -4,18 +4,33 @@ import { storage } from 'gcloud'; import { FilesAdapter } from './FilesAdapter'; import requiredParameter from '../../requiredParameter'; +function requiredOrFromEnvironment(env, name) { + let environmentVariable = process.env[env]; + if (!environmentVariable) { + requiredParameter(`GCSAdapter requires an ${name}`); + } + return environmentVariable; +} + +function fromEnvironmentOrDefault(env, defaultValue) { + let environmentVariable = process.env[env]; + if (environmentVariable) { + return environmentVariable; + } + return defaultValue; +} + export class GCSAdapter extends FilesAdapter { // GCS Project ID and the name of a corresponding Keyfile are required. // Unlike the S3 adapter, you must create a new Cloud Storage bucket, as this is not created automatically. // See https://googlecloudplatform.github.io/gcloud-node/#/docs/master/guides/authentication // for more details. constructor( - projectId = requiredParameter('GCSAdapter requires a GCP Project ID'), - keyFilename = requiredParameter('GCSAdapter requires a GCP keyfile'), - bucket = requiredParameter('GCSAdapter requires a GCS bucket name'), - { bucketPrefix = '', - directAccess = false } = {} - ) { + projectId = requiredOrFromEnvironment('GCP_PROJECT_ID', 'projectId'), + keyFilename = requiredOrFromEnvironment('GCP_KEYFILE_PATH', 'keyfile path'), + bucket = requiredOrFromEnvironment('GCS_BUCKET_NAME', 'bucket name'), + { bucketPrefix = fromEnvironmentOrDefault('GCS_BUCKET_PREFIX', ''), + directAccess = fromEnvironmentOrDefault('GCS_DIRECT_ACCESS', false) } = {}) { super(); this._bucket = bucket; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index ab7d715667..7ff8ce2906 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -2,7 +2,7 @@ AdaptableController.js AdaptableController is the base class for all controllers -that support adapter, +that support adapter, The super class takes care of creating the right instance for the adapter based on the parameters passed @@ -28,30 +28,30 @@ export class AdaptableController { this.validateAdapter(adapter); this[_adapter] = adapter; } - + get adapter() { return this[_adapter]; } - + get config() { return new Config(this.appId); } - + expectedAdapterType() { throw new Error("Subclasses should implement expectedAdapterType()"); } - + validateAdapter(adapter) { if (!adapter) { throw new Error(this.constructor.name+" requires an adapter"); } - + let Type = this.expectedAdapterType(); // Allow skipping for testing - if (!Type) { + if (!Type) { return; } - + // Makes sure the prototype matches let mismatches = Object.getOwnPropertyNames(Type.prototype).reduce( (obj, key) => { const adapterType = typeof adapter[key]; @@ -64,7 +64,7 @@ export class AdaptableController { } return obj; }, {}); - + if (Object.keys(mismatches).length > 0) { throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches); } diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 712e326c16..9abd87ff9c 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -13,11 +13,11 @@ export class FilesController extends AdaptableController { } createFile(config, filename, data, contentType) { - + let extname = path.extname(filename); - + const hasExtension = extname.length > 0; - + if (!hasExtension && contentType && mime.extension(contentType)) { filename = filename + '.' + mime.extension(contentType); } else if (hasExtension && !contentType) { @@ -27,6 +27,8 @@ export class FilesController extends AdaptableController { filename = randomHexString(32) + '_' + filename; var location = this.adapter.getFileLocation(config, filename); + console.log(this.adapter); + console.log(location); return this.adapter.createFile(config, filename, data, contentType).then(() => { return Promise.resolve({ url: location, From 0f00d659cb95cbcf4baa683e3c4205f42632dae6 Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Mon, 7 Mar 2016 00:34:41 -0800 Subject: [PATCH 7/8] Removed extraneous console.log() --- src/Adapters/Files/GCSAdapter.js | 2 -- src/Controllers/FilesController.js | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js index 065592eb6b..8bd19447f5 100644 --- a/src/Adapters/Files/GCSAdapter.js +++ b/src/Adapters/Files/GCSAdapter.js @@ -82,7 +82,6 @@ export class GCSAdapter extends FilesAdapter { return new Promise((resolve, reject) => { let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); file.delete((err, res) => { - console.log("delete: ", filename, err, res); if(err !== null) { return reject(err); } @@ -100,7 +99,6 @@ export class GCSAdapter extends FilesAdapter { file.exists((err, exists) => { if (exists) { file.download((err, data) => { - console.log("get: ", filename, err, data); if (err !== null) { return reject(err); } diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 9abd87ff9c..335dfdf2bd 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -27,8 +27,6 @@ export class FilesController extends AdaptableController { filename = randomHexString(32) + '_' + filename; var location = this.adapter.getFileLocation(config, filename); - console.log(this.adapter); - console.log(location); return this.adapter.createFile(config, filename, data, contentType).then(() => { return Promise.resolve({ url: location, From 2c5144028bf5c641224a0fbd5accc63569dc2e6b Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Mon, 7 Mar 2016 00:47:08 -0800 Subject: [PATCH 8/8] Added tests to adapter loader, cleaned up README, renamed to GCS_BUCKET from GCS_BUCKET_NAME --- README.md | 22 ++++++++++++++++-- spec/AdapterLoader.spec.js | 40 ++++++++++++++++++++------------ spec/FilesController.spec.js | 8 +++---- src/Adapters/Files/GCSAdapter.js | 2 +- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b0a8d01559..c4a86d1056 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,14 @@ PARSE_SERVER_MAX_UPLOAD_SIZE ``` -##### Configuring S3 Adapter +##### Configuring File Adapters +Parse Server allows developers to choose from several options when hosting files: the `GridStoreAdapter`, which backed by MongoDB; the `S3Adapter`, which is backed by [Amazon S3](https://aws.amazon.com/s3/); or the `GCSAdapter`, which is backed by [Google Cloud Storage](https://cloud.google.com/storage/). -You can use the following environment variable setup the S3 adapter +`GridStoreAdapter` is used by default and requires no setup, but if you're interested in using S3 or GCS, additional configuration information is available below. + +###### Configuring `S3Adapter` + +You can use the following environment variable setup to enable the S3 adapter: ```js S3_ACCESS_KEY @@ -149,6 +154,19 @@ S3_DIRECT_ACCESS ``` +###### Configuring `GCSAdapter` + +You can use the following environment variable setup to enable the GCS adapter: + +```js +GCP_PROJECT_ID +GCP_KEYFILE_PATH +GCS_BUCKET +GCS_BUCKET_PREFIX +GCS_DIRECT_ACCESS + +``` + ## Contributing We really want Parse to be yours, to see it grow and thrive in the open source community. Please see the [Contributing to Parse Server guide](CONTRIBUTING.md). diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index 69381fc547..56bf0d448d 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -3,44 +3,45 @@ var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; var ParsePushAdapter = require("../src/Adapters/Push/ParsePushAdapter"); var S3Adapter = require("../src/Adapters/Files/S3Adapter").default; +var GCSAdapter = require("../src/Adapters/Files/GCSAdapter").default; describe("AdapterLoader", ()=>{ - + it("should instantiate an adapter from string in object", (done) => { var adapterPath = require('path').resolve("./spec/MockAdapter"); var adapter = loadAdapter({ adapter: adapterPath, options: { - key: "value", + key: "value", foo: "bar" } }); - + expect(adapter instanceof Object).toBe(true); expect(adapter.options.key).toBe("value"); expect(adapter.options.foo).toBe("bar"); done(); }); - + it("should instantiate an adapter from string", (done) => { var adapterPath = require('path').resolve("./spec/MockAdapter"); var adapter = loadAdapter(adapterPath); - + expect(adapter instanceof Object).toBe(true); done(); }); - + it("should instantiate an adapter from string that is module", (done) => { var adapterPath = require('path').resolve("./src/Adapters/Files/FilesAdapter"); var adapter = loadAdapter({ adapter: adapterPath }); - + expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - + it("should instantiate an adapter from function/Class", (done) => { var adapter = loadAdapter({ adapter: FilesAdapter @@ -48,27 +49,27 @@ describe("AdapterLoader", ()=>{ expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - + it("should instantiate the default adapter from Class", (done) => { var adapter = loadAdapter(null, FilesAdapter); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - + it("should use the default adapter", (done) => { var defaultAdapter = new FilesAdapter(); var adapter = loadAdapter(null, defaultAdapter); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - + it("should use the provided adapter", (done) => { var originalAdapter = new FilesAdapter(); var adapter = loadAdapter(originalAdapter); expect(adapter).toBe(originalAdapter); done(); }); - + it("should fail loading an improperly configured adapter", (done) => { var Adapter = function(options) { if (!options.foo) { @@ -79,14 +80,14 @@ describe("AdapterLoader", ()=>{ param: "key", doSomething: function() {} }; - + expect(() => { var adapter = loadAdapter(adapterOptions, Adapter); expect(adapter).toEqual(adapterOptions); }).not.toThrow("foo is required for that adapter"); done(); }); - + it("should load push adapter from options", (done) => { var options = { ios: { @@ -100,7 +101,7 @@ describe("AdapterLoader", ()=>{ }).not.toThrow(); done(); }); - + it("should load S3Adapter from direct passing", (done) => { var s3Adapter = new S3Adapter("key", "secret", "bucket") expect(() => { @@ -109,4 +110,13 @@ describe("AdapterLoader", ()=>{ }).not.toThrow(); done(); }) + + it("should load GCSAdapter from direct passing", (done) => { + var gcsAdapter = new GCSAdapter("projectId", "path/to/keyfile", "bucket") + expect(() => { + var adapter = loadAdapter(gcsAdapter, FilesAdapter); + expect(adapter).toBe(gcsAdapter); + }).not.toThrow(); + done(); + }) }); diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 183dcb2753..3b2108e71e 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -32,21 +32,21 @@ describe("FilesController",()=>{ console.log("set S3_ACCESS_KEY and S3_SECRET_KEY to test S3Adapter") } - if (process.env.GCP_PROJECT_ID && process.env.GCP_KEYFILE_PATH && process.env.GCS_BUCKET_NAME) { + if (process.env.GCP_PROJECT_ID && process.env.GCP_KEYFILE_PATH && process.env.GCS_BUCKET) { // Test the GCS Adapter - var gcsAdapter = new GCSAdapter(process.env.GCP_PROJECT_ID, process.env.GCP_KEYFILE_PATH, process.env.GCS_BUCKET_NAME); + var gcsAdapter = new GCSAdapter(process.env.GCP_PROJECT_ID, process.env.GCP_KEYFILE_PATH, process.env.GCS_BUCKET); FCTestFactory.testAdapter("GCSAdapter", gcsAdapter); // Test GCS with direct access - var gcsDirectAccessAdapter = new GCSAdapter(process.env.GCP_PROJECT_ID, process.env.GCP_KEYFILE_PATH, process.env.GCS_BUCKET_NAME, { + var gcsDirectAccessAdapter = new GCSAdapter(process.env.GCP_PROJECT_ID, process.env.GCP_KEYFILE_PATH, process.env.GCS_BUCKET, { directAccess: true }); FCTestFactory.testAdapter("GCSAdapterDirect", gcsDirectAccessAdapter); } else if (!process.env.TRAVIS) { - console.log("set GCP_PROJECT_ID, GCP_KEYFILE_PATH, and GCS_BUCKET_NAME to test GCSAdapter") + console.log("set GCP_PROJECT_ID, GCP_KEYFILE_PATH, and GCS_BUCKET to test GCSAdapter") } }); diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js index 8bd19447f5..8fb34af840 100644 --- a/src/Adapters/Files/GCSAdapter.js +++ b/src/Adapters/Files/GCSAdapter.js @@ -28,7 +28,7 @@ export class GCSAdapter extends FilesAdapter { constructor( projectId = requiredOrFromEnvironment('GCP_PROJECT_ID', 'projectId'), keyFilename = requiredOrFromEnvironment('GCP_KEYFILE_PATH', 'keyfile path'), - bucket = requiredOrFromEnvironment('GCS_BUCKET_NAME', 'bucket name'), + bucket = requiredOrFromEnvironment('GCS_BUCKET', 'bucket name'), { bucketPrefix = fromEnvironmentOrDefault('GCS_BUCKET_PREFIX', ''), directAccess = fromEnvironmentOrDefault('GCS_DIRECT_ACCESS', false) } = {}) { super();