VasilioRuzanni / angular-modelizer

Simple and lightweight yet feature-rich models to use with AngularJS apps.
MIT License
26 stars 4 forks source link

AngularJS Modelizer

Simple and lightweight yet feature-rich models to use with AngularJS apps. Started as a loose port of Backbone models, inspired by Restangular and Ember.Data but with AngularJS in mind and with different features. Follows the "Convention over Configuration" principle and significantly reduces the amount of boilerplate code necessary for trivial actions.



Getting started

Dependencies

Modelizer depends on angular and lodash (since version 0.2.0 Modelizer doesn't depend on lodash anymore). Supported Angular versions are 1.2.X and 1.3.X.

Note: Angular 1.0.X and 1.1.X might just be supported as well because Modelizer does not really rely on too many Angular components, only $q and $http services.

Installation

With bower:

$ bower install angular-modelizer --save

Attach to your app (with dependencies):

<!-- index.html -->
<script src="https://github.com/VasilioRuzanni/angular-modelizer/raw/master/angular.js"></script>
<script src="https://github.com/VasilioRuzanni/angular-modelizer/raw/master/lodash.js"></script>
<script src="https://github.com/VasilioRuzanni/angular-modelizer/raw/master/angular-modelizer.js"></script>
// app.js
angular.module('app', ['angular-modelizer']);

Define model

This step is completely optional unless some rich model or collection attributes are needed

angular.module('app')

  .run(['modelize', function (modelize) {
    // Note: `modelize.defineModel(...)` is an alias for `modelize.Model.extend(...)`

    modelize.defineModel('post', {
      baseUrl: '/posts',

      // `id` is assumed to be created by the server
      name: '',
      title: 'New post', // `New post` becomes the default value
      publishedOn: modelize.attr.date(),
      author: modelize.attr.model({ modelClass: 'user' }),
      comments: modelize.attr.collection({ modelClass: 'comment' })
    });
  }])  

  // Expose as an Angular service for convenience
  .factory('Post', ['modelize', function (modelize) {
    return modelize('post').$modelClass;
  }]);

}]);

Use your model in a controller

angular.module('app').controller('PostListCtrl', ['$scope', 'Post', function ($scope, Post) {

  $scope.posts = Post.$newCollection();
  $scope.posts.fetch();

  // OR

  Post.all().then(function (posts) {
    $scope.posts = posts;
  });

  // OR

  $scope.posts = Post.all().$future;

}]);

OR

angular.module('app').controller('PostCtrl', ['$scope', 'modelize', function ($scope, modelize) {

  // modelizer automatically resolves the class by model name,
  // plural model name, or baseUrl. If no model "classes" are
  // found during resolution, it falls back to default `Model`
  $scope.posts = modelize('posts').$newCollection();
  $scope.posts.fetch();

  // OR

  $scope.posts = modelize('posts').all().$future;

}]);

See Guide and API Reference for more detail.

Features

Possible future features:

Guide

Modelizer is a very simple thing. It provides the default Model class which is sufficient for many use cases unless some attribute should be a model or a collection of particular model class on its own.

Defining/extending models

Code speaks louder than plain English does:

// app/models/post.js
angular.module('app')

  .run(['modelize', function (modelize) {
    // Note: `modelize.defineModel(...)` is an alias for `modelize.Model.extend(...)`

    return modelize.defineModel('post', {
      baseUrl: '/posts',

      // `id` is assumed to be created by the server
      name: '',
      title: 'New post', // `New post` becomes the default value
      publishedOn: modelize.attr.date(), // Make sure `publishedOn` is always a Date object
      author: modelize.attr.model({ modelClass: 'user' }), // Define nested model
      comments: modelize.attr.collection({ modelClass: 'comment' }) // Define collection property
    });
  }])

  .factory('Post', ['modelize', function (modelize) {
    return modelize('post').$modelClass;
  }]);

// app/models/comment.js
angular.module('app')

 .run(['modelize', function (modelize) {
    modelize.defineModel('comment', {
      baseUrl: '/comments',

      // `id` is assumed to be created by the server
      text: '',
      author: modelize.attr.model({ modelClass: 'user' })
    });
  }])

 .factory('Comment', ['modelize', function (modelize) {
    return modelize('comment').$modelClass;
  }]);

Model static methods

To define custom static methods for model (to use alongside default all(), query(), get(), etc) you could:

// When defining the custom model itself
modelize.defineModel('post', {
  title: '',
  ...

  static: {
    getRecent: function () {
      return this.$request.get(this.baseUrl + '/recent');
    }
  }
});

// Using pure JavaScript
Post.getRecent = function () {
  return this.$request.get(this.baseUrl + '/recent');
};

Extending collections

The collection in Modelizer is just a regular JavaScript Array with some methods, properties and "state" mixed in for convenience. Aside from default set of methods and properties, you could add your own.

This is done using either of two main ways:

1. Extend collection when defining model:

modelize.defineModel('post', {
  title: '',
  ...

  collection: {
    someCollectionMethod: function () {
      // ...
    }
  }
});

2. Extend collection afterwards.

Useful if you want to extend the arbitrary model class including default Model. Please notice that extendCollection method modifies the model class it is being called on by extending its internal metadata.

var Post = modelize.defineModel('post', {
  title: '',
  ...
});

Post.extendCollection({
  someCollectionMethod: function () {
    // ...
  }
});

Note: extendCollection returns the model class it has been called on.

Model names and modelClassCache

modelClassCache is where model classes are stored internally for fast lookups by:

This is needed for correct model class resolution.

Model class appears on the modelClassCache every time new model is defined with modelize.defineModel(...), modelize.Model.extend(...) or SomeModelClass.extend(...).

All of above-mentioned methods accept model name as the first parameter. This is the name under which the model class appears in modelClassCache:

modelize.defineModel('post', { ... });

But what about collection name?

Well, by default the collection name is generated internally based on the specified model name:

// collection name is 'posts'
modelize.defineModel('post', { ... });

// collection name is 'comments'
modelize.defineModel('comment', { ... });

// collection name is 'princesses'
// Note: ends with 's', so 'es' is appended
modelize.defineModel('princess', { ... });

But you could specify that explicitly too by providing array of Strings as the first argument to Model.extend(...):

// collection name is 'companys' which is hardly desired
modelize.defineModel('company', { ... });

// collection name is 'companies'
modelize.defineModel(['company', 'companies'], { ... });

// collection name is 'colossi'
modelize.defineModel(['colossus', 'colossi'], { ... });

Model baseUrl and urlPrefix

You could set the baseUrl when defining model:

// all instances of `post` model will have '/posts' base URL unless
// explicitly overridden for particular instance
var Post = modelize.defineModel('post', {
  baseUrl: '/posts'
});

Or when creating the new model instance:

// post1 will have '/posts' baseUrl
var post1 = Post.$new();

// post2 will have '/some/special/posts' baseUrl
var post2 = Post.$new({ baseUrl: '/some/special/posts' });

If you don't provide the baseUrl on either model definition or new instance creation, it will be automatically generated based on collectionName:

var Post = modelize.defineModel('post', {
  // No baseUrl is set here
});

// post will have '/posts' baseUrl
var post = Post.$new();

var Mouse = modelize.defineModel(['mouse', 'mice'], {
  // No baseUrl is set here
});

// mouse will have '/mice' baseUrl
var mouse = Mouse.$new();

Note: baseUrl is a special property and is excluded from model upon serialization.

urlPrefix is a simple property whose value will be prepended to baseUrl when resolving model instance resource URL.

Why is that given we already have baseUrl? Well, this way we allow different urlPrefixes in different contexts while allow models to handle baseUrl on their own. This is heavily used for dynamic URL building on model class resolution.

Model class resolution

This is the sweet thing in Modelizer. Basically, in order to work with models, you have to get the "model class" somehow first to create model or collection instances, or use convenience static methods like all() or get().

You could just get the model class directly as a value returned from modelize.defineModel(...):

// app/models/post.js
angular.module('app').factory('Post', ['modelize', function (modelize) {

  // Just make the `Post` angular service return the Post model class
  return modelize.defineModel('post', {
    // No baseUrl is set here
  });

}]);

// app/controllers/post-list-ctrl.js
angular.module('app').controller('PostListCtrl', ['Post', function (Post) {

  // Init new collection of `Post` models
  $scope.posts = Post.$newCollection();

  // Request all posts using static method on model class
  Post.all().then(function (posts) {
    $scope.posts = posts;
  });

}]);

Note: You don't need to know all the internal detail in order to use the Modelizer API. But if you're curious - make sure to read carefully since the stuff below might be a bit complicated to get right away.

There is also another way to obtain the model class and this is where the Modelizer object comes into play.

The core API is:

Some points to note:

The model class resolution strategy relies on the fact that resourceName argument can be either:

Basic examples (see next sections for more):

// Lets define some models first:
// =============================

modelize.defineModel('post', {
  baseUrl: '/blog/posts',
  comments: modelize.attr.collection({
    modelClass: 'comment',
    baseUrl: '/special-comments'
  })
});

modelize.defineModel('comment', {
  baseUrl: '/comments',
  ...
});

// Then use that (e.g., in a controller):
// =====================================

// Resolves to 'post' model (by collection name)
// and exposes the 'collection modelizer'
modelize('posts');

// Resolves to 'post' model (by model name)
// and exposes the 'model modelizer'
modelize.one('post', 123);

// Resolves to 'post' model (by collection name)
// and exposes the 'model modelizer'
modelize.one('posts', 123);

// Resolves to 'comment' model (by collection name)
// and exposes the 'collection modelizer'
// The baseUrl is set to '/comments' (taken from 'comment' model class)
modelize('comments');

// Resolves to 'comment' model (by 'post' model attrubute name)
// and exposes the 'collection modelizer'
// The urlPrefix is set to '/blog/posts'
// The baseUrl is set to '/special-comments'
// (taken from 'post' attribute definition metadata)
modelize.one('posts', 123).many('comments');

// No custom model is found, resolves to default Model class
// baseUrl is set to '/things'
modelize('things');

// No custom model is found, resolves to default Model class
// baseUrl is set to '/things/123/subthings'
modelize('things/123/subthings');

// No custom model is found, resolves to default Model class
// baseUrl is set to '/subthings'
// urlPrefix is set to '/things/123'
modelize.one('things', 123).many('subthings');

// No custom model is found (because Post baseUrl is /blog/posts in our case),
// resolves to default Model class instead. Only searched by baseUrl since provided
// `resourceName` is immediately considered URL because of '/'.
// baseUrl is set to '/posts'
modelize('/posts');

Refer to the next sections on detail about how to work with these model classes and model data.

Initializing models

You can easily create new model and collection instances:

// Using model "class":
// ===================

// Empty collection
var posts = Post.$newCollection();

// Collection With data
var posts = Post.$newCollection([{
  title: 'Post 1',
  ...
}, {
  title: 'Post 2',
  ...
}]);

// Empty model
var post = Post.$new();
var post = new Post();

// Model with some attributes set
var post = Post.$new({ title: 'Post 1' });
var post = new Post({ title: 'Post 1' });

// Same thing using `Modelizer`:
// ============================

var posts = modelize('posts').$newCollection();
// OR
var posts = modelize('posts').$newCollection([{ ... }, { ... }, { ... }]);

var post = modelize('posts').$new();
// OR
var post = modelize.one('post').$new();
var post = modelize.one('posts').$new();
var post = modelize.one('posts').$new({ ... });

Fetching model data

You can easily fetch the model data by calling fetch() on model instance. Note that fetch() method accepts url option to fetch from arbitrary URL. options are also passed to the underlying $http service calls so feel free to provide any params or headers there.

// Note: all methods that work with HTTP return promises

// Fetch collection
// ================

// Create empty collection
var posts = Post.$newCollection();
// OR
var posts = modelize('posts').$newCollection();

// Then fetch the entire collection
// GET /posts
posts.fetch(); // returns promise

// Fetch single item
// =================

var post = modelize('posts').$new({ id: 123 });

// GET /posts/123
post.fetch();

// Get first item from already "fetched" collection
// Note that item already has the 'id' attribute set
var post = posts[0];

// GET /posts/123
post.fetch(); // Sync, returns promise

// GET /some/custom/url-to-fetch
post.fetch({ url: '/some/cusom/url-to-fetch' }); // returns promise

// GET /posts/123/comments
post.comments.fetch(); // Property defined with 'modelize.attr.collection()'

Using model static methods to query remote data

Model class exposes a set of concenience methods to make remote requests that return promises resolved with either model or collection instance (depending on method). As usual, methods can be invoked on model class directly or via modelizer.

// Get a single item
// =================

// GET /posts/123
modelize('posts').get(123).then(function (post) {
  // Note: Use promise callbacks when you have
  // something to do after request has completed.
  // In simple cases just use `$future` property
  // of modelizer promises.
  $scope.post = post;
});
// OR
var post = Post.get(123).$future;
var post = modelize('posts').get(123).$future;
var post = modelize.one('post', 123).get().$future;

// Get collection of items
// =======================

// GET /posts
var posts = modelize('posts').all().$future;
// OR
var posts = Post.all().$future;

// GET /posts?published=false
var posts = modelize('posts').query({ published: false }).$future;

Saving models

Once you have changed the model data, you might need its new state to be posted back to the API server. This is what model save() method for. Depending on current model state and data, we can either create, update or partially update the model data on the server.

There is also a save() method on Modelizer that accepts the data to send to the server. This method basically initializes the new model with provided data and calls save() on it. So, all the same rules (from above) apply here.

// Using `save()` method on Modelizer
// =================================

// POST /posts
modelize('posts').save({ title: 'Some post title' });

// PUT /posts/123
modelize('posts').save({ id: 123, title: 'Some post title' });

// PATCH /posts/123
modelize('posts').save({ id: 123, title: 'Some post title' }, { patch: true });

// Using `model.save()` method
// =========================

// Create an item
var post = modelize('posts').$new({ title: 'Some new title' });
// Does not cause an update since
// we've never saved the model before
post.title = 'Some other title';

// POST /posts
post.save(); // returns promise

// There is also a convenience `create()` method on collection:
var posts = modelize('posts').all().$future;
...
// POST /posts
posts.create({ title: 'Some new post' }); // returns promise

// Note: Changing properties does not cause an update since
// we've never saved the model before.
var post = modelize('posts').$new({ title: 'Some new title' });
post.title = 'Some other title';

// POST /posts
post.save(); // returns promise

// Update an item
var post = modelize('posts').get(123).$future;
...
post.title = 'Another title';

// PUT /posts/123
post.save();

// Partially update an item
var post = modelize('posts').get(123).$future;
...
post.title = 'Another title';

// PATCH /posts/123
post.save({ patch: true });

Deleting models

Deleting models is extraordinary simple. Again, both model instance and Modelizer have destroy() methods for this.

// Using `destroy()` method on Modelizer
// =================================

// DELETE /posts/123
modelize('posts').destroy(123);

// Using `model.destroy()` method
// =========================

// No request is done in this case since we never saved the model
var post = modelize('posts').$new({ title: 'Some new title' });
post.destroy(); // returns promise

// Now get the post for below examples
var post = modelize('posts').get(123);

// Deleting post, immediately remove from collections
// DELETE /posts/123
post.destroy(); // returns promise

// Deleting post, but wait for the successful response before
// removing from collections
// DELETE /posts/123
post.destroy({ wait: true }); // returns promise

// Deleting post, but keep the model in collections.
// Use with caution! The collection is in inconsistent state now.
// DELETE /posts/123
post.destroy({ keepInCollections: true }); // returns promise

// In any case, post obtains the $destroyed property
post.$destroyed; // true

Serialization notes

Model has the following methods to assist serialization:

The getAttributes() method is used to only get "serializable" attributes of a model. Note that reserved properties are excluded from that set of attributes. Specify includeComputed: true option to have computed properties added to resulting object.

The current list of reserved properties is the following:

// The list of reserved internal properties
// that are "non-attributes" and should be excluded
// from model when getting its attributes (workaround
// to allow model attributes on a model directly
// side by side with system/internal properties).
var _reservedProperties = [
  '$$hashKey',
  '$iid',
  '_modelClassMeta',
  '_collections',
  '_remoteState',
  '_loadingTracker',
  '_initOptions',
  'idAttribute',
  'baseUrl',
  'urlPrefix',
  '$modelErrors',
  '$error',
  '$valid',
  '$invalid',
  '$loading',
  '$selected',
  '$destroyed'
];

There is also a getChangedAttributes() method that only returns attributes that are changed since the last known server state. Used to perform PATCH operations. Uses diff method internally (see next section).

The serialize() method on a model simply calls getAttributes() and then handles the special cases when some model attribute is a model or collection on its own (and calls serialize() on them).

The serialize() method on a collection only runs serialize() on each model from that collection and returns the array of serialized models.

The toJSON() method only transforms the already serialize()d models to actual JSON string.

Model diffing

The model saves its last known server state internally and when attributes change, it knows how to perform the correct PATCH. How is that? Well, thats what diff for.

Model performs diff between current and last known server state in the changedAttributes() method.

diff can be performed against any other object though. It doesn't compare models by reference and instead compares attribute values one by one. So, any object can be used to diff model against. The final diff is a hash of differences only keyed with attribute name and contains current and compared value:

var post = modelize('post').$new({
  title: 'Some post title',
  text: 'Some text'
});

var diff = post.diff({ title: 'Some other title', text: 'Some text' });
// `diff` is now as the following:
// {
//   title: {
//     currentValue: 'Some post title',
//     comparedValue: 'Some other title'
//   }
// }

Using $request for arbitrary HTTP requests

You can perform arbitrary HTTP requests by using the same convenience wrapper around Angular $http service - modelizer $request. The only thing it does is makes successful requests promises being resolved with data only and not entire response (pass rawData: true option to obtain the entire response as $http does by default). It also adds convenience $future object to promises (always contains data even if fullResponse: true option is passed.

For convenience, the $request is set as a property of:

All the options of $request methods are passed to corresponding $http methods so params, data, method, headers, etc are all acceptable.

// Make arbitrary HTTP requests

// posts isn't a model or collection, just a regular object here
var posts = modelize.$request.get('/blog/posts').$future;

// Note: We don't work with Models here at all, just raw requests
// Create an item
modelize.$request.post('/blog/posts', {
  title: 'Some post title',
  text: 'Some post text'
});

// Update an item
modelize.$request.put('/blog/posts/123', {
  title: 'Some updated post title',
  text: 'Some updated post text'
});

// Perform a partial update
modelize.$request.patch('/blog/posts/123', {
  title: 'Some updated post title'
});

// Useful to define custom model and collection methods
// that need to perform some HTTP calls.
modelize.defineModel('post', {
  title: '',
  text: '',

  someCustomAction: function () {
    return this.$request.post(this.resourceUrl() + '/some-custom-action',
      this.getAttributes({ includeComputed: true }));
  },

  static: {
    getRecent: function () {
      // `this` is now model class, it has $request property too
      return this.$request.get(this.baseUrl + '/recent');
    }
  },

  collection: {
    someCollectionMethod: function () {
      // collections have $request property as well
      return this.$request.get(...);
    }
  }
})

Tracking model $loading state

One of pretty common use cases is to show some kind of loading indicator (aka "spinner") while HTTP request associated with model is being performed.

This is what model or collection $loading property for. It always returns true when there is active request for a model or collection (either to fetch data, update or destroy the model). The "loading" state tracker is attached to the model as the _loadingTracker property. It supports the addPromise() method so feel free to add new promises which will cause the model to be $loading.

This property is one of the reserved properties so its not included when model is serialized.

Assume we have model/collection on the controller $scope:

// post-list-ctrl.js
angular.module('app').controller('PostListCtrl', ['$scope', 'modelize',
  function ($scope, modelize) {

    $scope.posts = modelize('posts').all().$future;

  }]);

Show spinners while the entire collection or a single model is loading:

<div class="list-loading-spinner" ng-show="posts.$loading">
  The list of posts is loading...
</div>
<div ng-repeat="post in posts">
  <div class="single-post-loading-spinner" ng-show="post.$loading">
    Post is loading...
  </div>
  <h1>{{ post.title }}</h2>
  <div>
    ...
  </div>
</div>

Parsing validation errors

When model save fails, the promise save() method returns is rejected with error server responds with. The model.save() method handles that error as well by parsing the validation error and maintains the $modelErrors object with:

To parse the error response, model has the parseModelErrors() method which by default uses the configurable global parseModelErrors(). Note that global parseModelErrors() only handles server errors with status code 422 Unprocessable entity

So, to change the error parsing logic, you could:

The default server response format is expected to have these fields:

{
  "fieldErrors": [{
    "field": "name",
    "message": "Your name field is invalid"
  }, {
    "field": "description",
    "message": "Your description field is invalid"
  }, {
    "field": "description",
    "message": "Yet another problem with your description"
  }]
}

Model maintains its $modelErrors property in the following form:

{
  name: ['Some name error'],
  description: ['Some descriotion error', 'Another description error']
}

Override easily:


// Override for marticular model class
modelize.defineModel('thing', {
  name: '',
  description: '',

  parseModelErrors: function (responseData, options) {
    // Custom parsing logic
    // ...

    // Result should follow the format: 
    return {
      name: ['Some name error'],
      description: ['Some descriotion error', 'Another description error']
    };
  }
});

// Override global one
angular.module('app').config(['modelizeProvider', function (modelizeProvider) {
  // This will be applied to all model classes
  // unless explicitly overridden for particular model
  modelizeProvider.parseModelErrors = function (responseData, options) {
    // Custom parsing logic
    // ...
  };
});

mzModelError directive

There is a tiny mzModelError directive in Modelizer to help you to show model errors to the user. Requires ng-model attribute set on the same element (will be ignored otherwise). Think of it as of just another validator like 'required' but related to model errors returned from server.

In the example below, the message with modelError validation token will appear for form control when server returns the error for title field on post model. See previous section on how these errors are parsed.

<form name="postForm" novalidate>
  <!-- The model error property will be implied automatically based 
  on ng-model property and the value will be parsed against
  "post.$modelErrors.title" expression -->
  <imput name="title" ng-model="post.title" required minlength="4" mz-model-error>

  <!-- OR: explicitly set the object to take model errors from -->
  <imput name="title" ng-model="post.title" required minlength="4" mz-model-error="post.$modelErrors.title">

  <!-- Show the errors with ngMessages -->
  <div ng-messages="postForm.title.$error">
    <div ng-message="required">The title is required for a blog post</div>
    <div ng-message="minlength">The title should be at least 4 charactes long</div>
    <div ng-message="modelError">
      <div ng-repeat="err in post.$modelErrors.title">{{ err }}</div>
    </div>
</form>

Note: mzModelError can work with any object, not only modelizer models. The error will appear on postForm.title.$error under modelError key.

API reference

Coming soon!

Contributing

If you have found some bug or want to request some new feature, please use GitHub Issues to let us know about that. Pull Requests are welcome too.

License

MIT