Backbone.js: Model save without the emulateJSON model attribute

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: 1
bar: 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!

Comments

comments