promises How can I extend the constructor of an AngularJS resource($resource)?



difference between promise and $q (4)

I have a model, defined using $resource, that I am successfully loading.

Each loaded instance is, as promised, an instance of the class I defined.

(The example below is from the Angular docs. In it, User.get results in an object that is an instanceof User.)

var User = $resource('/user/:userId', {userId:'@id'});

However, imagine each User comes over the wire like this:

{
  "username": "Bob",
  "preferences": [
    {
      "id": 1,
      "title": "foo",
      "value": false
    }
  ] 
}

I defined a Preference factory that adds valuable methods to Preference objects. But when a User loads, those preferences aren’t Preferences, naturally.

I attempted this:

User.prototype.constructor = function(obj) {
  _.extend(this, obj);
  this.items = _.map(this.preferences, function(pref) {
    return new Preference(pref);
  });
  console.log('Our constructor ran'); // never logs anything
}

But it has no effect and never logs anything.

How can I make each item in my Users’ preferences array an instance of Preference?


Answer #1

transformResponse does the job. Consider example (I wanted to use Autolinker to format response content).

return $resource('posts/:postId', {
    postId: '@_id'
}, {
    get : {
        transformResponse : function(data) {
            var response = angular.fromJson( data );
            response.content = Autolinker.link(response.content);
            return response;
        }
    },
    update: {
        method: 'PUT'
} });

Answer #2

Attempting to modify the constructor property of the prototype object won't do what you expect anyhow, please take a look at the very nice post here.

To really understand what is going on, one should look at the source code of the ngResource module - there are a lot of things at work there, but what's important is that the $resource factory returns a plain JavaScript function (really, what else). Invoking this function with the documented parameters returns a Resource constructor object, which is defined privately in resourceFactory.

As you may recall, AngularJS services are singletons, meaning that calling $resource will return the same function every time (in this case, resourceFactory). The important takeaway is that every time this function is evaluated, a new Resource constructor object is returned, meaning that you can prototype your own functions on it safely, without worrying that this will pollute all Resource instances globally.

Here is a service that you can use much as the original $resource factory, while defining your own custom methods that will be available on all of its instances:

angular.module('app').factory('ExtendedResourceFactory', ['$resource',
  function($resource) {                                                        
    function ExtendedResourceFactory() {
      var Resource = $resource.apply(this, arguments);

      Resource.prototype.myCustomFunction = function() {
        ...
      };

      return Resource;
    }

    return ExtendedResourceFactory;
  }
]);

Inside myCustomFunction you have access to the data returned from the server so you can use this.preferences and return whichever custom class you want to build.


Answer #3

You can do this by overriding the built-in resource actions to transform the request and response (See transformRequest and transformResponse in the docs.):

var m = angular.module('my-app.resources');
m.factory('User', [
          '$resource',
  function($resource) {

    function transformUserFromServer(user) {
      // Pass Preference directly to map since, in your example, it takes a JSON preference as an argument
      user.preferences = _.map(user.preferences, Preference);
      return user;
    }

    function transformUserForServer(user) {
      // Make a copy so that you don't make your existing object invalid
      // E.g., changes here may invalidate your model for its form, 
      //  resulting in flashes of error messages while the request is 
      //  running and before you transfer to a new page
      var copy = angular.copy(user);
      copy.preferences = _.map(user.preferences, function(pref) {
        // This may be unnecessary in your case, if your Preference model is acceptable in JSON format for your server
        return {
          id: pref.id,
          title: pref.title,
          value: pref.value
        };
      });

      return copy;
    }

    function transformUsersFromServer(users) {
      return _.map(users, transformUserFromServer);
    }

    return $resource('/user/:userId', {
        userId: '@id'
      }, {
        get: {
          method: 'GET',
          transformRequest: [
            angular.fromJson,
            transformUserFromServer
          ]
        },
        query: {
          method: 'GET',
          isArray: true,
          transformRequest: [
            angular.fromJson,
            transformUsersFromServer
          ]
        },
        save: {
          method: 'POST',
          // This may be unnecessary in your case, if your Preference model is acceptable in JSON format for your server
          transformRequest: [
            transformUserForServer,
            angular.toJson
          ],
          // But you'll probably still want to transform the response
          transformResponse: [
            angular.fromJson,
            transformUserFromServer
          ]
        },
        // update is not a built-in $resource method, but we use it so that our URLs are more RESTful
        update: {
          method: 'PUT',
          // Same comments above apply in the update case.
          transformRequest: [
            transformUserForServer,
            angular.toJson
          ],
          transformResponse: [
            angular.fromJson,
            transformUserFromServer
          ]
        }
      }
    );
  };
]);

Answer #4

$resource is a simple implementation, and lacks in things like this.

User.prototype.constructor won't do anything; angular doesn't try to act like it's object oriented, unlike other libraries. It's just javascript.

..But luckily, you have promises and javascript :-). Here's a way you could do it:

function wrapPreferences(user) {
  user.preferences = _.map(user.preferences, function(p) {
    return new Preference(p);
  });
  return user;
}

var get = User.get;
User.get = function() {
  return get.apply(User, arguments).$then(wrapPreferences);
};
var $get = User.prototype.$get;
User.prototype.$get = function() {
  return $get.apply(this, arguments).$then(wrapPreferences);
};

You could abstract this into a method which decorates any of a resource's methods: It takes an object, an array of method names, and a decorator function.

function decorateResource(Resource, methodNames, decorator) {
  _.forEach(methodNames, function(methodName) {
    var method = Resource[methodName];
    Resource[methodName] = function() {
      return method.apply(Resource, arguments).$then(decorator);
    };
    var $method = Resource.prototype[methodName];
    Resource.prototype[methodName] = function() {
      return $method.apply(this, arguments).$then(decorator);
    };
  });
}
decorateResource(User, ['get', 'query'], wrapPreferences);




angularjs-factory