From 932d2f7323ef723566e564ebbc3bd45349cbba6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 24 Oct 2018 12:52:51 +0100 Subject: [PATCH 01/10] chore: reorder and adds comments to media controller --- dadi/lib/controller/media.js | 178 ++++++++++++++++++++++++----------- 1 file changed, 125 insertions(+), 53 deletions(-) diff --git a/dadi/lib/controller/media.js b/dadi/lib/controller/media.js index a5a8b4e9..da42ab8b 100755 --- a/dadi/lib/controller/media.js +++ b/dadi/lib/controller/media.js @@ -16,6 +16,24 @@ const StorageFactory = require('./../storage/factory') const streamifier = require('streamifier') const url = require('url') +/** + * Block with metadata pertaining to an API collection. + * + * @typedef {Object} Metadata + * @property {Number} page - current page + * @property {Number} offset - offset from start of collection + * @property {Number} totalCount - total number of documents + * @property {Number} totalPages - total number of pages + * @property {Number} nextPage - number of next available page + * @property {Number} prevPage - number of previous available page + */ + +/** + * @typedef {Object} ResultSet + * @property {Metadata} metadata - object with collection metadata + * @property {Array} results - list of documents + */ + const MediaController = function (model, server) { this.model = model this.server = server @@ -24,6 +42,13 @@ const MediaController = function (model, server) { MediaController.prototype = new Controller() +/** + * Formats the current date as a YYYY/MM/DD(/HH/MM/SS) string, + * with the time portion being optional. + * + * @param {Boolean} includeTime Whether to include the time + * @return {String} + */ MediaController.prototype._formatDate = function (includeTime) { let d = new Date() let dateParts = [ @@ -58,7 +83,13 @@ MediaController.prototype._signToken = function (obj) { } /** + * Searchs for documents in the datbase and returns a + * metadata object. * + * @param {Object} req + * @param {Object} res + * @param {Function} next + * @returns {Promise} */ MediaController.prototype.count = function (req, res, next) { let path = url.parse(req.url, true) @@ -81,7 +112,70 @@ MediaController.prototype.count = function (req, res, next) { } /** + * Deletes media files and removes their reference from the database. + * + * @param {Object} req + * @param {Object} res + * @param {Function} next + * @return {Promise} + */ +MediaController.prototype.delete = function (req, res, next) { + let query = req.params.id ? { _id: req.params.id } : req.body.query + + if (!query) return next() + + return acl.access.get(req.dadiApiClient, this.model.aclKey).then(access => { + if (access.delete !== true) { + return help.sendBackJSON(null, res, next)( + acl.createError(req.dadiApiClient) + ) + } + + return this.model.get({ + query, req + }) + }).then(results => { + if (!results.results[0]) { + return help.sendBackJSON(404, res, next)() + } + + let file = results.results[0] + + // remove physical file + let storageHandler = StorageFactory.create(file.fileName) + + return storageHandler.delete(file).then(result => { + return this.model.delete({ + query, + req + }) + }).then(({deletedCount, totalCount}) => { + if (config.get('feedback')) { + // Send 200 with JSON payload. + return help.sendBackJSON(200, res, next)(null, { + success: true, + message: 'Document(s) deleted successfully', + deleted: deletedCount, + totalCount + }) + } + + // Send 204 with no content. + res.statusCode = 204 + res.end() + }) + }).catch(error => { + return help.sendBackJSON(200, res, next)(error) + }) +} + +/** + * Finds documents in the database. * + * @param {Object} req + * @param {Object} res + * @param {Function} next + * @return {Promise} */ MediaController.prototype.get = function (req, res, next) { let path = url.parse(req.url, true) @@ -109,7 +203,12 @@ MediaController.prototype.get = function (req, res, next) { } /** - * Serve a media file from its location. + * Serves a media file from its location. + * + * @param {Object} req + * @param {Object} res + * @param {Function} next + * @return {Promise} */ MediaController.prototype.getFile = function (req, res, next, route) { let storageHandler = StorageFactory.create(req.params.filename) @@ -144,10 +243,14 @@ MediaController.prototype.getPath = function (fileName) { } } -MediaController.prototype.put = function (req, res, next) { - return this.post(req, res, next) -} - +/** + * Processes media uploads and adds their references to the database. + * + * @param {Object} req + * @param {Object} res + * @param {Function} next + * @return {Promise} + */ MediaController.prototype.post = function (req, res, next) { let method = req.method.toLowerCase() let token = req.params.token @@ -340,56 +443,25 @@ MediaController.prototype.post = function (req, res, next) { }) } -MediaController.prototype.delete = function (req, res, next) { - let query = req.params.id ? { _id: req.params.id } : req.body.query - - if (!query) return next() - - return acl.access.get(req.dadiApiClient, this.model.aclKey).then(access => { - if (access.delete !== true) { - return help.sendBackJSON(null, res, next)( - acl.createError(req.dadiApiClient) - ) - } - - return this.model.get({ - query, req - }) - }).then(results => { - if (!results.results[0]) { - return help.sendBackJSON(404, res, next)() - } - - let file = results.results[0] - - // remove physical file - let storageHandler = StorageFactory.create(file.fileName) - - return storageHandler.delete(file).then(result => { - return this.model.delete({ - query, - req - }) - }).then(({deletedCount, totalCount}) => { - if (config.get('feedback')) { - // Send 200 with JSON payload. - return help.sendBackJSON(200, res, next)(null, { - success: true, - message: 'Document(s) deleted successfully', - deleted: deletedCount, - totalCount - }) - } - - // Send 204 with no content. - res.statusCode = 204 - res.end() - }) - }).catch(error => { - return help.sendBackJSON(200, res, next)(error) - }) +/** + * Processes media uploads and adds their references to the database. + * This is an alias for `MediaController.prototype.post`. + * + * @param {Object} req + * @param {Object} res + * @param {Function} next + * @return {Promise} + */ +MediaController.prototype.put = function (req, res, next) { + return this.post(req, res, next) } +/** + * Takes a raw media bucket route (e.g. /media/myBucket) and registers + * all the associated routes, for signing, uploading and retrieving files. + * + * @param {String} route + */ MediaController.prototype.registerRoutes = function (route) { this.route = route From ab69469027ebd085854e55aa7dcd502f936108d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Wed, 24 Oct 2018 15:02:23 +0100 Subject: [PATCH 02/10] refactor: add processFile method to media controller --- dadi/lib/controller/media.js | 217 ++++++++++++++++++++--------------- package.json | 1 - 2 files changed, 122 insertions(+), 96 deletions(-) diff --git a/dadi/lib/controller/media.js b/dadi/lib/controller/media.js index da42ab8b..2bdc1dd2 100755 --- a/dadi/lib/controller/media.js +++ b/dadi/lib/controller/media.js @@ -8,7 +8,6 @@ const help = require('./../help') const imagesize = require('imagesize') const jwt = require('jsonwebtoken') const mediaModel = require('./../model/media') -const mime = require('mime') const PassThrough = require('stream').PassThrough const path = require('path') const sha1 = require('sha1') @@ -271,7 +270,8 @@ MediaController.prototype.post = function (req, res, next) { } let data = [] - let fileName = '' + let fileName + let mimeType return Promise.resolve(aclCheck).then(() => { return new Promise((resolve, reject) => { @@ -280,17 +280,21 @@ MediaController.prototype.post = function (req, res, next) { }) // Listen for event when Busboy finds a file to stream - busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { + busboy.on('file', (fieldname, file, inputFileName, encoding, inputMimeType) => { if (method === 'post' && this.tokenPayloads[token]) { - if (this.tokenPayloads[token].fileName && - this.tokenPayloads[token].fileName !== filename) { + if ( + this.tokenPayloads[token].fileName && + this.tokenPayloads[token].fileName !== inputFileName + ) { return reject( new Error('UNEXPECTED_FILENAME') ) } - if (this.tokenPayloads[token].mimetype && - this.tokenPayloads[token].mimetype !== mimetype) { + if ( + this.tokenPayloads[token].mimetype && + this.tokenPayloads[token].mimetype !== inputMimeType + ) { return reject( new Error('UNEXPECTED_MIMETYPE') ) @@ -299,113 +303,70 @@ MediaController.prototype.post = function (req, res, next) { delete this.tokenPayloads[token] - fileName = filename + fileName = inputFileName + mimeType = inputMimeType file.on('data', chunk => { data.push(chunk) }) }) - // Listen for event when Busboy is finished parsing the form + // Listen for event when Busboy is finished parsing the form. busboy.on('finish', () => { let concatenatedData = Buffer.concat(data) - let stream = streamifier.createReadStream(concatenatedData) - let imageSizeStream = new PassThrough() - let dataStream = new PassThrough() - - // duplicate the stream so we can use it for the imagesize() request and the - // response. this saves requesting the same data a second time. - stream.pipe(imageSizeStream) - stream.pipe(dataStream) - - // get the image size and format - imagesize(imageSizeStream, (err, imageInfo) => { - if (err && err !== 'invalid') { - return reject(err) - } - - let fields = Object.keys(this.model.schema) - let obj = { - fileName: fileName - } - - if (fields.includes('mimetype')) { - obj.mimetype = mime.getType(fileName) - } - - // Is `imageInfo` available? - if (!err) { - if (fields.includes('width')) { - obj.width = imageInfo.width - } - - if (fields.includes('height')) { - obj.height = imageInfo.height - } - } - - // Write the physical file. - this.writeFile( - req, - fileName, - mime.getType(fileName), - dataStream - ).then(result => { - if (fields.includes('contentLength')) { - obj.contentLength = result.contentLength - } - - obj.path = result.path - - // If the method is POST, we are creating a new document. - // If not, it's an update. - if (method === 'post') { - let internals = { + this.processFile({ + data: concatenatedData, + fileName, + mimeType, + req + }).then(response => { + // If the method is POST, we are creating a new document. + // If not, it's an update. + if (method === 'post') { + return this.model.create({ + documents: response, + internals: { _apiVersion: req.url.split('/')[1], _createdAt: Date.now(), _createdBy: req.dadiApiClient && req.dadiApiClient.clientId - } - - return this.model.create({ - documents: obj, - internals, - req - }) - } - - if (!req.params.id) { - return reject( - new Error('UPDATE_ID_MISSING') - ) - } - - let internals = { - _lastModifiedAt: Date.now(), - _lastModifiedBy: req.dadiApiClient && req.dadiApiClient.clientId - } - - return this.model.update({ - query: { - _id: req.params.id }, - update: obj, - internals, - req - }) - }).then(response => { - response.results = response.results.map(document => { - return mediaModel.formatDocuments(document) + req, + validate: false }) + } + + if (!req.params.id) { + return reject( + new Error('UPDATE_ID_MISSING') + ) + } - resolve(response) - }).catch(err => { - return help.sendBackJSON(err.statusCode, res, next)(err) + return this.model.update({ + query: { + _id: req.params.id + }, + internals: { + _lastModifiedAt: Date.now(), + _lastModifiedBy: req.dadiApiClient && req.dadiApiClient.clientId + }, + req, + update: response, + validate: false + }) + }).then(response => { + response.results = response.results.map(document => { + return mediaModel.formatDocuments(document) }) + + resolve(response) + }).catch(err => { + resolve( + help.sendBackJSON(err.statusCode, res, next)(err) + ) }) }) - // Pipe the HTTP Request into Busboy req.pipe(busboy) }) }).then(response => { @@ -443,6 +404,72 @@ MediaController.prototype.post = function (req, res, next) { }) } +/** + * Processes the uploaded file and returns a response object containing any + * metadata properties that are global to all file types as well as any + * additional properties specific to the MIME type in question. + * + * @param {Stream} options.data Uploaded file + * @param {String} options.fileName File name + * @param {String} options.mimeType MIME type + * @param {Object} options.req Request + * @return {Promise} + */ +MediaController.prototype.processFile = function ({ + data, + fileName, + mimeType, + req +}) { + let stream = streamifier.createReadStream(data) + let queue = Promise.resolve({ + contentLength: data.length, + fileName, + mimeType + }) + let outputStream = new PassThrough() + + stream.pipe(outputStream) + + // Setting up any additional streams based on MIME type. + switch (mimeType) { + case 'image/jpeg': + case 'image/png': + let imageSizeStream = new PassThrough() + + stream.pipe(imageSizeStream) + + queue = queue.then(response => new Promise((resolve, reject) => { + imagesize(imageSizeStream, (error, imageInfo) => { + if (error) { + return resolve(response) + } + + resolve( + Object.assign(response, { + width: imageInfo.width, + height: imageInfo.height + }) + ) + }) + })) + } + + return queue.then(response => { + // Write the physical file. + return this.writeFile( + req, + fileName, + mimeType, + outputStream + ).then(result => { + return Object.assign(response, { + path: result.path + }) + }) + }) +} + /** * Processes media uploads and adds their references to the database. * This is an alias for `MediaController.prototype.post`. diff --git a/package.json b/package.json index d13d9900..f83d9cfd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "jsonwebtoken": "^8.0.0", "langs": "^2.0.0", "length-stream": "^0.1.1", - "mime": "^2.3.1", "mkdirp": "^0.5.1", "moment": "2.19.3", "natural": "^0.6.1", From 65321c49e9c60e56569b3e95dc0be78fa3daaa04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 15:25:41 +0100 Subject: [PATCH 03/10] feat: improve error handling of field hooks --- dadi/lib/model/index.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/dadi/lib/model/index.js b/dadi/lib/model/index.js index 99453e62..fe554131 100755 --- a/dadi/lib/model/index.js +++ b/dadi/lib/model/index.js @@ -204,10 +204,16 @@ Model.prototype._compileFieldHooks = function () { * @api private */ Model.prototype._createValidationError = function (message, data) { - const err = new Error(message || 'Model Validation Failed') - err.statusCode = 400 + const error = new Error(message || 'Model Validation Failed') - return err + error.statusCode = 400 + error.success = false + + if (data) { + error.errors = data + } + + return error } /** @@ -637,7 +643,20 @@ Model.prototype.runFieldHooks = function ({ } }) - return queue + return queue.catch(error => { + let errorData = [ + { + field, + message: error.message + } + ] + + logger.error({module: 'field hooks'}, error) + + return Promise.reject( + this._createValidationError('Validation failed', errorData) + ) + }) } /** From b8c99fa07e75b2f63c5ebdea81ced14afc208bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 15:26:00 +0100 Subject: [PATCH 04/10] feat: pass updatedDocuments to field hooks on update --- dadi/lib/model/collections/update.js | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/dadi/lib/model/collections/update.js b/dadi/lib/model/collections/update.js index 93787c59..5a873911 100644 --- a/dadi/lib/model/collections/update.js +++ b/dadi/lib/model/collections/update.js @@ -115,10 +115,7 @@ function update ({ isUpdate: true, schema }).catch(errors => { - let error = this._createValidationError('Validation Failed') - - error.success = false - error.errors = errors + let error = this._createValidationError('Validation Failed', errors) return Promise.reject(error) }) @@ -126,6 +123,15 @@ function update ({ // Format the query. query = this.formatQuery(query) + return this.find({ + query + }) + }).then(({results}) => { + // Create a copy of the documents that matched the find + // query, as these will be updated and we need to send back + // to the client a full result set of modified documents. + updatedDocuments = results + // Add any internal fields to the update. Object.assign(update, internals) @@ -138,7 +144,8 @@ function update ({ return result.then(transformedUpdate => { return this.runFieldHooks({ data: { - internals + internals, + updatedDocuments }, field, input: { @@ -152,15 +159,6 @@ function update ({ }, Promise.resolve({})).then(transformedUpdate => { update = transformedUpdate - return this.find({ - query - }) - }).then(result => { - // Create a copy of the documents that matched the find - // query, as these will be updated and we need to send back - // to the client a full result set of modified documents. - updatedDocuments = result.results - // Run any `beforeUpdate` hooks. if (this.settings.hooks && this.settings.hooks.beforeUpdate) { return new Promise((resolve, reject) => { From d1e7851dd9f0b1d6b6fe64f3784f9962bce205d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 15:26:27 +0100 Subject: [PATCH 05/10] refactor: minor styling changes --- dadi/lib/controller/media.js | 4 ++-- dadi/lib/model/collections/create.js | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/dadi/lib/controller/media.js b/dadi/lib/controller/media.js index 2bdc1dd2..24cad255 100755 --- a/dadi/lib/controller/media.js +++ b/dadi/lib/controller/media.js @@ -353,7 +353,7 @@ MediaController.prototype.post = function (req, res, next) { req, update: response, validate: false - }) + }) }).then(response => { response.results = response.results.map(document => { return mediaModel.formatDocuments(document) @@ -428,7 +428,7 @@ MediaController.prototype.processFile = function ({ mimeType }) let outputStream = new PassThrough() - + stream.pipe(outputStream) // Setting up any additional streams based on MIME type. diff --git a/dadi/lib/model/collections/create.js b/dadi/lib/model/collections/create.js index 50b23176..ba47ab9a 100644 --- a/dadi/lib/model/collections/create.js +++ b/dadi/lib/model/collections/create.js @@ -68,10 +68,7 @@ function create ({ documents, schema }).catch(errors => { - let error = this._createValidationError('Validation Failed') - - error.success = false - error.errors = errors + let error = this._createValidationError('Validation Failed', errors) return Promise.reject(error) }) From 58b8c2cc501a9140352863ea60626c821d4a7c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 15:27:09 +0100 Subject: [PATCH 06/10] feat: add Media type --- dadi/lib/fields/media.js | 84 +++ features.json | 3 +- package.json | 2 +- test/acceptance/fields/media.js | 552 ++++++++++++++++++ .../vtest/testdb/collection.test-schema.json | 5 +- 5 files changed, 643 insertions(+), 3 deletions(-) create mode 100644 dadi/lib/fields/media.js create mode 100644 test/acceptance/fields/media.js diff --git a/dadi/lib/fields/media.js b/dadi/lib/fields/media.js new file mode 100644 index 00000000..32af2b26 --- /dev/null +++ b/dadi/lib/fields/media.js @@ -0,0 +1,84 @@ +module.exports.type = 'media' + +module.exports.beforeOutput = function ({ + client, + config, + field, + input, + schema +}) { + let bucket = (schema.settings && schema.settings.mediaBucket) || config.get('media.defaultBucket') + let model = this.getForeignModel(bucket) + + if (!model) { + return input + } + + let isArraySyntax = Array.isArray(input[field]) + let normalisedValue = isArraySyntax ? input[field] : [input[field]] + let mediaObjectIDs = normalisedValue.map(value => { + if (typeof value !== 'string') { + return value._id + } + + return value + }).filter(Boolean) + + if (mediaObjectIDs.length === 0) { + return input + } + + return model.get({ + client, + query: { + _id: { + $in: mediaObjectIDs + } + } + }).then(({results}) => { + return results.reduce((mediaObjects, result) => { + mediaObjects[result._id] = result + + return mediaObjects + }, {}) + }).then(mediaObjects => { + return normalisedValue.map(value => { + if (mediaObjects[value._id]) { + let mergedValue = Object.assign({}, mediaObjects[value._id], value) + let sortedValue = Object.keys(mergedValue).sort().reduce((sortedValue, field) => { + sortedValue[field] = mergedValue[field] + + return sortedValue + }, {}) + + return sortedValue + } + + return value + }) + }).then(composedValue => { + return Object.assign(input, { + [field]: isArraySyntax ? composedValue : composedValue[0] + }) + }) +} + +module.exports.beforeSave = function ({ + field, + input +}) { + let isArraySyntax = Array.isArray(input[field]) + let normalisedValue = (isArraySyntax ? input[field] : [input[field]]).map(value => { + if (typeof value === 'string') { + return { + _id: value + } + } + + return value + }) + + return { + [field]: isArraySyntax ? normalisedValue : normalisedValue[0] + } +} diff --git a/features.json b/features.json index e7f80e52..c60ddbff 100644 --- a/features.json +++ b/features.json @@ -2,5 +2,6 @@ "aclv1", "i18nv1", "i18nv2", - "collectionsv1" + "collectionsv1", + "validationv1" ] \ No newline at end of file diff --git a/package.json b/package.json index f83d9cfd..acdae543 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ ] }, "dependencies": { - "@dadi/api-validator": "^1.0.0", + "@dadi/api-validator": "^1.1.0", "@dadi/boot": "^1.1.3", "@dadi/cache": "^3.0.0", "@dadi/et": "^2.0.0", diff --git a/test/acceptance/fields/media.js b/test/acceptance/fields/media.js new file mode 100644 index 00000000..938f1af3 --- /dev/null +++ b/test/acceptance/fields/media.js @@ -0,0 +1,552 @@ +const should = require('should') +const sinon = require('sinon') +const fs = require('fs') +const path = require('path') +const request = require('supertest') +const config = require(__dirname + '/../../../config') +const help = require(__dirname + '/../help') +const app = require(__dirname + '/../../../dadi/lib/') +const Model = require('./../../../dadi/lib/model') + +let bearerToken +let configBackup = config.get() +let client = request(`http://${config.get('server.host')}:${config.get('server.port')}`) + +describe.only('Media field', () => { + beforeEach(done => { + help.dropDatabase('testdb', err => { + app.start(() => { + help.getBearerToken((err, token) => { + bearerToken = token + + done(err) + }) + }) + }) + }) + + afterEach(done => { + app.stop(done) + }) + + describe('Single value', () => { + describe('POST', () => { + it('should reject a string value that is not an hexadecimal ID', done => { + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send({ + leadImage: 'QWERTYUIOP' + }) + .expect(400) + .end((err, res) => { + res.body.success.should.eql(false) + res.body.errors[0].field.should.eql('leadImage') + res.body.errors[0].code.should.eql('ERROR_VALUE_INVALID') + + done(err) + }) + }) + + it('should accept a media object ID as a string', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: mediaObject._id + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + client + .get(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + done(err) + }) + }) + }) + }) + + it('should reject an object value that does not contain an hexadecimal ID', done => { + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send({ + leadImage: { + someProperty: 'Some value' + } + }) + .expect(400) + .end((err, res) => { + res.body.success.should.eql(false) + res.body.errors[0].field.should.eql('leadImage') + res.body.errors[0].code.should.eql('ERROR_VALUE_INVALID') + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send({ + leadImage: { + _id: 'QWERTYUIOP', + someProperty: 'Some value' + } + }) + .expect(400) + .end((err, res) => { + res.body.success.should.eql(false) + res.body.errors[0].field.should.eql('leadImage') + res.body.errors[0].code.should.eql('ERROR_VALUE_INVALID') + + done(err) + }) + }) + }) + + it('should accept a media object as an object', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: { + _id: mediaObject._id + } + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + client + .get(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + done(err) + }) + }) + }) + }) + + it('should accept a media object as an object with additional metadata', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: { + _id: mediaObject._id, + altText: 'A diagram outlining media support in DADI API', + crop: [16, 32, 64, 128] + } + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.altText.should.eql(payload.leadImage.altText) + results[0].leadImage.crop.should.eql(payload.leadImage.crop) + results[0].leadImage.fileName.should.eql('1f525.png') + + client + .post(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.altText.should.eql(payload.leadImage.altText) + results[0].leadImage.crop.should.eql(payload.leadImage.crop) + results[0].leadImage.fileName.should.eql('1f525.png') + + done(err) + }) + }) + }) + }) + }) + + describe('PUT', () => { + it('should reject a string that isn\'t a hexadecimal ID', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: mediaObject._id + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + let updatePayload = { + leadImage: 'QWERTYUIOP' + } + + client + .put(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(updatePayload) + .expect(400) + .end((err, res) => { + res.body.success.should.eql(false) + res.body.errors[0].field.should.eql('leadImage') + res.body.errors[0].code.should.eql('ERROR_VALUE_INVALID') + + done(err) + }) + }) + }) + }) + + it('should accept a media object ID as a string', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject1 = res.body.results[0] + + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/flowers.jpg') + .end((err, res) => { + let mediaObject2 = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: mediaObject1._id + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject1._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + let updatePayload = { + leadImage: mediaObject2._id + } + + client + .put(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(updatePayload) + .end((err, res) => { + client + .get(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject2._id) + results[0].leadImage.fileName.should.eql('flowers.jpg') + + done(err) + }) + }) + }) + }) + }) + }) + + it('should reject an object that doesn\'t contain a hexadecimal ID', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: mediaObject._id + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + let updatePayload = { + leadImage: { + _id: 'QWERTYUIOP' + } + } + + client + .put(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(updatePayload) + .expect(400) + .end((err, res) => { + res.body.success.should.eql(false) + res.body.errors[0].field.should.eql('leadImage') + res.body.errors[0].code.should.eql('ERROR_VALUE_INVALID') + + done(err) + }) + }) + }) + }) + + it('should accept a media object ID as an object', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject1 = res.body.results[0] + + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/flowers.jpg') + .end((err, res) => { + let mediaObject2 = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: mediaObject1._id + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject1._id) + results[0].leadImage.fileName.should.eql('1f525.png') + + let updatePayload = { + leadImage: { + _id: mediaObject2._id + } + } + + client + .put(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(updatePayload) + .end((err, res) => { + client + .get(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject2._id) + results[0].leadImage.fileName.should.eql('flowers.jpg') + + done(err) + }) + }) + }) + }) + }) + }) + + it('should accept a media object ID as an object with additional metadata', done => { + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/1f525.png') + .end((err, res) => { + let mediaObject1 = res.body.results[0] + + client + .post('/media/upload') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .attach('avatar', 'test/acceptance/temp-workspace/media/flowers.jpg') + .end((err, res) => { + let mediaObject2 = res.body.results[0] + let payload = { + title: 'Media support in DADI API', + leadImage: { + _id: mediaObject1._id, + altText: 'Original alt text', + caption: 'Original caption' + } + } + + client + .post('/vtest/testdb/test-schema') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(payload) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject1._id) + results[0].leadImage.altText.should.eql(payload.leadImage.altText) + results[0].leadImage.caption.should.eql(payload.leadImage.caption) + results[0].leadImage.fileName.should.eql('1f525.png') + + let updatePayload = { + leadImage: { + _id: mediaObject2._id, + altText: 'New alt text', + crop: [16, 32, 64, 128] + } + } + + client + .put(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .send(updatePayload) + .end((err, res) => { + client + .get(`/vtest/testdb/test-schema/${results[0]._id}`) + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerToken}`) + .end((err, res) => { + let {results} = res.body + + results.should.be.instanceOf(Array) + results.length.should.eql(1) + results[0].title.should.eql(payload.title) + results[0].leadImage._id.should.eql(mediaObject2._id) + results[0].leadImage.altText.should.eql(updatePayload.leadImage.altText) + results[0].leadImage.crop.should.eql(updatePayload.leadImage.crop) + should.not.exist(results[0].leadImage.caption) + results[0].leadImage.fileName.should.eql('flowers.jpg') + + done(err) + }) + }) + }) + }) + }) + }) + }) + }) +}) diff --git a/test/acceptance/workspace/collections/vtest/testdb/collection.test-schema.json b/test/acceptance/workspace/collections/vtest/testdb/collection.test-schema.json index e3dcf1e5..d7240bda 100755 --- a/test/acceptance/workspace/collections/vtest/testdb/collection.test-schema.json +++ b/test/acceptance/workspace/collections/vtest/testdb/collection.test-schema.json @@ -16,7 +16,10 @@ "search": { "weight": 2 } - } + }, + "leadImage": { + "type": "Media" + } }, "settings": { "cache": true, From 477f28524711066d3a94ead18be5249e693eb0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 15:29:02 +0100 Subject: [PATCH 07/10] test: re-enable tests --- test/acceptance/fields/datetime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/acceptance/fields/datetime.js b/test/acceptance/fields/datetime.js index ab3b2951..74ab3661 100644 --- a/test/acceptance/fields/datetime.js +++ b/test/acceptance/fields/datetime.js @@ -13,7 +13,7 @@ let bearerToken let configBackup = config.get() let connectionString = 'http://' + config.get('server.host') + ':' + config.get('server.port') -describe.only('DateTime Field', function () { +describe('DateTime Field', function () { before(() => { config.set('paths.collections', 'test/acceptance/temp-workspace/collections') }) From cbe5421150fb20e9952094910d4a8c2da5bdb212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 16:15:25 +0100 Subject: [PATCH 08/10] refactor: add mimetype property for backward compatibility --- dadi/lib/controller/media.js | 6 +++++- test/acceptance/media.js | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dadi/lib/controller/media.js b/dadi/lib/controller/media.js index 24cad255..d5dbb805 100755 --- a/dadi/lib/controller/media.js +++ b/dadi/lib/controller/media.js @@ -425,7 +425,11 @@ MediaController.prototype.processFile = function ({ let queue = Promise.resolve({ contentLength: data.length, fileName, - mimeType + mimeType, + + // (!) For backward compatibility. To be removed in + // version 5.0.0. ¯\_(ツ)_/¯ + mimetype: mimeType }) let outputStream = new PassThrough() diff --git a/test/acceptance/media.js b/test/acceptance/media.js index 78b1c036..1b9aef9e 100644 --- a/test/acceptance/media.js +++ b/test/acceptance/media.js @@ -302,7 +302,7 @@ describe('Media', function () { res.body.results.length.should.eql(1) res.body.results[0].fileName.should.eql(obj.fileName) - res.body.results[0].mimetype.should.eql(obj.mimetype) + res.body.results[0].mimeType.should.eql(obj.mimetype) res.body.results[0].width.should.eql(512) res.body.results[0].height.should.eql(512) @@ -315,7 +315,7 @@ describe('Media', function () { .attach('avatar', 'test/acceptance/temp-workspace/media/flowers.jpg') .end((err, res) => { res.body.results[0].fileName.should.eql('flowers.jpg') - res.body.results[0].mimetype.should.eql('image/jpeg') + res.body.results[0].mimeType.should.eql('image/jpeg') res.body.results[0].width.should.eql(1600) res.body.results[0].height.should.eql(1086) @@ -325,7 +325,7 @@ describe('Media', function () { .set('Authorization', `Bearer ${bearerToken}`) .end((err, res) => { res.body.results[0].fileName.should.eql('flowers.jpg') - res.body.results[0].mimetype.should.eql('image/jpeg') + res.body.results[0].mimeType.should.eql('image/jpeg') res.body.results[0].width.should.eql(1600) res.body.results[0].height.should.eql(1086) From 4f4d9f4696a7913161ee8b68f8d240df1e39f346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 16:15:36 +0100 Subject: [PATCH 09/10] test: re-enable tests --- test/acceptance/fields/media.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/acceptance/fields/media.js b/test/acceptance/fields/media.js index 938f1af3..9bfb49fa 100644 --- a/test/acceptance/fields/media.js +++ b/test/acceptance/fields/media.js @@ -12,7 +12,7 @@ let bearerToken let configBackup = config.get() let client = request(`http://${config.get('server.host')}:${config.get('server.port')}`) -describe.only('Media field', () => { +describe('Media field', () => { beforeEach(done => { help.dropDatabase('testdb', err => { app.start(() => { From ea4970e9bdbf06b6742d3ae686d6c075e2be10ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 26 Oct 2018 16:37:05 +0100 Subject: [PATCH 10/10] chore: remove unused package --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index acdae543..74d97b84 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "streamifier": "^0.1.1", "underscore": "1.8.3", "underscore-contrib": "^0.3.0", - "validator": "9.4.1", "vary": "^1.1.2" }, "devDependencies": {