From 857c316bdad86917d79abdea7f18b5fd2510b48d Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Tue, 1 Dec 2015 21:01:12 +0100 Subject: [PATCH] Backbone transport for Webdav --- core/js/core.json | 1 + core/js/oc-backbone-webdav.js | 316 ++++++++++++++++ core/js/oc-backbone.js | 2 + core/js/tests/specs/oc-backbone-webdavSpec.js | 352 ++++++++++++++++++ core/vendor/davclient.js/lib/client.js | 65 ++++ 5 files changed, 736 insertions(+) create mode 100644 core/js/oc-backbone-webdav.js create mode 100644 core/js/tests/specs/oc-backbone-webdavSpec.js diff --git a/core/js/core.json b/core/js/core.json index c7621a08d62..43cb1b472f5 100644 --- a/core/js/core.json +++ b/core/js/core.json @@ -23,6 +23,7 @@ "oc-dialogs.js", "js.js", "oc-backbone.js", + "oc-backbone-webdav.js", "l10n.js", "apps.js", "share.js", diff --git a/core/js/oc-backbone-webdav.js b/core/js/oc-backbone-webdav.js new file mode 100644 index 00000000000..3ca31902c93 --- /dev/null +++ b/core/js/oc-backbone-webdav.js @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +/** + * Webdav transport for Backbone. + * + * This makes it possible to use Webdav endpoints when + * working with Backbone models and collections. + * + * Requires the davclient.js library. + * + * Usage example: + * + * var PersonModel = OC.Backbone.Model.extend({ + * // make it use the DAV transport + * sync: OC.Backbone.davSync, + * + * // DAV properties mapping + * davProperties: { + * 'id': '{http://example.com/ns}id', + * 'firstName': '{http://example.com/ns}first-name', + * 'lastName': '{http://example.com/ns}last-name', + * 'age': '{http://example.com/ns}age' + * }, + * + * // additional parsing, if needed + * parse: function(props) { + * // additional parsing (DAV property values are always strings) + * props.age = parseInt(props.age, 10); + * return props; + * } + * }); + * + * var PersonCollection = OC.Backbone.Collection.extend({ + * // make it use the DAV transport + * sync: OC.Backbone.davSync, + * + * // use person model + * // note that davProperties will be inherited + * model: PersonModel, + * + * // DAV collection URL + * url: function() { + * return OC.linkToRemote('dav') + '/person/'; + * }, + * }); + */ + +/* global dav */ + +(function(Backbone) { + var methodMap = { + 'create': 'POST', + 'update': 'PROPPATCH', + 'patch': 'PROPPATCH', + 'delete': 'DELETE', + 'read': 'PROPFIND' + }; + + // Throw an error when a URL is needed, and none is supplied. + function urlError() { + throw new Error('A "url" property or function must be specified'); + } + + /** + * Convert a single propfind result to JSON + * + * @param {Object} result + * @param {Object} davProperties properties mapping + */ + function parsePropFindResult(result, davProperties) { + var props = { + href: result.href + }; + + _.each(result.propStat, function(propStat) { + if (propStat.status !== 'HTTP/1.1 200 OK') { + return; + } + + for (var key in propStat.properties) { + var propKey = key; + if (davProperties[key]) { + propKey = davProperties[key]; + } + props[propKey] = propStat.properties[key]; + } + }); + + if (!props.id) { + // parse id from href + props.id = parseIdFromLocation(props.href); + } + + return props; + } + + /** + * Parse ID from location + * + * @param {string} url url + * @return {string} id + */ + function parseIdFromLocation(url) { + var queryPos = url.indexOf('?'); + if (queryPos > 0) { + url = url.substr(0, queryPos); + } + + var parts = url.split('/'); + return parts[parts.length - 1]; + } + + function isSuccessStatus(status) { + return status >= 200 && status <= 299; + } + + function convertModelAttributesToDavProperties(attrs, davProperties) { + var props = {}; + var key; + for (key in attrs) { + var changedProp = davProperties[key]; + if (!changedProp) { + console.warn('No matching DAV property for property "' + key); + continue; + } + props[changedProp] = attrs[key]; + } + return props; + } + + function callPropFind(client, options, model, headers) { + return client.propFind( + options.url, + _.values(options.davProperties) || [], + options.depth, + headers + ).then(function(response) { + if (isSuccessStatus(response.status)) { + if (_.isFunction(options.success)) { + var propsMapping = _.invert(options.davProperties); + var results; + if (options.depth > 0) { + results = _.map(response.body, function(data) { + return parsePropFindResult(data, propsMapping); + }); + // discard root entry + results.shift(); + } else { + results = parsePropFindResult(response.body, propsMapping); + } + + options.success(results); + return; + } + } else if (_.isFunction(options.error)) { + options.error(response); + } + }); + } + + function callPropPatch(client, options, model, headers) { + client.propPatch( + options.url, + convertModelAttributesToDavProperties(model.changed, options.davProperties), + headers + ).then(function(result) { + if (isSuccessStatus(result.status)) { + if (_.isFunction(options.success)) { + // pass the object's own values because the server + // does not return the updated model + options.success(model.toJSON()); + } + } else if (_.isFunction(options.error)) { + options.error(result); + } + }); + + } + + function callMethod(client, options, model, headers) { + headers['Content-Type'] = 'application/json'; + return client.request( + options.type, + options.url, + headers, + options.data + ).then(function(result) { + if (!isSuccessStatus(result.status)) { + if (_.isFunction(options.error)) { + options.error(result); + } + return; + } + + if (_.isFunction(options.success)) { + if (options.type === 'PUT' || options.type === 'POST') { + // pass the object's own values because the server + // does not return anything + var responseJson = result.body || model.toJSON(); + var locationHeader = result.xhr.getResponseHeader('Content-Location'); + if (options.type === 'POST' && locationHeader) { + responseJson.id = parseIdFromLocation(locationHeader); + } + options.success(responseJson); + return; + } + options.success(result.body); + } + }); + } + + function davCall(options, model) { + var client = new dav.Client({ + baseUrl: options.url, + xmlNamespaces: _.extend({ + 'DAV:': 'd', + 'http://owncloud.org/ns': 'oc' + }, options.xmlNamespaces || {}) + }); + client.resolveUrl = function() { + return options.url; + }; + var headers = _.extend({ + 'X-Requested-With': 'XMLHttpRequest' + }, options.headers); + if (options.type === 'PROPFIND') { + return callPropFind(client, options, model, headers); + } else if (options.type === 'PROPPATCH') { + return callPropPatch(client, options, model, headers); + } else { + return callMethod(client, options, model, headers); + } + } + + /** + * DAV transport + */ + function davSync(method, model, options) { + var params = {type: methodMap[method]}; + var isCollection = (model instanceof Backbone.Collection); + + if (method === 'update' && (model.usePUT || (model.collection && model.collection.usePUT))) { + // use PUT instead of PROPPATCH + params.type = 'PUT'; + } + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError(); + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.data = JSON.stringify(options.attrs || model.toJSON(options)); + } + + // Don't process data on a non-GET request. + if (params.type !== 'PROPFIND') { + params.processData = false; + } + + if (params.type === 'PROPFIND' || params.type === 'PROPPATCH') { + var davProperties = model.davProperties; + if (!davProperties && model.model) { + // use dav properties from model in case of collection + davProperties = model.model.prototype.davProperties; + } + if (davProperties) { + if (_.isFunction(davProperties)) { + params.davProperties = davProperties.call(model); + } else { + params.davProperties = davProperties; + } + } + + params.davProperties = _.extend(params.davProperties || {}, options.davProperties); + + if (_.isUndefined(options.depth)) { + if (isCollection) { + options.depth = 1; + } else { + options.depth = 0; + } + } + } + + // Pass along `textStatus` and `errorThrown` from jQuery. + var error = options.error; + options.error = function(xhr, textStatus, errorThrown) { + options.textStatus = textStatus; + options.errorThrown = errorThrown; + if (error) { + error.call(options.context, xhr, textStatus, errorThrown); + } + }; + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.davCall(_.extend(params, options), model); + model.trigger('request', model, xhr, options); + return xhr; + } + + // exports + Backbone.davCall = davCall; + Backbone.davSync = davSync; + +})(OC.Backbone); + diff --git a/core/js/oc-backbone.js b/core/js/oc-backbone.js index 75a40979340..5c4eb2d24c8 100644 --- a/core/js/oc-backbone.js +++ b/core/js/oc-backbone.js @@ -7,6 +7,8 @@ * See the COPYING-README file. * */ + +/* global Backbone */ if(!_.isUndefined(Backbone)) { OC.Backbone = Backbone.noConflict(); } diff --git a/core/js/tests/specs/oc-backbone-webdavSpec.js b/core/js/tests/specs/oc-backbone-webdavSpec.js new file mode 100644 index 00000000000..8fe0b9e8297 --- /dev/null +++ b/core/js/tests/specs/oc-backbone-webdavSpec.js @@ -0,0 +1,352 @@ +/** +* ownCloud +* +* @author Vincent Petry +* @copyright 2014 Vincent Petry +* +* This library is free software; you can redistribute it and/or +* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +* License as published by the Free Software Foundation; either +* version 3 of the License, or any later version. +* +* This library is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU AFFERO GENERAL PUBLIC LICENSE for more details. +* +* You should have received a copy of the GNU Affero General Public +* License along with this library. If not, see . +* +*/ + +/* global dav */ + +describe('Backbone Webdav extension', function() { + var davClientRequestStub; + var davClientPropPatchStub; + var davClientPropFindStub; + var deferredRequest; + + beforeEach(function() { + deferredRequest = $.Deferred(); + davClientRequestStub = sinon.stub(dav.Client.prototype, 'request'); + davClientPropPatchStub = sinon.stub(dav.Client.prototype, 'propPatch'); + davClientPropFindStub = sinon.stub(dav.Client.prototype, 'propFind'); + davClientRequestStub.returns(deferredRequest.promise()); + davClientPropPatchStub.returns(deferredRequest.promise()); + davClientPropFindStub.returns(deferredRequest.promise()); + }); + afterEach(function() { + davClientRequestStub.restore(); + davClientPropPatchStub.restore(); + davClientPropFindStub.restore(); + }); + + describe('collections', function() { + var TestModel; + var TestCollection; + beforeEach(function() { + TestModel = OC.Backbone.Model.extend({ + sync: OC.Backbone.davSync, + davProperties: { + 'firstName': '{http://owncloud.org/ns}first-name', + 'lastName': '{http://owncloud.org/ns}last-name', + } + }); + TestCollection = OC.Backbone.Collection.extend({ + sync: OC.Backbone.davSync, + model: TestModel, + url: 'http://example.com/owncloud/remote.php/test/' + }); + }); + + it('makes a POST request to create model into collection', function() { + var collection = new TestCollection(); + var model = collection.create({ + firstName: 'Hello', + lastName: 'World' + }); + + expect(davClientRequestStub.calledOnce).toEqual(true); + expect(davClientRequestStub.getCall(0).args[0]) + .toEqual('POST'); + expect(davClientRequestStub.getCall(0).args[1]) + .toEqual('http://example.com/owncloud/remote.php/test/'); + expect(davClientRequestStub.getCall(0).args[2]['Content-Type']) + .toEqual('application/json'); + expect(davClientRequestStub.getCall(0).args[2]['X-Requested-With']) + .toEqual('XMLHttpRequest'); + expect(davClientRequestStub.getCall(0).args[3]) + .toEqual(JSON.stringify({ + 'firstName': 'Hello', + 'lastName': 'World' + })); + + var responseHeaderStub = sinon.stub() + .withArgs('Content-Location') + .returns('http://example.com/owncloud/remote.php/test/123'); + deferredRequest.resolve({ + status: 201, + body: '', + xhr: { + getResponseHeader: responseHeaderStub + } + }); + + expect(model.id).toEqual('123'); + }); + + it('uses PROPFIND to retrieve collection', function() { + var successStub = sinon.stub(); + var errorStub = sinon.stub(); + var collection = new TestCollection(); + collection.fetch({ + success: successStub, + error: errorStub + }); + + expect(davClientPropFindStub.calledOnce).toEqual(true); + expect(davClientPropFindStub.getCall(0).args[0]) + .toEqual('http://example.com/owncloud/remote.php/test/'); + expect(davClientPropFindStub.getCall(0).args[1]) + .toEqual([ + '{http://owncloud.org/ns}first-name', + '{http://owncloud.org/ns}last-name' + ]); + expect(davClientPropFindStub.getCall(0).args[2]) + .toEqual(1); + expect(davClientPropFindStub.getCall(0).args[3]['X-Requested-With']) + .toEqual('XMLHttpRequest'); + + deferredRequest.resolve({ + status: 207, + body: [ + // root element + { + href: 'http://example.org/owncloud/remote.php/test/', + propStat: [] + }, + // first model + { + href: 'http://example.org/owncloud/remote.php/test/123', + propStat: [{ + status: 'HTTP/1.1 200 OK', + properties: { + '{http://owncloud.org/ns}first-name': 'Hello', + '{http://owncloud.org/ns}last-name': 'World' + } + }] + }, + // second model + { + href: 'http://example.org/owncloud/remote.php/test/456', + propStat: [{ + status: 'HTTP/1.1 200 OK', + properties: { + '{http://owncloud.org/ns}first-name': 'Test', + '{http://owncloud.org/ns}last-name': 'Person' + } + }] + } + ] + }); + + expect(collection.length).toEqual(2); + + var model = collection.get('123'); + expect(model.id).toEqual('123'); + expect(model.get('firstName')).toEqual('Hello'); + expect(model.get('lastName')).toEqual('World'); + + model = collection.get('456'); + expect(model.id).toEqual('456'); + expect(model.get('firstName')).toEqual('Test'); + expect(model.get('lastName')).toEqual('Person'); + + expect(successStub.calledOnce).toEqual(true); + expect(errorStub.notCalled).toEqual(true); + }); + + function testMethodError(doCall) { + var successStub = sinon.stub(); + var errorStub = sinon.stub(); + + doCall(successStub, errorStub); + + deferredRequest.resolve({ + status: 404, + body: '' + }); + + expect(successStub.notCalled).toEqual(true); + expect(errorStub.calledOnce).toEqual(true); + } + + it('calls error handler if error status in PROPFIND response', function() { + testMethodError(function(success, error) { + var collection = new TestCollection(); + collection.fetch({ + success: success, + error: error + }); + }); + }); + it('calls error handler if error status in POST response', function() { + testMethodError(function(success, error) { + var collection = new TestCollection(); + collection.create({ + firstName: 'Hello', + lastName: 'World' + }, { + success: success, + error: error + }); + }); + }); + }); + describe('models', function() { + var TestModel; + beforeEach(function() { + TestModel = OC.Backbone.Model.extend({ + sync: OC.Backbone.davSync, + davProperties: { + 'firstName': '{http://owncloud.org/ns}first-name', + 'lastName': '{http://owncloud.org/ns}last-name', + }, + url: function() { + return 'http://example.com/owncloud/remote.php/test/' + this.id; + } + }); + }); + + it('makes a PROPPATCH request to update model', function() { + var model = new TestModel({ + id: '123', + firstName: 'Hello', + lastName: 'World' + }); + + model.save({ + firstName: 'Hey' + }); + + expect(davClientPropPatchStub.calledOnce).toEqual(true); + expect(davClientPropPatchStub.getCall(0).args[0]) + .toEqual('http://example.com/owncloud/remote.php/test/123'); + expect(davClientPropPatchStub.getCall(0).args[1]) + .toEqual({ + '{http://owncloud.org/ns}first-name': 'Hey' + }); + expect(davClientPropPatchStub.getCall(0).args[2]['X-Requested-With']) + .toEqual('XMLHttpRequest'); + + deferredRequest.resolve({ + status: 201, + body: '' + }); + + expect(model.id).toEqual('123'); + expect(model.get('firstName')).toEqual('Hey'); + }); + + it('uses PROPFIND to fetch single model', function() { + var model = new TestModel({ + id: '123' + }); + + model.fetch(); + + expect(davClientPropFindStub.calledOnce).toEqual(true); + expect(davClientPropFindStub.getCall(0).args[0]) + .toEqual('http://example.com/owncloud/remote.php/test/123'); + expect(davClientPropFindStub.getCall(0).args[1]) + .toEqual([ + '{http://owncloud.org/ns}first-name', + '{http://owncloud.org/ns}last-name' + ]); + expect(davClientPropFindStub.getCall(0).args[2]) + .toEqual(0); + expect(davClientPropFindStub.getCall(0).args[3]['X-Requested-With']) + .toEqual('XMLHttpRequest'); + + deferredRequest.resolve({ + status: 207, + body: { + href: 'http://example.org/owncloud/remote.php/test/123', + propStat: [{ + status: 'HTTP/1.1 200 OK', + properties: { + '{http://owncloud.org/ns}first-name': 'Hello', + '{http://owncloud.org/ns}last-name': 'World' + } + }] + } + }); + + expect(model.id).toEqual('123'); + expect(model.get('firstName')).toEqual('Hello'); + expect(model.get('lastName')).toEqual('World'); + }); + it('makes a DELETE request to destroy model', function() { + var model = new TestModel({ + id: '123', + firstName: 'Hello', + lastName: 'World' + }); + + model.destroy(); + + expect(davClientRequestStub.calledOnce).toEqual(true); + expect(davClientRequestStub.getCall(0).args[0]) + .toEqual('DELETE'); + expect(davClientRequestStub.getCall(0).args[1]) + .toEqual('http://example.com/owncloud/remote.php/test/123'); + expect(davClientRequestStub.getCall(0).args[2]['X-Requested-With']) + .toEqual('XMLHttpRequest'); + expect(davClientRequestStub.getCall(0).args[3]) + .toBeFalsy(); + + deferredRequest.resolve({ + status: 200, + body: '' + }); + }); + + function testMethodError(doCall) { + var successStub = sinon.stub(); + var errorStub = sinon.stub(); + + doCall(successStub, errorStub); + + deferredRequest.resolve({ + status: 404, + body: '' + }); + + expect(successStub.notCalled).toEqual(true); + expect(errorStub.calledOnce).toEqual(true); + } + + it('calls error handler if error status in PROPFIND response', function() { + testMethodError(function(success, error) { + var model = new TestModel(); + model.fetch({ + success: success, + error: error + }); + }); + }); + it('calls error handler if error status in PROPPATCH response', function() { + testMethodError(function(success, error) { + var model = new TestModel(); + model.save({ + firstName: 'Hey' + }, { + success: success, + error: error + }); + }); + }); + }); +}); + diff --git a/core/vendor/davclient.js/lib/client.js b/core/vendor/davclient.js/lib/client.js index 18bbf13f3cd..1a73c7db020 100644 --- a/core/vendor/davclient.js/lib/client.js +++ b/core/vendor/davclient.js/lib/client.js @@ -1,5 +1,19 @@ if (typeof dav == 'undefined') { dav = {}; }; +dav._XML_CHAR_MAP = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''' +}; + +dav._escapeXml = function(s) { + return s.replace(/[<>&"']/g, function (ch) { + return dav._XML_CHAR_MAP[ch]; + }); +}; + dav.Client = function(options) { var i; for(i in options) { @@ -85,6 +99,57 @@ dav.Client.prototype = { }, + /** + * Generates a propPatch request. + * + * @param {string} url Url to do the proppatch request on + * @param {Array} properties List of properties to store. + * @return {Promise} + */ + propPatch : function(url, properties, headers) { + headers = headers || {}; + + headers['Content-Type'] = 'application/xml; charset=utf-8'; + + var body = + '\n' + + '\n' + + ' \n'; + + for(var ii in properties) { + + var property = this.parseClarkNotation(ii); + var propName; + var propValue = properties[ii]; + if (this.xmlNamespaces[property.namespace]) { + propName = this.xmlNamespaces[property.namespace] + ':' + property.name; + } else { + propName = 'x:' + property.name + ' xmlns:x="' + property.namespace + '"'; + } + body += ' <' + propName + '>' + dav._escapeXml(propValue) + '\n'; + } + body+=' \n'; + body+=' \n'; + body+=''; + + return this.request('PROPPATCH', url, headers, body).then( + function(result) { + return { + status: result.status, + body: result.body, + xhr: result.xhr + }; + }.bind(this) + ); + + }, + /** * Performs a HTTP request, and returns a Promise * -- 2.39.5