Last Updated: September 16, 2020
·
10.99K
· lkartono

Extend $resource with AngularJs

Almost every AngularJs applications need at one point to connect to an API to interact and retrieve datas. AngularJs out-of-the-box provide 2 commons ways to do so, using either $http or $resource object.

The latter wrap the former behind the scene to use in RESTful web API scenarios. Today, I’m gonna show you a little trick to extends $resource and give it a bit of extras convenient features.

$resource

In order to extend it, first we need to know how we generally use $resource. For the purpose of this article, we are going to assume that our application needs to play with users using the following API resource urls :

GET    /users
GET    /users/:id
POST   /users
PATCH  /users/:id
DELETE /users/:id

A standard implementation of $resource for this particular case would be :

(function () {
  'use strict';
  angular
    .module('app.core.resources')
    .factory('User', function ($resource) {
      return $resource('/users/:id', { id: '@id' }, {
        update: { method: 'PATCH' },
        destroy: { method: 'DELETE' }
      });
    });
})();

Extending $resource

The example above works actually well at what it does, and there’s nothing wrong at all of doing it that way.

However, in my case, and to stay consistent between my Rails backend and my frontend, I wanted to be able to have an object that I can use that way :

// Create a new user
var user = new User({ lastname: 'Kent', firstname: 'Clark' });
user.save();

// Get all users
var users = User.all();

// Find a user
var user = User.find(22);

// Update a user
User.find(22, function (user) {
  user.firstname = 'Martha';
  user.save();
});

// Delete a user
User.find(22, function (user) {
  user.delete();
});

The ActiveResource object

Let’s call our extension ActiveResource. Methods like save() and delete() need to be created at the prototype level in order to be used by object instance.

Also, save() need to check whether or not an id is already defined on the instance in order to execute a POST or a PATCH request. This service relies on https://github.com/iobaixas/angular-inflector in order to works.

(function () {
  'use strict';

  angular
    .module('wam.core')
    .provider('ARSettings', function () {
      this.apiUrl = undefined;

      this.$get = function () {
        return {
          apiUrl: this.apiUrl,
        };
      };

      this.configure = function (config) {
        for (var key in config) {
          this[key] = config[key];
        }
      };
    })
    .factory('ActiveResource', function ($resource, ARSettings, inflector, $injector) {

      /**
       * Check whether an object is a number.
       *
       * @param  {Object} object - Object to check numericallity on.
       * @return {Boolean} True if number, false otherwise.
       */
      var isNumeric = function (object) {
        return !isNaN(parseFloat(object)) && isFinite(object);
      };

      /**
       * Generate options based on arguments passed.
       * If the object passed is :
       *    - An object : Use it directly.
       *    - A string : Inflect it and use the default config.
       *    - Something else : Throw an error.
       *
       * @param  {Object} args - Javascript object or string as the name of the resource (singular).
       * @return {Object} Options to pass to $resource.
       */
      var sanitizeOptions = function (args) {
        if (args !== null && typeof args === 'string') {
          var _resName = inflector.pluralize(args);
          return {
            url: '/' + _resName + '/:id/:action',
            params: { id: '@id' },
            namespace: args
          };
        } else if (args !== null && typeof args === 'object') {
          return args;
        } else {
          throw new Error(args + ' is not a valid options');
        }
      };

      /**
       * ActiveResource core definition.
       */
      var Resource = function (options) {
        options = sanitizeOptions(options);
        options.params  = options.params  || {};
        options.methods = options.methods || {};

        /**
         * Transform data before querying the server.
         * In the case of Rails, will wrap the data with a resource namespace.
         *
         * @param {Object} data - Data to send.
         * @return {String} Stringify data.
         */
        var transformRequest = function (data) {
          if (!options.namespace) {
            return JSON.stringify(data);
          }

          var datas = {};
          datas[options.namespace] = data;
          return JSON.stringify(datas);
        };

        /**
         * Transform data after querying the server.
         * If the response contains an object (instead of a query) with the resource namespace in plural :
         * 
         * new ActiveResource('user') => Check for the key users
         * 
         * then attach to each object the Resource object. This is a particular case
         * mostly used in pagination scenario.
         *
         * @param {Object} data - Data to send.
         * @return {String} Stringify data.
         */
        var transformResponse = function (data) {
          data = JSON.parse(data);

          var namespace = inflector.pluralize(options.namespace);
          if (data[namespace]) {
            var ClassObject = $injector.get(
              inflector.camelize(inflector.singularize(namespace), true)
            );

            angular.forEach(data[namespace], function (object, index) {
              var instance = new ClassObject();
              data[namespace][index] = angular.extend(instance, object);
            });
          }

          return data;
        };

        var defaults = {
          browse:  { method: 'GET',   transformResponse: transformResponse },
          query:   { method: 'GET',   transformResponse: transformResponse, isArray: true },
          get:     { method: 'GET',   transformResponse: transformResponse },
          create:  { method: 'POST',  transformRequest: transformRequest },
          update:  { method: 'PATCH', transformRequest: transformRequest },
          destroy: { method: 'DELETE' }
        };

        angular.extend(defaults, options.methods);
        var resource = $resource(ARSettings.apiUrl + options.url, options.params, defaults);

        /**
         * Get an entire collection of objects.
         *
         * @param  {Object} args - $resource.query arguments.
         * @return {Promise} Promise
         */
        resource.all = function (args) {
          var options = args || {};
          return this.query(options);
        };

        /**
         * Get an entire collection of objects.
         * Since a search is often returning pagination type of data,
         * the collection of object will be wrapped under a key within that response.
         * See transformResponse for more information about that case.
         *
         * @param  {Object} args - $resource.query arguments.
         * @return {Promise} Promise
         */
        resource.search = function (args) {
          var options = args || {};
          return this.browse(options);
        };

        /**
         * Find a specific object.
         *
         * @param  {Object|Integer} args - $resource.get arguments, or { id: args } if numeric.
         * @param  {Function} callback - $resource.get callback function if any.
         * @return {Promise} Promise
         */
        resource.find = function (args, callback) {
          var options = isNumeric(args) ? { id: args } : args;
          return this.get(options, callback);
        };

        /**
         * Mixin custom methods to instance.
         *
         * @param  {Object} args - Set of properties to mixin the $resource object.
         * @return {this} this. Chainable.
         */
        resource.instanceMethods = function (args) {
          angular.extend(this.prototype, args);
          return this;
        };

        /**
         * $resource's $save method override.
         * Allow to use $save in order to create or update a resource based on it's id.
         *
         * @return {Promise} Promise
         */
        resource.prototype.save = function () {
          var action = this.id ? '$update' : '$create';
          return this[action]();
        };

        /**
         * Delete instance object.
         *
         * @return {Promise} Promise
         */
        resource.prototype.delete = function () {
          if (!this.id) {
            throw new Error('Object must have an id to be deleted.');
          }

          var options = { id: this.id };
          return this.$destroy(options);
        };

        return resource;
      };
      return Resource;
    });
})();

Explanation

Our ActiveResource object takes as a paremeter one argument that can be either an object or a string. In the case of a string, it should be the name of the resource in singular.

new ActiveResource('user');

The options will then be tranformed as :

{
  url: '/users/:id/:action',
  params: { id: '@id', action: '@action' },
  namespace: 'user'
}

NOTE : The namespace option here is used to transform data before a POST or a PATCH request.

In order to add save() and delete() to our ActiveResource instance, we need to add those at the prototype level. What this means is that the reference of this inside those methods is actually the instance itself. This is important because in the case of a save(), we need to call the $update() method only if an id is present :

resource.prototype.save = function () {
  var action = this.id ? '$update' : '$create';
  return this[action]();
};

If you haven’t noticed, I also add instanceMethods(). It is a convenient method that can be use at the instanciation level to add custom mixins to an object instance. For example, in the case of a User :

(function () {
  'use strict';
  angular
    .module('app.core.resources')
    .config(function (ARSettingsProvider) {
      ARSettingsProvider.configure({
        apiUrl: 'https://api.endpoint.com'
      });
    })
    .service('User', function (ActiveResource) {
      return new ActiveResource('user').instanceMethods({
        isAdmin: function () {
          return this.role == 'admin';
        },
        isUser: function () {
          return this.role == 'user';
        },
        fullname: function () {
          return this.lastname + ' ' + this.firstname;
        }
      });
    });
})();

Those methods can then be used in any injected object :

(function () {
  'use strict';
  angular
    .module('app.module.user')
    .controller('UsersController', function (User) {
      var vm = this;
      User.find({ action: 'me' }, function (me) {
        if (me.isAdmin()) {
          vm.users = User.all();
        }
      });
    });
})();

as well as within views :

<ul>
  <li ng-repeat="user in users">
    {{ user.fullname() }}
    <div ng-if="user.isAdmin()">
      <button class="btn btn-primary">Delete</button>
    </div>
  </li>
</ul>

Conclusion

This article only scratch the surface of what it is possible to do. It’s goal was to demonstrate how easy it is to use Angular native objects, and add functionnalities to them.