I wanted to use Backbone’s save
feature of models, but our non-REST backend apis at YouNow don’t accept a JSON request body and are used across multiple platforms – so tweaking the apis to handle the Backbone.emulateJSON way of sending POST data (stringified model attributes associated with a model
key) wasn’t going to cut it. Here’s one way that I was able to encode a POST using more familiar key/value pairs about a Backbone model – and a patched caveat along the way.
An example
If you had a model with the following attributes hash:
{ foo: 1, bar: 2}
Then the emulateJSON
POST body would be:
model: '{"foo":1,"bar":2}'
Yes, you could add support for this on the backend – assuming model
isn’t a regular attribute that your api is expecting.
A more familiar request body would be:
foo: 1bar: 2
One Solution
To achieve this, you need to override the save
method of your model like so:
save: function (attributes, options) { options = options || {}; attributes = attributes || {}; options.data = this.toJSON(); return Backbone.Model.prototype.save.call(this, attributes, options);}
Now that you’ve set options.data
, when the attributes
and options
arguments get passed to the base Backbone.Model’s save method (and then to Backbone.sync
), the following happens:
The condition below (taken from the source code of Backbone.sync
) is no longer true – since options.data
is not null
// Ensure that we have the appropriate request data. if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { params.contentType = 'application/json'; params.data = JSON.stringify(options.attrs || model.toJSON(options)); }
This means that params.data
won’t be set to the stringified attributes of the model. This becomes important for the next condition:
// For older servers, emulate JSON by encoding the request into an HTML-form. if (options.emulateJSON) { params.contentType = 'application/x-www-form-urlencoded'; params.data = params.data ? {model: params.data} : {}; }
We’ll actually use Backbone.emulateJSON
(set it to true) in order to get access to that 'application/x-www-form-urlencoded'
content-type. However, params.data
was not set due to our earlier override of options.data
and is kept as an empty object.
Lastly, due to params.data
now being an empty object, the following code is where our override of options.data
(and the setting of the request body) takes place:
// Make the request, allowing the user to override any Ajax options. var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
The params
(empty object) is extended with our options
object (containing our custom options.data
). This gets passed to the ajax call, which produces the POST request body that we wanted.
Problem
This works well, except when you want to pass attributes as the first argument to save
(as you should be able to do normally – irrespective of the override of the save
function).
Here’s what typically happens:
Backbone.save calls a set
on the calling model – applying the attributes (attrs
) that you’re passing:
// If we're not waiting and attributes exist, save acts as // `set(attr).save(null, opts)` with validation. Otherwise, check if // the model will be valid when the attributes, if any, are set. if (attrs && !options.wait) { // Here's where it sets the passed attributes ('this' is the calling model) if (!this.set(attrs, options)) return false; } else { if (!this._validate(attrs, options)) return false; }
After setting the attributes, Backbone.sync
gets called and generates params.data
from the model… But wait, we just went through how that wasn’t going to happen anymore due to our override of options.data
. This means that set
is called on the model, but the attribute changes aren’t reflected in options.data
since that was set way before (in our custom save
function).
The fix
We’ll have to call set
in our override of save
– right before we populated options.data
as follows:
save: function (attributes, options) { options = options || {}; attributes = attributes || {}; this.set(attributes); options.data = this.toJSON(); return Backbone.Model.prototype.save.call(this, attributes, options);}
Hope it helps!