Populating model from a third-party API

382 views Asked by At

I'm using Backbone for a personal project in which I created a model named MyModel. On initialization of this model, I want to populate its attributes from a JSON response from a third-party API:

app.MyModel = Backbone.Model.extend({
      url: 'https://api.xxxxxx.com/v12_1/item?id=53444d0d7ba4ca15456f5690&appId=xxxx&appKey=yyyy',

      defaults: {
          name: 'Default Name'
      }

  });

This model is used in a collection that will be used in an attribute embedded in a another model:

app.MyModels = Backbone.Collection.extend({
    model: app.MyModel
});

app.MyModel2 = Backbone.Model.extend({

    // Default attributes
    defaults: {
        name: 'Default Name'
    },

    initialize: function() {
        this.myModels = new app.MyModels();
        this.myModels.on('change', this.save);
    }
});

In a view created for MyModel2, I added a listener to a global element so we can initialize and add instances of MyModel to MyModels inside MyModel2.

app.MyModel2View = Backbone.View.extend({

    initialize: function() {
        // ...some code...
        var self = this;
        this.$(".add-myModel").click(function() {
            var myModel = new app.MyModel();
            myModel.fetch();
            self.model.myModels.add(myModel);
        });
        // ...some code...
    },
    // ...some code...
});

This is actually doing the intended goal, but throws an error in the console when the element is clicked and the instance added:

backbone.js:646 Uncaught TypeError: this.isNew is not a function

Is this a correct approach in Backbone to populate a model instance from an external API? I'm trying to figure out the reason for this error.

2

There are 2 answers

0
Emile Bergeron On BEST ANSWER

While Stephen is right, he only focuses on the most probable bug and leaves you dealing with everything else. I'll try to expand on this in my answer.


Model URL with ID in a query string

The API's URL is quite complex and it's cumbersome to copy-paste it every time you need it. It's best to get the URL handling in one place and one way to achieve this is with a simple service.

// The API service to use everywhere you need the API specific data.
app.API = {
    protocol: 'https',
    domain: 'api.xxxxxx.com',
    root: '/v12_1/',
    params: {
        appId: 'xxxx',
        appKey: 'yyyy',
    },
    /**
     * Get the full API url, with your optional path.
     * @param  {String} path (optional) to add to the url.
     * @return {String}  full API url with protocol, domain, root.
     */
    url: function(path) {
        path = path || '';
        if (path.slice(-1) !== '/') path += '/';
        return this.protocol + "://" + this.domain + this.root + path;
    },
    /**
     * Adds the query string to the url, merged with the default API parameters.
     * @param  {String} url  (optional) before the query string
     * @param  {Object} params to transform into a query string
     * @return {String}   e.g.: "your-url?param=value&otherparam=123"
     */
    applyParams: function(url, params) {
        return (url || "") + "?" + $.param(_.extend({}, this.params, params));
    },
};

Fill it with the API information.

Then, you can create a base model and collection (or replace the default Backbone behavior).

app.BaseModel = Backbone.Model.extend({
    setId: function(id, options) {
        return this.set(this.idAttribute, id, options);
    },
    url: function() {
        var base =
            _.result(this, 'urlRoot') ||
            _.result(this.collection, 'url') ||
            urlError();
        var id = this.get(this.idAttribute);
        return app.API.applyParams(base, this.isNew() || { id: encodeURIComponent(id) });
    },
});

app.BaseCollection = Backbone.Collection.extend({
    model: app.BaseModel,
    sync: function(method, collection, options) {
        var url = options.url || _.result(model, 'url') || urlError();
        options.url = aop.API.applyParams(url);
        return app.BaseCollection.__super__.sync.apply(this, arguments);
    }
});

Then using it is as simple as this:

app.MyModel = app.BaseModel.extend({
    urlRoot: app.API.url('item'),
})

app.Collection = app.BaseCollection.extend({
    model: app.MyModel,
    url: app.API.url('collection-items'),
});

The below test outputs:

var app = app || {};
(function() {


  app.API = {
    protocol: 'https',
    domain: 'api.xxxxxx.com',
    root: '/v12_1/',
    params: {
      appId: 'xxxx',
      appKey: 'yyyy',
    },
    /**
     * Get the full API url, with your optional path.
     * @param  {String} path (optional) to add to the url.
     * @return {String}  full API url with protocol, domain, root.
     */
    url: function(path) {
      path = path || '';
      if (path.slice(-1) !== '/') path += '/';
      return this.protocol + "://" + this.domain + this.root + path;
    },
    /**
     * Adds the query string to the url, merged with the default API parameters.
     * @param  {String} url  (optional) before the query string
     * @param  {Object} params to transform into a query string
     * @return {String}   e.g.: "your-url?param=value&otherparam=123"
     */
    applyParams: function(url, params) {
      return (url || "") + "?" + $.param(_.extend({}, this.params, params));
    },
  };

  app.BaseModel = Backbone.Model.extend({
    setId: function(id, options) {
      return this.set(this.idAttribute, id, options);
    },
    url: function() {
      var base =
        _.result(this, 'urlRoot') ||
        _.result(this.collection, 'url') ||
        urlError();
      var id = this.get(this.idAttribute);
      return app.API.applyParams(base, this.isNew() || {
        id: encodeURIComponent(id)
      });
    },
  });

  app.BaseCollection = Backbone.Collection.extend({
    model: app.BaseModel,
    sync: function(method, collection, options) {
      var url = options.url || _.result(model, 'url') || urlError();
      options.url = aop.API.applyParams(url);
      return app.BaseCollection.__super__.sync.apply(this, arguments);
    }
  });

  app.MyModel = app.BaseModel.extend({
    urlRoot: app.API.url('item'),
  })

  app.Collection = app.BaseCollection.extend({
    model: app.MyModel,
    url: app.API.url('collection-items'),
  });

  var model = new app.MyModel();
  console.log("New model url:", model.url());
  model.setId("53444d0d7ba4ca15456f5690");
  console.log("Existing model url:", model.url());

  var collection = new app.Collection();
  console.log("collection url:", _.result(collection, 'url'));

  var modelUrlThroughCollection = new app.BaseModel({
    id: "test1234"
  });
  collection.add(modelUrlThroughCollection);
  console.log("model via collection:", modelUrlThroughCollection.url());
})();
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js"></script>

New model url: https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy
Existing model url: https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy&id=53444d0d7ba4ca15456f5690
collection url: https://api.xxxxxx.com/v12_1/collection-items/
model via collection: https://api.xxxxxx.com/v12_1/collection-items/?appId=xxxx&appKey=yyyy&id=test1234

How to populate models with an external API?

Backbone.js gives structure to web applications by providing models with key-value binding and custom events, collections with a rich API of enumerable functions, views with declarative event handling, and connects it all to your existing API over a RESTful JSON interface.

If the API you're using adheres to REST principles, there's probably an endpoint which returns an array of objects. This is where the collection should fetch its data.

app.Collection = app.BaseCollection.extend({
    model: app.MyModel,
    url: app.API.url('collection-items'),
});
var collection = new app.Collection();
// GET request to 
// https://api.xxxxxx.com/v12_1/collection-items/?appId=xxxx&appKey=yyyy
collection.fetch();

And it should receive something like:

[
    { id: "24b6463n5", /* ... */ },
    { id: "345333bbv", /* ... */ },
    { id: "3g6g346g4", /* ... */ },
    /* ... */
]

If you want to add an existing model (referenced with an ID) to a collection:

var model = new app.MyModel({
    // giving an id to a model will make call to fetch possible
    id: "53444d0d7ba4ca15456f5690" 
});

// GET request to 
// https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy&id=53444d0d7ba4ca15456f5690
model.fetch();
collection.add(model);

The response should be a single object:

{ id: "53444d0d7ba4ca15456f5690", /* ... */ }

If you want to create a new model:

var model = new app.MyModel({ test: "data", /* notice no id passed */ });
// POST request to
// https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy
model.save();
// or, equivalent using a collection:
collection.create({ test: "data", /* notice no id passed */ });

Avoid .on/.bind in favor of .listenTo

Passing the context on an event binding is important with Backbone as most parts are classes versus jQuery callbacks which are usually anonymous functions working on local variables. In addition to this, you should use Backbone's listenTo instead of on.

Backbone js .listenTo vs .on

listenTo is the newer and better option because these listeners will be automatically removed for you during stopListening which is called when a view gets removed (via remove()). Prior to listenTo there was a really insidious problem with phantom views hanging around forever (leaking memory and causing misbehavior)...


Avoid manually binding events with jQuery

In the views, you should use the events property to automatically delegates the DOM events to the view's callbacks. It's still jQuery in the background, but cleaner, already integrated into Backbone and the context is automatically passed, so there's no need to use the var self = this trick.

app.MyModel2View = Backbone.View.extend({
    events: {
        "click .add-myModel": "onAddModelClick",
    },
    onAddModelClick: function() {
        this.model.myModels.add({});
    },
    // ...some code...
});

Creating a new model and fetching it makes no sense from the Backbone design unless you pass an id to the model. Just calling add on the collection with an empty object will create a default model.

0
Stephen Thomas On

Hard to say without more complete information, but it looks like you're not setting the context correctly when saving MyModels:

this.myModels.on('change', this.save);

That's an optional last parameter to the on() method, so maybe:

this.myModels.on('change', this.save, this);

See details in the documentation