UNPKG

angular-model

Version:

Simple HATEOS-oriented persistence module for AngularJS.

764 lines (690 loc) 22 kB
/** * AngularJS API Domain Model Service * * {@copyright 2015, Radify, Inc (http://radify.io/)} * {@link https://github.com/radify/angular-model#readme} * * @license BSD-3-Clause */ (function(window, angular, undefined) { 'use strict'; var forEach = angular.forEach, extend = angular.extend, copy = angular.copy, isFunc = angular.isFunction, isObject = angular.isObject, isArray = angular.isArray, isUndef = angular.isUndefined, equals = angular.equals; function lisObject(thing) { return thing && thing.constructor && thing.constructor === Object; } function deepExtend(dst, source) { for (var prop in source) { if (source[prop] && lisObject(source[prop])) { dst[prop] = dst[prop] || {}; if (lisObject(dst[prop])) { deepExtend(dst[prop], source[prop]); continue; } } else if (isArray(source[prop])) { dst[prop] = []; for (var i = 0; i < source[prop].length; i++) { var item = source[prop][i]; if (lisObject(item)) { dst[prop].push(deepExtend({}, item)); } else if (isArray(item)) { dst[prop].push(deepExtend([], item)); } else { dst[prop].push(source[prop][i]); } } continue; } dst[prop] = source[prop]; } return dst; } function isEmpty(obj) { var name; for (name in obj) {return false;} return true; } function inherit(parent, extra) { return extend(new (extend(function() {}, {prototype: parent}))(), extra); } function hyphenate(str) { var toLower = function($1) { return '-' + $1.toLowerCase(); }; return str.replace(/([A-Z])/g, toLower).replace(/^-+/, ''); } function serialize(obj, prefix) { var str = []; var enc = encodeURIComponent; var k, v; for (var p in obj) { k = prefix ? prefix + '[' + p + ']' : p; v = obj[p]; str.push(isObject(v) ? serialize(v, k) : enc(k) + '=' + enc(v)); } return str.join('&'); } /** * @ngdoc overview * @name ur.model * @requires angular * @requires window * @description * Simple HATEOS-oriented persistence module for AngularJS. * * Angular Model is a module that provides a simple way to bind client-side domain logic to JSON-based API resources * * By sticking to hypermedia design principles, Angular Model allows you to implement client applications that are * cleanly decoupled from your server architecture. * * angular-model allows you to perform CRUD against an API in a manner similar to Active Record. * * In your AngularJS application, include the JavaScript: ```html // your specific paths may vary <script src="node_modules/radify/angular-model.js"></script> ``` * * In your app configuration, state a dependency on Angular Model: ```javascript angular.module('myApp', [ 'ur.model' ]); ``` */ angular.module('ur.model', []).provider('model', function() { var expr, // Used to evaluate expressions; initialized by the service invokation http, // Provider-level copy of $http service q; // Provider-level copy of $q service // 'Box' an object value in a model instance if it is present. // Otherwise, return an empty model object. function autoBox(object, model, data) { if (!object) { return isArray(data) ? model.collection(data, true) : model.instance(data); } if (isArray(data) && isArray(object)) { return model.collection(data, true); } if (data && JSON.stringify(data).length > 3) { deepExtend(object, data); object.$original.sync(data); } return object; } // Global registry of settings var global = { // Base URL prepended to generated URLs base: '', // Extract URL from object, or use default URL url: function(object) { if (object instanceof ModelClass) { return object.url(); } if (object instanceof ModelInstance || isFunc(object.$model)) { var model = object.$model(); return expr(object, model.$config().identity + '.href').get() || model.url(); } throw new Error('Could not get URL for ' + typeof object); }, }; // Master registry of application model configurations var registry = {}; // Default configuration for new model classes var DEFAULTS = { // Default values which should be applied when creating a new model instance defaults: {}, // The name of the key that identifies the object identity: '$links.self', // The name of the key to assign an object map of errors errors: '$errors' }; var DEFAULT_METHODS = { /** * @ngdoc object * @name ur.model:$class * @description * Methods available on the model class * * Analogous to static methods in the OOP world * * You can specify custom class methods: * yourApp.config(function(modelProvider) { modelProvider.model('posts', { $class: { types: function() { return ['announcement', 'article'] } } }); }); */ $class: { /** * @ngdoc function * @name all * @methodOf ur.model:$class * @param {object=} data Configuration of the request that will be sent to your API * @param {object=} headers Map of custom headers to send to your API * * @description * Retrieve collection of post instances from the API * @example ``` model('posts').all().then(function(posts) { console.log(posts.length); }); => 4 ``` * @returns {object} Promise from an API request */ all: function(data, headers) { return $request(null, this, 'GET', data, headers); }, /** * @ngdoc function * @name first * @methodOf ur.model:$class * @param {object=} data Configuration of the request that will be sent to your API * * @description * Retrieve a single post instances from the API * @example ``` model('posts').first({name: 'some post'}).then(function(post) { console.log(post._id); }); => 42 ``` * @returns {object} Promise from an API request */ first: function(data) { return this.all(data).then(function(response) { return angular.isArray(response) ? response[0] : response; }, function() { return null; }); }, /** * @ngdoc function * @name create * @methodOf ur.model:$class * @param {object=} data Configuration of the instance that you are creating. Merged with any defaults * specified when this model was declared. * * @description * Creates an instance on of the model * * @example ``` var post = model('Posts').create({}); ``` * @returns {object} angular-model instance */ create: function(data) { return this.instance(deepExtend(copy(this.$config().defaults), data || {})); }, }, /** * @ngdoc object * @name ur.model:$instance * @description * Methods available on model instances * * You can use these when you have created or loaded a model instance, see the example * var post = model('posts').first({_id: 42}); console.log(post.name); => "Post with ID 42" post.name = 'renamed'; post.$save(); * * You can specify custom instance methods: * yourApp.config(function(modelProvider) { modelProvider.model('posts', { $instance: { $logo: function() { return this.logo || '/logos/default.png';. } } }); }); */ $instance: { /** * @ngdoc function * @name $save * @methodOf ur.model:$instance * @description * Persist an instance to the API * @example ``` var post = model('posts').create({ name: 'some post' }); post.$save(); ``` * @param {object=} data Data to save to this model instance. Defaults to the result of `this.$modified()` * @returns {object} Promise from an API request */ $save: function(data) { var method, requestData; if (this.$exists()) { method = 'PATCH'; requestData = data ? copy(data) : this.$modified(); } else { method = 'POST'; requestData = deepExtend(this, data ? copy(data) : {}); } if (equals({}, requestData)) { return q.when(this); } return $request(this, this.$model(), method, requestData); }, /** * @ngdoc function * @name $delete * @methodOf ur.model:$instance * @description * Delete an instance from the API * @example ``` post.$delete(); ``` * @returns {object} Promise from an API request */ $delete: function() { return $request(this, this.$model(), 'DELETE'); }, /** * @ngdoc function * @name $reload * @methodOf ur.model:$instance * @description * Refresh an instance of a model from the API * @example ``` post.$reload(); ``` * @returns {object} Promise from an API request */ $reload: function() { return $request(this, this.$model(), 'GET'); }, /** * @ngdoc function * @name $revert * @methodOf ur.model:$instance * @description * Reset the model to the state it was originally in when you first got it from the API * @example ``` post.$revert(); ``` */ $revert: function() { var original = copy(this.$original()); for (var prop in this) { if (isFunc(this[prop])) { continue; } this[prop] = original[prop]; } }, /** * @ngdoc function * @name $exists * @methodOf ur.model:$instance * @description * Checks whether an object exists in the API, based on whether it has an identity URL. * @example ``` if (post.$exists()) { console.log('It exists'); } ``` * @returns {boolean} True if the identifier of this instance exists in the API */ $exists: function() { return !!expr(this, this.$model().$config().identity).get(); }, /** * @ngdoc function * @name $dirty * @methodOf ur.model:$instance * @description * Returns boolean - true if a model instance is unmodified, else false. Inverse of $pristine. * @example ``` if (post.$pristine()) { console.log('It is just as it was when we got it from the API'); } ``` * @returns {boolean} true if a model instance is modified, else false. Inverse of $pristine. */ $dirty: function() { return !this.$pristine(); }, /** * @ngdoc function * @name $pristine * @methodOf ur.model:$instance * @description * Returns boolean - false if a model instance is unmodified, else true. Inverse of $dirty. * @example ``` if (post.$dirty()) { console.log('Post has been modified'); } ``` * @returns {boolean} true if a model instance is unmodified, else false. Inverse of $dirty. */ $pristine: function() { return equals(this, this.$original()); }, /** * @ngdoc function * @name $modified * @methodOf ur.model:$instance * @description * Returns a map of the properties that have been changed * @example ``` console.log(post.$modified()); ``` * @returns {object} Map of the fields that have been changed from the $pristine version */ $modified: function() { var original = this.$original(), diff = {}; for (var prop in this) { if (isFunc(this[prop])) { continue; } if (!equals(this[prop], original[prop])) { diff[prop] = this[prop]; } } return diff; }, /** * @ngdoc function * @name $related * @methodOf ur.model:$instance * @description * Hydrates the $links property of the instance. $links are used so that an instance * can tell the client which objects are related to it. For example, a `post` may have an * `author` object related to it. * @example ``` console.log(post.links()); ``` * @returns {object} Promise from the API */ $related: function(name) { var link, model, instance; if (!this.$hasRelated(name)) { throw new Error('Relation `' + name + '` does not exist.'); } link = this.$links[name]; model = registry[link.name]; if (!model) { throw new Error('Relation `' + name + '` with model `' + link.name + '` is not defined.'); } instance = model.create(); expr(instance, model.$config().identity).set(link); return instance.$reload(); }, /** * @ngdoc function * @name $hasRelated * @methodOf ur.model:$instance * @param {string} name Name of the related property to check for * @description * Does an instance have a relation of name `name`? * @example ``` if (post.$hasRelated('author')) { console.log('Post has an author'); } ``` * @returns {boolean} true if a $link to `name` exists on this instance */ $hasRelated: function(name) { return isObject(this.$links[name]); } }, /** * @ngdoc object * @name ur.model:$collection * @description * Methods available on model collections * * You can use collection methods to deal with a bunch of instances together. This allows you to have powerful * and expressive methods on collections. * * You can specify custom collection methods: * yourApp.config(function(modelProvider) { modelProvider.model('posts', { $collection: { $hasArchived: function() { return !angular.isUndefined(_.find(this, { archived: true })); } }, }); }); */ $collection: { /** * @ngdoc function * @name add * @methodOf ur.model:$collection * @param {object} object Object to persist data onto * @param {object=} data Data to persist onto the object * @description * Saves the `object` with `data` * @returns {boolean} true if a $link to `name` exists on this instance */ add: function(object, data) { return object.$save(data || {}); }, /** * @ngdoc function * @name remove * @methodOf ur.model:$collection * @param {(number|object)} index Either the index of the item in the collection to remove, or the object * itself, which will be searched for in the collection * @description * Find `index` and delete it from the API, then remove it from the collection * @returns {object} Promise from the API */ remove: function(index) { index = (typeof index !== 'number') ? index = this.indexOf(index) : index; var self = this, result = self[index].$delete(); result.then(function() { self.splice(index, 1); }); return result; } } }; function $request(object, model, method, data, headers) { var writeMethods = ['POST', 'PUT', 'PATCH']; var defaultHeaders = {'Content-Type': 'application/json;charset=UTF-8'}; var isWrite = writeMethods.indexOf(method) > -1; headers = extend({}, isWrite ? defaultHeaders : {}, headers); var deferred = q.defer(), params = { method: method, url: global.url(object || model), data: data, headers: headers }; if (!isWrite && isObject(data) && !isEmpty(data)) { params.url += (params.url.indexOf('?') > -1 ? '&' : '?') + serialize(data); } return extend(deferred.promise, { $response: null, $request: http(params).then(function(response) { deferred.promise.$response = response; deferred.resolve(autoBox(object, model, response.data)); }, function(response) { if (model.$config().errors && response.data) { expr(object, model.$config().errors).set(response.data); } deferred.promise.$response = response; deferred.reject(response); }) }); } // Configures a new model, updates an existing model's settings, or updates global settings function config(name, options) { if (isObject(name)) { extend(global, name); return; } var previous = (registry[name] && registry[name].$config) ? registry[name].$config() : null, base = extend({}, previous ? extend({}, previous) : extend({}, DEFAULTS, DEFAULT_METHODS)); options = deepExtend(copy(base), options); if (!options.url) { options.url = global.base.replace(/\/$/, '') + '/' + hyphenate(name); } registry[name] = new ModelClass(options); } extend(this, { /** * @ngdoc function * @name ur.model:model * @param {string} name Name of the 'class', e.g. 'posts' * @param {object=} options Config to initialise the model 'class' with. You can supply an object literal to * configure your model here. * @description * Main factory function for angular-model * * @example ``` yourApp.config(function(modelProvider) { modelProvider.model('posts', { // configuration options $instance: { // custom instance functions }, $class: { // custom class functions }, $collection: { // custom collection functions } }); }); ``` * @returns {ur.model} instance of angular-model for the 'class' identified by 'name' */ model: function(name, options) { config(name, options); return this; }, /** * @ngdoc function * @name ur.model:$get * @description * Get the model class factory * * @param {object} $http https://docs.angularjs.org/api/ng/service/$http * @param {object} $parse https://docs.angularjs.org/api/ng/service/$parse * @param {object} $q https://docs.angularjs.org/api/ng/service/$q * @return {object} The model service */ $get: ['$http', '$parse', '$q', function($http, $parse, $q) { q = $q; http = $http; // Extracts a value from an object based on a string expression expr = function(obj, path) { var parsed = $parse(path); return { get: function() { return parsed(obj); }, set: function(value) { return parsed.assign(obj || {}, value); } }; }; // Adds, gets, or updates a named model configuration function ModelClassFactory(name, options) { if (!isUndef(options)) { return config(name, options); } return registry[name] || undefined; } ModelClassFactory.load = function(dst, promises) { forEach(promises, function(promise, name) { promise.then(function(value) { dst[name] = value; }); }); return q.all(promises); }; return ModelClassFactory; }] }); function ModelClass(options) { var scopedClassMethods = { $config: function() { return extend({}, options); }, url: function() { return options.url; }, instance: function(data) { options.$instance = inherit(new ModelInstance(this, copy(data) || {}), options.$instance); return inherit(options.$instance, data || {}); }, collection: function(data, boxElements) { var owner = this, collection = extend([], extend({ $model: function() { return owner; } }, options.$collection)); if (data && data.length) { for (var i = data.length - 1; i >= 0; i--) { collection.unshift(boxElements ? this.instance(data[i]) : data[i]); } } return collection; } }; extend(this, scopedClassMethods, options.$class); } function ModelInstance(owner, original) { var self = this; this.$model = function() { return owner; }; this.$original = function() { return original; }; this.$original.sync = function(data) { original = deepExtend(original, data); }; } }) /** * @ngdoc directive * @name ur.model.directive:link * @element link * @restrict 'E' * @param {string} rel Must be equal to "resource". * @param {string} name The name of the angular-model "class" to use. * @param {string} href Where should angular-model look for the API for this resource. * @description * angular-model will scan your page looking for `<link rel="resources">` tags. It will use these * to work out where your API endpoints are for your angular-model classes. * * So, if you have a "class" Posts, you would define a link with an href pointing to the API endpoint * for Posts. This should be a HATEOS-compliant API endpoint. * * @requires ur.model * * @example ```html <html ng-app="myApp"> <head> <title>My Posts Application</title> <link rel="resource" name="Posts" href="/api/posts"> ``` */ .directive('link', ['model', function(model) { return { restrict: 'E', link: function(scope, element, attrs) { if (attrs.rel !== 'resource' || !attrs.href || !attrs.name) { return; } model(attrs.name, {url: attrs.href, singleton: attrs.singleton ? true : false}); } }; }]); })(window, window.angular);