core/js/oc-backbone-webdav.js
/*
* 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) {
if (_.isArray(result)) {
return _.map(result, function(subResult) {
return parsePropFindResult(subResult, 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 (key in davProperties) {
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('/');
var result;
do {
result = parts[parts.length - 1];
parts.pop();
// note: first result can be empty when there is a trailing slash,
// so we take the part before that
} while (!result && parts.length > 0);
return decodeURIComponent(result);
}
function isSuccessStatus(status) {
return status >= 200 && status <= 299;
}
function convertModelAttributesToDavProperties(attrs, davProperties) {
var props = {};
var key;
for (key in attrs) {
var changedProp = davProperties[key];
var value = attrs[key];
if (!changedProp) {
// no matching DAV property for property, skip
continue;
}
if (_.isBoolean(value) || _.isNumber(value)) {
// convert to string
value = '' + value;
}
props[changedProp] = value;
}
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 = parsePropFindResult(response.body, propsMapping);
if (options.depth > 0) {
// discard root entry
results.shift();
}
options.success(results);
return;
}
} else if (_.isFunction(options.error)) {
options.error(response);
}
});
}
function callPropPatch(client, options, model, headers) {
var changes = model.changed;
if (options.wait && _.isEmpty(changes)) {
// usually with "wait" mode, the changes aren't set yet,
changes = options.data;
// if options.patch is not set, then data contains all the data
// instead of just the properties to patch
if (!options.patch) {
// remove reserved properties
delete changes.href;
delete changes[_.result(model, 'idAttribute')];
// note: there is no way to diff with previous values here so
// we just send everything
}
}
return client.propPatch(
options.url,
convertModelAttributesToDavProperties(changes, options.davProperties),
headers
).then(function(result) {
if (result.status === 207 && result.body && result.body.length > 0) {
if (_.find(result.body[0].propStat, function(propStat) {
var statusCode = parseInt(propStat.status.split(' ')[1], 10);
return statusCode >= 400;
})) {
// in REST, validation errors are usually represented with 422 Unprocessable Entity,
result.status = 422;
}
}
if (isSuccessStatus(result.status)) {
// with wait, we set the changes only after success
if (options.wait) {
model.set(changes, options);
}
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 callMkCol(client, options, model, headers) {
var props = convertModelAttributesToDavProperties(model.attributes, options.davProperties);
if (!props['{DAV:}resourcetype']) {
props['{DAV:}resourcetype'] = '<d:collection/>';
}
return client.mkcol(
options.url,
props,
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) {
var data = options.data;
if (_.isObject(data)) {
headers['Content-Type'] = 'application/json';
data = JSON.stringify(data);
} else if (_.isString(data) && data.substr(0, 6) === '<?xml ') {
headers['Content-Type'] = 'application/xml';
} else {
headers['Content-Type'] = 'text/plain';
}
return client.request(
options.type,
options.url,
headers,
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;
}
// if multi-status, parse
if (result.status === 207) {
var propsMapping = _.invert(options.davProperties);
options.success(parsePropFindResult(result.body, propsMapping));
} else {
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',
'requesttoken': OC.requestToken
}, options.headers);
if (options.type === 'PROPFIND') {
return callPropFind(client, options, model, headers);
} else if (options.type === 'PROPPATCH') {
return callPropPatch(client, options, model, headers);
} else if (options.type === 'MKCOL') {
return callMkCol(client, options, model, headers);
} else {
return callMethod(client, options, model, headers);
}
}
/**
*
*/
function getTypeForMethod(method, model) {
var type = methodMap[method];
if (!type) {
// return method directly
return method;
}
// TODO: use special attribute "resourceType" instead
var isWebdavCollection = model instanceof WebdavCollectionNode;
// need to override default behavior and decide what to do
if (method === 'create') {
if (isWebdavCollection) {
if (!_.isUndefined(model.id)) {
// create new collection with known id
type = 'MKCOL';
} else {
// unsupported
throw 'Cannot create Webdav collection without id';
}
} else {
if (!_.isUndefined(model.id)) {
// need to create it first
type = 'PUT';
} else {
// creating without known id, will receive it after creation
type = 'POST';
}
}
} else if (method === 'update') {
// it exists, only update properties
type = 'PROPPATCH';
// force PUT usage ?
if (model.usePUT || (model.collection && model.collection.usePUT)) {
type = 'PUT';
}
}
return type;
}
/**
* DAV transport
*/
function davSync(method, model, options) {
var params = {type: getTypeForMethod(method, model)};
var isCollection = (model instanceof Backbone.Collection);
// 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 = 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' || params.type === 'MKCOL') {
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;
}
/**
* Regular Webdav leaf node
*/
var WebdavNode = Backbone.Model.extend({
sync: davSync,
constructor: function() {
this.on('sync', this._onSync, this);
this._isNew = true;
Backbone.Model.prototype.constructor.apply(this, arguments);
},
_onSync: function() {
this._isNew = false;
},
isNew: function() {
// we can't rely on the id so use a dummy attribute
return !!this._isNew;
}
});
/**
* Children collection for a Webdav collection node
*/
var WebdavChildrenCollection = Backbone.Collection.extend({
sync: davSync,
collectionNode: null,
model: WebdavNode,
constructor: function() {
this.on('sync', this._onSync, this);
Backbone.Collection.prototype.constructor.apply(this, arguments);
},
initialize: function(models, options) {
options = options || {};
this.collectionNode = options.collectionNode;
return Backbone.Collection.prototype.initialize.apply(this, arguments);
},
_onSync: function(model) {
if (model instanceof Backbone.Model) {
// since we saved, mark as non-new
if (!_.isUndefined(model._isNew)) {
model._isNew = false;
}
} else {
// since we fetched, mark models as non-new
model.each(function(model) {
if (!_.isUndefined(model._isNew)) {
model._isNew = false;
}
});
}
},
url: function() {
return this.collectionNode.url();
}
});
/**
* Webdav collection which is a special node, represented by a backbone model
* and a sub-collection for its children.
*/
var WebdavCollectionNode = WebdavNode.extend({
sync: davSync,
childrenCollectionClass: WebdavChildrenCollection,
_childrenCollection: null,
getChildrenCollection: function() {
if (!this._childrenCollection) {
this._childrenCollection = new this.childrenCollectionClass([], {collectionNode: this});
}
return this._childrenCollection;
}
});
// exports
Backbone.davCall = davCall;
Backbone.davSync = davSync;
Backbone.WebdavNode = WebdavNode;
Backbone.WebdavChildrenCollection = WebdavChildrenCollection;
Backbone.WebdavCollectionNode = WebdavCollectionNode;
})(OC.Backbone);