From 62dd05f9146c368535504fb350b12d873751c586 Mon Sep 17 00:00:00 2001 From: Nicolas Perriault Date: Thu, 14 Jul 2016 11:32:12 +0200 Subject: [PATCH] Fix #104: Add support for the attachment API. --- package.json | 12 +++++---- src/base.js | 13 +++++---- src/collection.js | 50 +++++++++++++++++++++++++++++++---- src/endpoint.js | 20 +++++++++----- src/utils.js | 57 ++++++++++++++++++++++++++++++++++++++++ test/integration_test.js | 22 ++++++++++++++-- test/kinto.ini | 9 ++++++- test/setup-jsdom.js | 13 +++++++++ 8 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 test/setup-jsdom.js diff --git a/package.json b/package.json index b310df27..84f24516 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,11 @@ "prepublish": "node_modules/.bin/toctoc -w -d 2 README.md", "publish-to-npm": "npm run build && npm run dist && npm publish", "report-coverage": "npm run test-cover && ./node_modules/coveralls/bin/coveralls.js < ./coverage/lcov.info", - "tdd": "babel-node node_modules/.bin/_mocha --watch 'test/**/*_test.js'", + "tdd": "babel-node node_modules/.bin/_mocha --require ./test/setup-jsdom.js --watch 'test/**/*_test.js'", "test": "npm run test-nocover", "test-cover": "babel-node node_modules/.bin/babel-istanbul cover --report text $npm_package_config_ISTANBUL_OPTS node_modules/.bin/_mocha -- 'test/**/*_test.js'", "test-cover-html": "babel-node node_modules/.bin/babel-istanbul cover --report html $npm_package_config_ISTANBUL_OPTS node_modules/.bin/_mocha -- 'test/**/*_test.js' && open coverage/index.html", - "test-nocover": "babel-node node_modules/.bin/_mocha 'test/**/*_test.js'", + "test-nocover": "babel-node node_modules/.bin/_mocha --require ./test/setup-jsdom.js 'test/**/*_test.js'", "lint": "eslint src test" }, "repository": { @@ -37,9 +37,11 @@ }, "homepage": "https://github.com/Kinto/kinto-http.js#readme", "dependencies": { - "isomorphic-fetch": "^2.2.1" + "isomorphic-fetch": "^2.2.1", + "uuid": "^2.0.1" }, "devDependencies": { + "atob": "^2.0.3", "babel-cli": "^6.6.5", "babel-core": "^6.6.5", "babel-eslint": "^5.0.0-beta6", @@ -60,12 +62,12 @@ "esdoc-es7-plugin": "0.0.3", "esdoc-importpath-plugin": "0.0.1", "eslint": "2.2.0", + "jsdom": "^9.4.1", "kinto-node-test-server": "0.0.1", "mocha": "^2.3.4", "sinon": "^1.17.2", "toctoc": "^0.2.2", - "uglifyify": "^3.0.1", - "uuid": "^2.0.1" + "uglifyify": "^3.0.1" }, "engines": { "node": ">=6" diff --git a/src/base.js b/src/base.js index d187aa55..ee21d23a 100644 --- a/src/base.js +++ b/src/base.js @@ -343,10 +343,13 @@ export default class KintoClientBase { * @private * @param {Object} request The request object. * @param {Object} [options={}] The options object. - * @param {Boolean} [options.raw=false] If true, resolve with full response object, including json body and headers instead of just json. + * @param {Boolean} [options.raw=false] If true, resolve with full response + * @param {Boolean} [options.stringify=true] If true, serialize body data to + * JSON. * @return {Promise} */ - execute(request, options={raw: false}) { + execute(request, options={raw: false, stringify: true}) { + const {raw, stringify} = options; // If we're within a batch, add the request to the stack to send at once. if (this._isBatch) { this._requests.push(request); @@ -354,16 +357,16 @@ export default class KintoClientBase { // from within a batch operation. const msg = "This result is generated from within a batch " + "operation and should not be consumed."; - return Promise.resolve(options.raw ? {json: msg} : msg); + return Promise.resolve(raw ? {json: msg} : msg); } const promise = this.fetchServerSettings() .then(_ => { return this.http.request(this.remote + request.path, { ...request, - body: JSON.stringify(request.body) + body: stringify ? JSON.stringify(request.body) : request.body, }); }); - return options.raw ? promise : promise.then(({json}) => json); + return raw ? promise : promise.then(({json}) => json); } /** diff --git a/src/collection.js b/src/collection.js index a3608af9..a0ee33c3 100644 --- a/src/collection.js +++ b/src/collection.js @@ -1,4 +1,6 @@ -import { toDataBody, qsify, isObject } from "./utils"; +import { v4 as uuid } from "uuid"; + +import { toDataBody, qsify, isObject, createFormData } from "./utils"; import * as requests from "./requests"; import endpoint from "./endpoint"; @@ -150,10 +152,11 @@ export default class Collection { /** * Creates a record in current collection. * - * @param {Object} record The record to create. - * @param {Object} [options={}] The options object. - * @param {Object} [options.headers] The headers object option. - * @param {Boolean} [options.safe] The safe option. + * @param {Object} record The record to create. + * @param {Object} [options={}] The options object. + * @param {Object} [options.headers] The headers object option. + * @param {Boolean} [options.safe] The safe option. + * @param {Object} [options.permissions] The permissions option. * @return {Promise} */ createRecord(record, options={}) { @@ -164,6 +167,42 @@ export default class Collection { return this.client.execute(request); } + /** + * XXX to document; + * - n'accepter que les data uri car elles contiennent filename + * - conseiller d'utiliser new File() et la méthode getAsDataURL pour retrouver + * une data uri + * + * @param {String} dataURL The data url. + * @param {Object} [record={}] The record data. + * @param {Object} [options={}] The options object. + * @param {Object} [options.headers] The headers object option. + * @param {Boolean} [options.safe] The safe option. + * @param {Number} [options.last_modified] The last_modified option. + * @param {Object} [options.permissions] The permissions option. + * @return {Promise} + */ + addAttachment(dataURI, record={}, options={}) { + const reqOptions = this._collOptions(options); + const {permissions} = reqOptions; + const id = record.id || uuid.v4(); + const path = endpoint("attachment", this.bucket.name, this.name, id); + const body = {data: record, permissions}; + const updateRequest = requests.updateRequest(path, body, reqOptions); + const formData = createFormData(dataURI, body); + const addAttachmentRequest = { + ...updateRequest, + method: "POST", + body: formData + }; + return this.client.execute(addAttachmentRequest, {stringify: false}); + } + + // TODO + removeAttachment(recordId, options={}) { + + } + /** * Updates a record in current collection. * @@ -172,6 +211,7 @@ export default class Collection { * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. + * @param {Object} [options.permissions] The permissions option. * @return {Promise} */ updateRecord(record, options={}) { diff --git a/src/endpoint.js b/src/endpoint.js index cd082b75..f6299381 100644 --- a/src/endpoint.js +++ b/src/endpoint.js @@ -3,12 +3,20 @@ * @type {Object} */ const ENDPOINTS = { - root: () => "/", - batch: () => "/batch", - bucket: (bucket) => "/buckets" + (bucket ? `/${bucket}` : ""), - collection: (bucket, coll) => `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""), - group: (bucket, group) => `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""), - record: (bucket, coll, id) => `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""), + root: () => + "/", + batch: () => + "/batch", + bucket: (bucket) => + "/buckets" + (bucket ? `/${bucket}` : ""), + collection: (bucket, coll) => + `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""), + group: (bucket, group) => + `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""), + record: (bucket, coll, id) => + `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""), + attachment: (bucket, coll, id) => + `${ENDPOINTS.record(bucket, coll, id)}/attachment`, }; /** diff --git a/src/utils.js b/src/utils.js index bf7d799b..47306e7b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,6 @@ +import atob from "atob"; + + /** * Chunks an array into n pieces. * @@ -229,3 +232,57 @@ export function nobatch(message) { export function isObject(thing) { return typeof thing === "object" && thing !== null && !Array.isArray(thing); } + +/** + * Parses a data url. + * @param {String} dataURL The data url. + * @return {Object} + */ +export function parseDataURL(dataURL) { + const regex = /^data:(.*);base64,(.*)/; + const match = dataURL.match(regex); + if (!match) { + throw new Error(`Invalid data-url: ${String(dataURL).substr(0, 32)}...`); + } + const props = match[1]; + const base64 = match[2]; + const [type, ...rawParams] = props.split(";"); + const params = rawParams.reduce((acc, param) => { + const [key, value] = param.split("="); + return {...acc, [key]: value}; + }, {}); + return {...params, type, base64}; +} + +/** + * Extracts file information from a data url. + * @param {String} dataURL The data url. + * @return {Object} + */ +export function extractFileInfo(dataURL) { + const {name, type, base64} = parseDataURL(dataURL); + const binary = atob(base64); + const array = []; + for(let i = 0; i < binary.length; i++) { + array.push(binary.charCodeAt(i)); + } + const blob = new Blob([new Uint8Array(array)], {type}); + return {name, blob}; +} + +/** + * Creates a FormData instance from a data url and an existing JSON response + * body. + * @param {String} dataURL The data url. + * @param {Object} body The response body. + * @return {FormData} + */ +export function createFormData(dataURL, body) { + const {blob, name} = extractFileInfo(dataURL); + const formData = new FormData(); + formData.append("attachment", blob, name); + for (const property in body) { + formData.append(property, JSON.stringify(body[property])); + } + return formData; +} diff --git a/test/integration_test.js b/test/integration_test.js index df83e95e..b3ec7fe9 100644 --- a/test/integration_test.js +++ b/test/integration_test.js @@ -1,6 +1,5 @@ "use strict"; -import btoa from "btoa"; import chai, { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; import sinon from "sinon"; @@ -24,7 +23,10 @@ describe("Integration tests", function() { this.timeout(0); before(() => { - server = new KintoServer(TEST_KINTO_SERVER, {maxAttempts: 200}); + server = new KintoServer(TEST_KINTO_SERVER, { + maxAttempts: 200, + kintoConfigPath: __dirname + "/kinto.ini", + }); }); after(() => server.killAll()); @@ -979,6 +981,22 @@ describe("Integration tests", function() { }); }); + describe.only(".addAttachment()", () => { + const dataURL = "data:text/plain;name=test.txt;base64," + btoa("test"); + + it("should create a record with an attachment", () => { + return coll + .addAttachment(dataURL, {foo: "bar"}) + .catch(err => { + console.log(server.logs.toString()); + throw err; + }) + .should.eventually.have.property("data") + .to.have.property("attachment") + .to.not.be.null; + }); + }); + describe(".getRecord()", () => { it("should retrieve a record by its id", () => { return coll.createRecord({title: "blah"}) diff --git a/test/kinto.ini b/test/kinto.ini index b919ff88..7ca489dc 100644 --- a/test/kinto.ini +++ b/test/kinto.ini @@ -10,8 +10,15 @@ kinto.userid_hmac_secret = a-secret-string # Allow browsing all buckets kinto.bucket_read_principals = system.Authenticated -# Add default bucket feature +# Plugins registration kinto.includes = kinto.plugins.default_bucket + kinto_attachment + +# Kinto-attachment +kinto.attachment.base_url = http://0.0.0.0:8888/attachments +kinto.attachment.folder = {bucket_id}/{collection_id} +kinto.attachment.keep_old_files = true +kinto.attachment.base_path = /tmp [server:main] use = egg:waitress#main diff --git a/test/setup-jsdom.js b/test/setup-jsdom.js new file mode 100644 index 00000000..8f6da0f2 --- /dev/null +++ b/test/setup-jsdom.js @@ -0,0 +1,13 @@ +const jsdom = require("jsdom"); + +// Setup the jsdom environment +// @see https://github.com/facebook/react/issues/5046 +global.document = jsdom.jsdom(""); +global.window = document.defaultView; +global.navigator = global.window.navigator; +global.Blob = global.window.Blob; +global.FormData = global.window.FormData; + +// btoa polyfill for tests +global.btoa = require("btoa"); +global.atob = require("atob");