Skip to content

Instantly share code, notes, and snippets.

@amcdnl
Last active August 29, 2015 14:06
Show Gist options
  • Save amcdnl/9f5609713ae8a4fd475e to your computer and use it in GitHub Desktop.
Save amcdnl/9f5609713ae8a4fd475e to your computer and use it in GitHub Desktop.

Angular Models

Proposal: Create a light weight model layer that bridges the gap between some of the features that are common with SPA.

Why would you use this over other available solutions?

  • Lightweight/Simple, the code simply does some basic copy/extending and prototypical instances; no magic required.
  • Patterns/Practices, the model definition closely resembles Angular's ngResource meaning its easy to swap out, replace later (if ngResource gets awesome suddenly), eases scaling new devs / organizations, and its designed for Angular; not a backbone port!
  • Utiilizes Angular at the core, it doesn't duplicate things Angular already does. Any action can be passed a $http configuration option, all your interceptors still work, it uses Angular's cache, etc!
  • Compliant, URI Template matches the specs.
  • 1.27KB gziped/minified ( excludes depedencies )
  • Minimal Depdencies, only use URI template and deep-diff ( this isn't even required ) utility. NO underscore, lodash, jquery, etc!
  • Its full of awesome features

CAUTION: This is still experimental.

Other Solutions

  • Restmod Very nice solution but very opinionated and hyper-active ( lots of breaking changes between minor versions ). 22kb min, kinda heavy for a model layer.

  • Modelizer Good but requires Lodash. 23kb min

  • ModelCore Good ( really like the API ) but not very well tested and not active.

  • angular-watch-resource - really only handles collections

  • angular-restful - Very basic but nice

  • ngResource Out of the box model layer, very limited.

  • angularjs-rails-resource Too rails-ish.

  • angular-nested-resource - Okay API, not loving the nested architecture.

  • Aar.js Very light, not sure what value this adds.

  • Angular Activerecord A copy of BackboneModel but doesn't really work with Angular patterns.

  • Angular-Data Not really a model layer but a data store. Very very heavy ( 67kb min )

  • ngActiveResource Very ruby-ish api. Requires lodash. Has validation but thats not needed in angular if you do it right.

  • restangular I don't consider this a model layer; it feels moore like a fancy http layer that returns promises because everyone complains about ngResource not doing it. It requires underscore.

  • BreezeJS This is a very full featured model/cache/validation etc. Its framework agnostic, which means it follows its own patterns and not angulars. Its very heavy, requires server data massaging, and the API looks like Microsoft Entity Framework ( overkill IMO ).

  • ng-backbone Another backbone model clone. This one actually requires backbone and lodash.

Requirements

  • URI Templates (RFC6570)
  • Object Deep Diff / Reversion
  • Model instances
  • Collections
  • Single Datastore
  • Caching
  • Default value population
  • Pending / Completed Status
  • Relationships
  • Track active promises to prevent duplicate sends

Roadmap

  • Lifecyle events ( pre-save, post-save, after-update, after-delete, etc )
  • Inhertiance
  • Deseralizers
  • Socket listeners - probably could do w/ events
  • API Versioning ( api/v1/ ... api/v2/ )
  • Pagination out of the box

Depedencies

NOTE: You will need to include deep-diff and uritemplates references and ensure usage is correct.

API

Model Factory

Simple Definition

A basic model definition.

var module = angular.module('services.zoo', ['core.model']);

module.factory('AnimalModel', function($modelFactory){
  return $modelFactory('api/zoo');
});

return module;

Advanced Definition

A advanced definition that demonstrates all scenarios.

var module = angular.module('services.zoo', ['core.model']);

module.factory('AnimalModel', function($modelFactory){

    var model = $modelFactory('api/zoo', {
    
        // the default primary key
        pk: 'id',
    
        map: {
            'zooId: 'id',

            // has many
            'animals': AnimalModel.List,

            // has one
            'location': LocationModel
        },
        
        // only called on empty inits
        defaults: {
            'created': new Date()
        },
        
        // All return $promise 
        // Note: All default options are transposed to all new instance
        // unless explicitly overridened
        actions:{
            'base': {
                // any $http argument
                
                // before ajax call
                // this only manipulates data sent not core object
                beforeRequest: function() { ... } 
                
                // after ajax call response
                // happens before the object is wrapped
                afterRequest: function(){ ... }
            },
            
            // these are implied by default given
            // the base url unless overridden like below
            // - get
            // - query
            // - post
            // - update
            // - delete
            
            'query': { 
                // lets cache all query requests
                // this uses the out of the box angular caching
                // for $http.  I create a cache factory on each
                // instance so you can access it via `$cache` attribute
                cache: true
            },
            
            // custom methods
            'queryFood':{
                type: 'GET',
                
                // urls inherit the base url
                // url becomes: api/zoo/food
                url: 'food', 
                
                // doesn't attach prototype methods
                wrap: false 
            },
            
            'update': {
                // by default, save/update/delete will all
                // invalidate the cache if defined.
                invalidateCache: true
            },
            
            // anything with $ prefix is attached to instance
            '$copy': {
                type: 'POST',
                url: 'stlouis/zoo/copy',
                
                // overrides the root url
                override: true
            }
        },
        
        list: {
        
          // example list helper
          nameById: function(id) {
               var user = this.find(function(u){
                   return u.id === id;
               });
               return user ? user.name() : "Unavailable";
           }
           
        },
        
        instance: {
            
            // instance api
            // - $save
            // - $destroy
            
            // revision api
            // - $diff
            // - $revert
        
            // example custom helper
            'getName': function(val){ 
                return val.first + ' ' val.last 
            } 
        },
        
        // any method that does not represent a http action
        // that will be attached to the static class
        myStaticMethod: function(){
        
        }
    });
    
    return model;

});

Controller Usage

module.controller('ZooCtrl', function ($scope, AnimalModel) {

    // create single
    var animal = new AnimalModel({
        name: 'Panda'
    });
    
    // creates if no id
    // updates if has id
    animal.$save();

    // create list
    // objects will automatically be wrapped
    var animalList = new AnimalModel.List([ {}, {}, ... ]);
    
    // add the model to this list
    animalList.push(animal)
    
    // deletes the model
    // and removes from the list i pushed into
    animal.$destroy();
});

Example Requests

//-> api/zoo/345234
return AnimalsModel.get(id);

//-> api/zoo/345234?type=panda
return AnimalsModel.get(id, { type: panda });

//-> api/zoo/345234
return AnimalsModel.get({ id: id });

//-> api/zoo?name=panda
return AnimalsModel.query({ name: 'panda' });

UI-Router resolves

$stateProvider.state('zoo', {
    url: '/zoo',
    templateUrl: 'zoo.tpl.html',
    controller: 'ZooCtrl',
    resolve: {
        animals: function (AnimalsModel) {
            return AnimalsModel.query({ name: 'panda' });
            
        }
    }
});

Lists

module.controller('ZooCtrl', function ($scope, AnimalModel, animals) {
    // animals === [ AnimalModel({ type: 'panda' }) ]
    
    var animal = animals[0];
    //-> animals = [ { type: 'panda' } ]
    
    // Update an instance in the list
    animal.type = 'lion';
    //-> animal[0].type == 'lion'
    
    //-> commits THIS model to server
    animal.$save();

    // automatically deletes from list
    animal.$destory();
    //-> animals = []

});

Useful Tips/Notes

Usage

This system DOESNT make sense for all your $http assets. I'd recommend implementing for assets that have CRUD with RESTful APIs.

Cache

Angular caches the http response from the server in a $cacheFactory based on the url of the request. Angular does not handle cache invalidation though. During a POST/UPDATE/DELETE $modelFactory actually will invalidate the cache using the invalidateCache factory. This will remove ALL cache instances for that particular model.

If you choose to use the cache, you should also consider other clients invalidating your cache. This can be achieved by using a socket implementation at the server level to distribute events to the client to invalidate cache. $modelFactory keeps an instance of the $cacheFactory on its static instance for easy access to do your invalidation.

Event Distribution

Sometimes you need a pub/sub model. Using Angular's core broadcast system we can achieve that relatively simple.

var factory = $modelFactory('api/zoo', {
    actions:{
        'delete': {
            afterRequest:function(model){
                $rootScope.$broadcast('animalDeleted', model.id);
            }
        }
    }
});

then later in another controller/etc:

$rootScope.$on('animalDeleted', function(id){
    alert('Animal deleted: ' + id)    
})

Button States

The $pending attribute on the model can be used to easily disable a button while things are saving/updating/deleting. Example:

<button ng-disabled="myModel.$pending">Save</button>

when completed the $pending state will be set to false re-enabling the button.

Todos

  • Better cache invalidation

  • Review beforeRequest & afterRequest implementation and if should convert to use transformResponse \ transformRequest like Angular.

  • Investigate copy/extend usage for perf

  • Fetch relationships if not present in response

  • Odd API cases like: POST api/zoo/{locationId}/{animalId}/ with data that might look like: { id: 1234, animalName: 'panda', ... }

define(['angular', 'common/core/model'], function (angular) {
var module = angular.module('services.apps', ['core.model']);
module.factory('AppsModel', function($modelFactory){
var model = $modelFactory('api/apps', {
defaults: {
createWorkspace: true
},
actions: {
query: {
cache: true
},
$copy: {
method: 'POST',
url: 'copy'
},
$download: {
method: 'POST',
url: 'export'
}
}
});
return model;
});
return module;
});
define(['angular', 'uri-templates', 'common/utils/diff'], function(angular, uriTemplates){
var module = angular.module('core.model', ['utils.diff']);
// compression
var forEach = angular.forEach,
extend = angular.extend,
copy = angular.copy;
// Deep extends
// http://stackoverflow.com/questions/15310935/angularjs-extend-recursive
var extendDeep = function extendDeep(dst) {
forEach(arguments, function(obj) {
if (obj !== dst) {
forEach(obj, function(value, key) {
if (dst[key] && dst[key].constructor && dst[key].constructor === Object) {
extendDeep(dst[key], value);
} else {
dst[key] = value;
}
});
}
});
return dst;
};
// Based on https://gist.github.com/amcdnl/9f5609713ae8a4fd475e
module.factory('$modelFactory', function($http, $q, $log, $cacheFactory, Diff){
var defaultOptions = {
/**
* Primary key of the model
*/
pk: 'id',
/**
* By default, trailing slashes will be stripped
* from the calculated URLs.
*/
stripTrailingSlashes: true,
/**
* Default values for a new instance.
* This will only be populated if the property
* is undefined.
*
* Example:
* defaults: {
* 'create': new Date()
* }
*/
defaults: {},
/**
* Attribute mapping. Tranposes attributes
* from a response to a different attribute.
*
* Also handles 'has many' and 'has one' relations.
*
* Example:
* map: {
* // transpose `animalId` to
* // `id` on our instance
* 'id': 'animalId',
*
* // transposes `animal` attribute
* // to an array of `AnimalModel`'s
* 'animal': AnimalModel.List,
*
* // transposes `location` attribute
* // to an instance of `LocationModel`
* 'location': LocationModel
* }
*/
map:{},
/**
* Hash declaration of model actions.
*
* NOTE: Anything prefixed with `$` will be attached to the
* model instance rather than the static.
*/
actions:{
/**
* Base options to be applied to all other actions by default.
* In addition to the methods listed here, any `$http` attribute
* is valid. https://docs.angularjs.org/api/ng/service/$http
*
* If the method is a `GET` and the arguments invoking it are a string or number,
* then the model automatically assumes you are wanting to pass those are the primary key.
*
* Action Agnostic Attributes:
* - `override` Overrides the base url prefixing.
* - `method` Case insensitive HTTP method (e.g. GET, POST, PUT, DELETE, JSONP, etc).
* - `url` URL to be invoked by `$http`. All urls are prefixed with the base url passed initally. All templates are [URI Template](http://tools.ietf.org/html/rfc6570) spec.
*/
'base': {
/**
* Wrap the response from an action in a instance of the model.
*/
wrap: true,
/**
* Callback before data is sent to server.
* This allows developers to manipulate the
* object before its sent to the server but
* not effect the core object.
*/
beforeRequest: undefined,
/**
* Callback after data recieved from server but
* before the data is wrapped in an instance.
*/
afterRequest: undefined,
/**
* By default, do not cache the requests.
*/
cache: false
},
'get': {
method: 'GET'
},
'query': {
method: 'GET',
/**
* If true then the returned object for this action is an array.
*/
isArray: true
},
/**
* In theory `post`, `update`, and `delete` below would/should not be used,
* instead one would use `$save` or `$destroy` to be invoked
*/
'post': {
method: 'POST',
invalidateCache: true
},
'update': {
method: 'PUT',
invalidateCache: true
},
'delete': {
method: 'DELETE',
invalidateCache: true
}
},
/**
* Instance level extensions/helpers.
*
* Example:
* instance: {
* 'name': function() {
* return this.first + ' ' + this.last
* }
* }
*/
instance: {},
/**
* List level extensions/helpers.
*
* Example:
*
* list: {
* 'namesById': function(id){
* return this.find(function(u){ return u.id === id; });
* }
* }
*
*/
list: {}
};
// keywords that are reserved for model instance
// internal usage only and to be stripped
// before sending to server
var instanceKeywords = [ '$array', '$save', '$destroy',
'$pending', '$revert', '$diff' ];
// keywords that are reserved for the model static
// these are used to determine if a attribute should be extended
// to the model static class for like a helper that is not a http method
var staticKeywords = [ 'actions', 'instance', 'list', 'defaults',
'pk', 'stripTrailingSlashes', 'map' ];
/**
* Model factory.
*
* Example usages:
* $modelFactory('api/zoo');
* $modelFactory('api/zoo', { ... });
*/
function modelFactory(url, options) {
/**
* Prevents multiple calls of the exact same type.
*
* { key: url, value: promise }
*
*/
var promiseTracker = {};
// copy so we also extend our defaults and not override
//var actions = angular.extend({}, defaultOptions.actions, options.actions);
options = extendDeep({}, copy(defaultOptions), options);
//
// Instance
// ------------------------------------------------------------
/**
* Model instance.
*
* Example usages:
* var zoo = new Zoo({ ... });
*/
function Model(value) {
var instance = this, old;
// if the value is undefined, create a empty obj
if(value === undefined){
value = {};
}
// build the defaults but only on new instances
forEach(options.defaults, function(v, k){
// only populates when not already defined
if(value[k] === undefined){
if(typeof v === 'function'){
// pass the value so you can combine things
// this could be tricky if you have defaults that rely on other defaults ...
// like: { name: function(val) { return val.firstName + val.lastName }) }
value[k] = v(value);
} else {
value[k] = v;
}
}
});
// Map all the objects to new names or relationships
forEach(options.map, function(v, k){
if(typeof v === Model || typeof v === ModelCollection){
instance[k] = new v(value[k]);
} else {
instance[k] = value[k];
delete value[k];
}
});
// attach instance actions
forEach(options.actions, function(v, k){
if(k[0] === '$'){
instance[k] = function(){
return Model.$buildRequest(k, v, instance);
};
}
});
// copy values to the instance
extend(instance, value);
// copy instance level helpers to this instance
extend(instance, copy(options.instance));
/**
* Save the instance to the server. Posts the instance unless
* the instance has the `pk` attribute already then it will do a put.
*/
instance.$save = function(){
var promise = Model[instance[options.pk] ?
'update' : 'post'](instance);
instance.$pending = true;
promise.then(function(value){
instance.$pending = false;
// extend the value from the server to me
extend(instance, value);
});
return promise;
};
/**
* Delete the instance. Performs a DELETE on this instance performing
* the delete action passing an instance of itself.
*
* If the item is associated with an array, it will automatically be removed
* on successful delete.
*/
instance.$destroy = function(){
// keep a local pointer since we strip before send
var promise = Model.delete(instance);
instance.$pending = true;
promise.then(function(){
instance.$pending = false;
var arr = instance.$array;
if(arr){
arr.splice(arr.indexOf(instance), 1);
}
});
return promise;
};
/**
* Display the difference between the original data and the
* current instance.
* https://github.com/flitbit/diff
*/
instance.$diff = function(){
return Diff.deep(old, instance);
};
/**
* Reverts the current instance back to the first instance of the object.
* This does NOT save the model to the server, call `$save` to do that.
*/
instance.$revert = function(){
extend(instance, old);
return instance;
};
// Create a copy of the value last so we get all the goodies,
// like default values and whatnot.
old = copy(value);
};
//
// Static
// ------------------------------------------------------------
/**
* Create an instance of a cache factory
* for tracking data of this instance type.
* https://docs.angularjs.org/api/ng/service/$cacheFactory
*/
Model.$cache = $cacheFactory(url);
// attach actions
forEach(options.actions, function(v, k){
// don't do base or $
if(k === 'base' || k[0] === '$') return;
Model[k] = function(){
//http://stackoverflow.com/questions/2091138/why-doesnt-join-work-with-function-arguments
var args = Array.prototype.slice.call(arguments);
return Model.$buildRequest.apply(this, [k, v].concat(args));
};
});
/**
* Builds the request for a set of actions.
*/
Model.$buildRequest = function(action, param, data, extras){
var clone = copy(options.actions.base);
extend(clone, copy(param));
// if we explicity call cache
// to true and don't pass a factory
// lets use our instance level for
// data storage means
if(clone.cache === true){
clone.cache = Model.$cache;
}
// uri template to parameterize
var uri = "";
// make sure we didn't override the base url prefixing
if(!clone.override){
// set the uri to the base
uri = url;
// if we have a url defined, append to base
if(clone.url) {
uri += "/" + clone.url;
}
// attach the pk referece by default if it is a 'core' type
if(action === "get" || action === "post" || action === "update" || action === "delete"){
uri += "/{" + options.pk + "}";
}
if(clone.method === "GET" && (angular.isString(data) || angular.isNumber("number"))){
// if we have a get method and its a number or a string
// you can assume i'm wanting to do something like:
// ZooModel.get(1234) instead of ZooModel.get({ id: 1234 });
var obj = {};
obj[options.pk] = data;
data = obj;
// if we have a extra argument on this case we should assume its a
//
if(extras){
data.param = extras;
uri += "{?param*}";
}
} else if(clone.method === "GET" && angular.isObject(data)){
// if its a GET request and its not the above, we can assume
// you want to do a query param like:
// ZooModel.query({ type: 'panda' }) and do /api/zoo?type=panda
data = { param: data };
uri += "{?param*}";
}
} else {
uri = clone.url;
}
clone.url = Model.$url(uri, data);
clone.data = data;
return Model.$call(clone);
};
/**
* Invokes `$http` given parameters and does some
* callback before/after and state setting.
*/
Model.$call = function(params){
// if we have the promise in queue, call it
if(promiseTracker[params.url]){
return promiseTracker[params.url];
}
var def = $q.defer();
// set the queue for this promise
promiseTracker[params.url] = def.promise;
// copy the data so we can manipulate
// it before the request and not affect
// the core object
params.data = copy(params.data);
// before callbacks
params.beforeRequest &&
params.beforeRequest(params);
// strip all the internal functions/etc
params.data = Model.$strip(params.data)
$http(params).success(function(response){
// after callbacks
params.afterRequest &&
params.afterRequest(response);
// if we had a cache, remove it
// this could be optimized to only do
// the invalidation of things by id/etc
if(params.invalidateCache){
Model.$cache.removeAll();
}
if(params.wrap){
if(params.isArray){
def.resolve(new Model.List(response));
} else {
def.resolve(new Model(response));
}
} else {
def.resolve(response);
}
promiseTracker[params.url] = undefined;
}).error(function(response){
def.reject(response);
});
return def.promise;
};
/**
* Returns a url given the URI template and parameters.
*
* Examples:
*
* // obj = { id: 2344 }
* Model.$url('api/zoo/{id}', obj)
* //-> 'api/zoo/2345'
*
* // {}
* Model.$url('api/zoo/{id}')
* //-> 'api/zoo'
*
* // { params: { type: 'panda' } }
* Model.$url('api/zoo/{?params*}')
* //-> 'api/zoo?type=panda'
*
* Optionally strips trailing `/`'s.
*
* Based on:
* https://github.com/geraintluff/uri-templates
*/
Model.$url = function(u, params){
var uri = new uriTemplates(u || url)
.fillFromObject(params || {});
if(options.stripTrailing){
uri = uri.replace(/\/+$/, '') || '/';
}
return uri;
};
/**
* Remove instances of reserved keywords
* before sending to server/json.
*/
Model.$strip = function(args){
// todo: this needs to account for relationships too?
// either make recursive or chain invoked
if(args && typeof args === "object"){
forEach(args, function(v,k){
if(instanceKeywords.indexOf(k) > -1){
delete args[k];
}
});
}
return args;
};
// extend the static class with arguments that are not internal
forEach(options, function(v, k){
if(instanceKeywords.indexOf(k) === -1){
Model[k] = v;
}
});
//
// Collection
// ------------------------------------------------------------
//
/**
* Model list instance.
* All raw objects passed will be converted to an instance of this model.
*
* If we `push` a item into an existing collection, a pointer will be made
* so on a destroy items will be removed from the array as well.
*
* Example usages:
* var zoos = new Zoo.List([ {}, ... ]);
*/
Model.List = function ModelCollection(value){
var instance = this;
// wrap each obj
value.forEach(function(v, i){
// create an instance
var inst = v.constructor === Model ?
v : new Model(v);
// set a pointer to the array
inst.$array = value;
// reset to new instance
value[i] = inst;
});
// override push to set an instance
// of the list on the model so destroys will chain
var __oldPush = value.push;
value.push = function(model){
if(model.constructor === Model){
model.$array = value;
}
__oldPush.apply(value, arguments);
};
// add list helpers
if(options.list){
extend(value, options.list);
}
return value;
};
return Model;
};
return modelFactory;
});
return module;
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment