Skip to content
This repository has been archived by the owner on Apr 4, 2022. It is now read-only.

Fix #104: Add support for the attachment API. #115

Merged
merged 20 commits into from
Jul 18, 2016
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ before_install:
- virtualenv .env
- if [[ $SERVER && $SERVER != development ]]; then .env/bin/pip install kinto==$SERVER; fi
- if [[ $SERVER && $SERVER == development ]]; then .env/bin/pip install https://github.com/Kinto/kinto/zipball/master; fi
- .env/bin/pip install kinto-attachment
- export KINTO_PSERVE_EXECUTABLE=.env/bin/pserve
script:
- npm $ACTION
Expand Down
13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand All @@ -60,12 +62,13 @@
"esdoc-es7-plugin": "0.0.3",
"esdoc-importpath-plugin": "0.0.1",
"eslint": "2.2.0",
"form-data": "^1.0.0-rc4",
"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"
Expand Down
13 changes: 8 additions & 5 deletions src/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,27 +343,30 @@ 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<Object, Error>}
*/
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);
// Resolve with a message in case people attempt at consuming the result
// 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);
}

/**
Expand Down
50 changes: 45 additions & 5 deletions src/collection.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<Object, Error>}
*/
createRecord(record, options={}) {
Expand All @@ -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<Object, Error>}
*/
addAttachment(dataURI, record={}, options={}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: add @capable decorator

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.
*
Expand All @@ -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<Object, Error>}
*/
updateRecord(record, options={}) {
Expand Down
20 changes: 14 additions & 6 deletions src/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
};

/**
Expand Down
1 change: 0 additions & 1 deletion src/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ export default class HTTP {
request(url, options={headers:{}}) {
let response, status, statusText, headers, hasTimedout;
// Ensure default request headers are always set
options.headers = Object.assign({}, HTTP.DEFAULT_REQUEST_HEADERS, options.headers);
options.mode = this.requestMode;
return new Promise((resolve, reject) => {
const _timeoutId = setTimeout(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/requests.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { omit } from "./utils";
import HTTP from "./http";

const requestDefaults = {
safe: false,
// check if we should set default content type here
headers: {},
headers: HTTP.DEFAULT_REQUEST_HEADERS,
permissions: undefined,
data: undefined,
patch: false,
Expand Down
56 changes: 56 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import atob from "atob";

/**
* Chunks an array into n pieces.
*
Expand Down Expand Up @@ -229,3 +231,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 {blob, name};
}

/**
* 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;
}
21 changes: 19 additions & 2 deletions test/integration_test.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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());
Expand Down Expand Up @@ -979,6 +981,21 @@ describe("Integration tests", function() {
});
});

describe.only(".addAttachment()", () => {
const input = "test";
const dataURL = "data:text/plain;name=test.txt;base64," + btoa(input);

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("size").eql(input.length);
});
});

describe(".getRecord()", () => {
it("should retrieve a record by its id", () => {
return coll.createRecord({title: "blah"})
Expand Down
9 changes: 8 additions & 1 deletion test/kinto.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions test/setup-jsdom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const jsdom = require("jsdom");

// Setup the jsdom environment
// @see https://github.com/facebook/react/issues/5046
global.document = jsdom.jsdom("<!doctype html><html><body></body></html>");
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");


global.FormData = require("form-data");
global.Blob = function(sequences) { return Buffer.from(sequences[0]); };