/active_model_serializers

ActiveModel::Serializer implementation and Rails hooks

Primary LanguageRubyMIT LicenseMIT

Build Status Code Climate Coverage Status

ActiveModel::Serializers

Master - 0.9.0

master is under development, there are some incompatible changes with the current stable release.

If you want to read the stable documentation visit 0.8 README

Purpose

ActiveModel::Serializers encapsulates the JSON serialization of objects. Objects that respond to read_attribute_for_serialization (including ActiveModel and ActiveRecord objects) are supported.

Serializers know about both a model and the current_user, so you can customize serialization based upon whether a user is authorized to see the content.

In short, serializers replace hash-driven development with object-oriented development.

Installing

The easiest way to install ActiveModel::Serializers is to add it to your Gemfile:

gem "active_model_serializers"

Then, install it on the command line:

$ bundle install

Ruby 1.8 is no longer supported!

If you must use a ruby 1.8 version (MRI 1.8.7, REE, Rubinius 1.8, or JRuby 1.8), you need to use version 0.8.x. Versions after 0.9.0 do not support ruby 1.8. To specify version 0.8, include this in your Gemfile:

gem "active_model_serializers", "~> 0.8.0"

Creating a Serializer

The easiest way to create a new serializer is to generate a new resource, which will generate a serializer at the same time:

$ rails g resource post title:string body:string

This will generate a serializer in app/serializers/post_serializer.rb for your new model. You can also generate a serializer for an existing model with the serializer generator:

$ rails g serializer post

Support for POROs

Currently ActiveModel::Serializers expects objects to implement read_attribute_for_serialization. That's all you need to do to have your POROs supported.

ActiveModel::Serializer

All new serializers descend from ActiveModel::Serializer

render :json

In your controllers, when you use render :json, Rails will now first search for a serializer for the object and use it if available.

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    render json: @post
  end
end

In this case, Rails will look for a serializer named PostSerializer, and if it exists, use it to serialize the Post.

This also works with respond_with, which uses to_json under the hood. Also note that any options passed to render :json will be passed to your serializer and available as @options inside.

To specify a custom serializer for an object, you can specify the serializer when you render the object:

render json: @post, serializer: FancyPostSerializer

Arrays

In your controllers, when you use render :json for an array of objects, AMS will use ActiveModel::ArraySerializer (included in this project) as the base serializer, and the individual Serializer for the objects contained in that array.

class PostSerializer < ActiveModel::Serializer
  attributes :title, :body
end

class PostsController < ApplicationController
  def index
    @posts = Post.all
    render json: @posts
  end
end

Given the example above, the index action will return

{
  "posts":
    [
      { "title": "Post 1", "body": "Hello!" },
      { "title": "Post 2", "body": "Goodbye!" }
    ]
}

By default, the root element is the name of the controller. For example, PostsController generates a root element "posts". To change it:

render json: @posts, root: "some_posts"

You may disable the root element for arrays at the top level, which will result in more concise json. See the next section for ways on how to do this. Disabling the root element of the array with any of those methods will produce

[
  { "title": "Post 1", "body": "Hello!" },
  { "title": "Post 2", "body": "Goodbye!" }
]

To specify a custom serializer for the items within an array:

render json: @posts, each_serializer: FancyPostSerializer

Disabling the root element

You have 4 options to disable the root element, each with a slightly different scope:

1. Disable root globally for all, or per class

In an initializer:

# Disable for all serializers (except ArraySerializer)
ActiveModel::Serializer.root = false

# Disable for ArraySerializer
ActiveModel::ArraySerializer.root = false

2. Disable root per render call in your controller

render json: @posts, root: false

3. Subclass the serializer, and specify using it

class CustomArraySerializer < ActiveModel::ArraySerializer
  self.root = false
end

# controller:
render json: @posts, serializer: CustomArraySerializer

4. Define default_serializer_options in your controller

If you define default_serializer_options method in your controller, all serializers in actions of this controller and it's children will use them. One of the options may be root: false

def default_serializer_options
  {
    root: false
  }
end

Getting the old version

If you find that your project is already relying on the old rails to_json change render :json to render json: @your_object.to_json.

Attributes and Associations

Once you have a serializer, you can specify which attributes and associations you would like to include in the serialized form.

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body
  has_many :comments
end

Attributes

For specified attributes, a serializer will look up the attribute on the object you passed to render :json. It uses read_attribute_for_serialization, which ActiveRecord objects implement as a regular attribute lookup.

Before looking up the attribute on the object, a serializer will check for the presence of a method with the name of the attribute. This allows serializers to include properties beyond the simple attributes of the model. For example:

class PersonSerializer < ActiveModel::Serializer
  attributes :first_name, :last_name, :full_name

  def full_name
    "#{object.first_name} #{object.last_name}"
  end
end

Within a serializer's methods, you can access the object being serialized as object.

Since this shadows any attribute named object, you can include them through object.object. For example:

class VersionSerializer < ActiveModel::Serializer
  attribute :version_object, key: :object

  def version_object
    object.object
  end
end

You can also access the scope method, which provides an authorization context to your serializer. By default, the context is the current user of your application, but this can be customized.

Serializers provides a method named filter used to determine what attributes and associations should be included in the output. This is typically used to customize output based on current_user. For example:

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body, :author

  def filter(keys)
    if scope.admin?
      keys
    else
      keys - [:author]
    end
  end
end

And it's also safe to mutate keys argument by doing keys.delete(:author) in case you want to avoid creating two extra arrays.

If you would like the key in the outputted JSON to be different from its name in ActiveRecord, you can declare the attribute with the different name and redefine that method:

class PostSerializer < ActiveModel::Serializer
  # look up subject on the model, but use title in the JSON
  def title
    object.subject
  end

  attributes :id, :body, :title
  has_many :comments
end

If you would like to add meta information to the outputted JSON, use the :meta option:

render json: @posts, serializer: CustomArraySerializer, meta: {total: 10}

The above usage of :meta will produce the following:

{
  "meta": { "total": 10 },
  "posts": [
    { "title": "Post 1", "body": "Hello!" },
    { "title": "Post 2", "body": "Goodbye!" }
  ]
}

If you would like to change the meta key name you can use the :meta_key option:

render json: @posts, serializer: CustomArraySerializer, meta: {total: 10}, meta_key: 'meta_object'

The above usage of :meta_key will produce the following:

{
  "meta_object": { "total": 10 },
  "posts": [
    { "title": "Post 1", "body": "Hello!" },
    { "title": "Post 2", "body": "Goodbye!" }
  ]
}

When using meta information, your serializer cannot have the { root: false } option, as this would lead to invalid JSON. If you do not have a root key, the meta information will be ignored.

If you would like direct, low-level control of attribute serialization, you can completely override the attributes method to return the hash you need:

class PersonSerializer < ActiveModel::Serializer
  attributes :first_name, :last_name

  def attributes
    hash = super
    if scope.admin?
      hash["ssn"] = object.ssn
      hash["secret"] = object.mothers_maiden_name
    end
    hash
  end
end

Associations

For specified associations, the serializer will look up the association and then serialize each element of the association. For instance, a has_many :comments association will create a new CommentSerializer for each comment and use it to serialize the comment.

By default, serializers simply look up the association on the original object. You can customize this behavior by implementing a method with the name of the association and returning a different Array. Often, you will do this to customize the objects returned based on the current user (scope).

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body
  has_many :comments

  # only let the user see comments he created.
  def comments
    object.comments.where(created_by: scope)
  end
end

As with attributes, you can change the JSON key that the serializer should use for a particular association.

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body

  # look up comments, but use +my_comments+ as the key in JSON
  has_many :comments, root: :my_comments
end

Also, as with attributes, serializers will execute a filter method to determine which associations should be included in the output. For example:

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body
  has_many :comments

  def filter(keys)
    keys.delete :comments if object.comments_disabled?
    keys
  end
end

Or ...

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body
  has_one :author
  has_many :comments

  def filter(keys)
    keys.delete :author unless scope.admin?
    keys.delete :comments if object.comments_disabled?
    keys
  end
end

You may also use the :serializer option to specify a custom serializer class and the :polymorphic option to specify an association that is polymorphic (STI), e.g.:

  has_many :comments, serializer: CommentShortSerializer
  has_one :reviewer, polymorphic: true

Serializers are only concerned with multiplicity, and not ownership. belongs_to ActiveRecord associations can be included using has_one in your serializer.

NOTE: polymorphic was removed because was only supported for has_one associations and is in the TODO list of the project.

Embedding Associations

By default, associations will be embedded inside the serialized object. So if you have a post, the outputted JSON will look like:

{
  "post": {
    "id": 1,
    "title": "New post",
    "body": "A body!",
    "comments": [
      { "id": 1, "body": "what a dumb post" }
    ]
  }
}

This is convenient for simple use-cases, but for more complex clients, it is better to supply an Array of IDs for the association. This makes your API more flexible from a performance standpoint and avoids wasteful duplication.

To embed IDs instead of associations, simply use the embed class method:

class PostSerializer < ActiveModel::Serializer
  embed :ids

  attributes :id, :title, :body
  has_many :comments
end

Now, any associations will be supplied as an Array of IDs:

{
  "post": {
    "id": 1,
    "title": "New post",
    "body": "A body!",
    "comment_ids": [ 1, 2, 3 ]
  }
}

Alternatively, you can choose to embed only the ids or the associated objects per association:

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :body

  has_many :comments, embed: :objects
  has_many :tags, embed: :ids
end

The JSON will look like this:

{
  "post": {
    "id": 1,
    "title": "New post",
    "body": "A body!",
    "comments": [
      { "id": 1, "body": "what a dumb post" }
    ],
    "tag_ids": [ 1, 2, 3 ]
  }
}

In addition to supplying an Array of IDs, you may want to side-load the data alongside the main object. This makes it easier to process the entire package of data without having to recursively scan the tree looking for embedded information. It also ensures that associations that are shared between several objects (like tags), are only delivered once for the entire payload.

You can specify that the data be included like this:

class PostSerializer < ActiveModel::Serializer
  embed :ids, include: true

  attributes :id, :title, :body
  has_many :comments
end

Assuming that the comments also has_many :tags, you will get a JSON like this:

{
  "post": {
    "id": 1,
    "title": "New post",
    "body": "A body!",
    "comment_ids": [ 1, 2 ]
  },
  "comments": [
    { "id": 1, "body": "what a dumb post", "tag_ids": [ 1, 2 ] },
    { "id": 2, "body": "i liked it", "tag_ids": [ 1, 3 ] },
  ],
  "tags": [
    { "id": 1, "name": "short" },
    { "id": 2, "name": "whiny" },
    { "id": 3, "name": "happy" }
  ]
}

When side-loading data, your serializer cannot have the { root: false } option, as this would lead to invalid JSON. If you do not have a root key, the include instruction will be ignored

You can also specify a different root for the embedded objects than the key used to reference them:

class PostSerializer < ActiveModel::Serializer
  embed :ids, include: true

  attributes :id, :title, :body
  has_many :comments, key: :comment_ids, root: :comment_objects
end

This would generate JSON that would look like this:

{
  "post": {
    "id": 1,
    "title": "New post",
    "body": "A body!",
    "comment_ids": [ 1 ]
  },
  "comment_objects": [
    { "id": 1, "body": "what a dumb post" }
  ]
}

You can also specify a different attribute to use rather than the ID of the objects:

class PostSerializer < ActiveModel::Serializer
  embed :ids, include: true

  attributes :id, :title, :body
  has_many :comments, embed_key: :external_id
end

This would generate JSON that would look like this:

{
  "post": {
    "id": 1,
    "title": "New post",
    "body": "A body!",
    "comment_ids": [ "COMM001" ]
  },
  "comments": [
    { "id": 1, "external_id": "COMM001", "body": "what a dumb post" }
  ]
}

NOTE: The embed :ids mechanism is primary useful for clients that process data in bulk and load it into a local store. For these clients, the ability to easily see all of the data per type, rather than having to recursively scan the data looking for information, is extremely useful.

If you are mostly working with the data in simple scenarios and manually making Ajax requests, you probably just want to use the default embedded behavior.

Customizing Scope

In a serializer, current_user is the current authorization scope which the controller provides to the serializer when you call render :json. By default, this is current_user, but can be customized in your controller by calling serialization_scope:

class ApplicationController < ActionController::Base
  serialization_scope :current_admin
end

The above example will also change the scope from current_user to current_admin.

Please note that, until now, serialization_scope doesn't accept a second object with options for specifying which actions should or should not take a given scope in consideration.

To be clear, it's not possible, yet, to do something like this:

class SomeController < ApplicationController
  serialization_scope :current_admin, except: [:index, :show]
end

So, in order to have a fine grained control of what each action should take in consideration for its scope, you may use something like this:

class CitiesController < ApplicationController
  serialization_scope nil

  def index
    @cities = City.all

    render json: @cities, each_serializer: CitySerializer
  end

  def show
    @city = City.find(params[:id])

    render json: @city, scope: current_admin
  end
end

Assuming that the current_admin method needs to make a query in the database for the current user, the advantage of this approach is that, by setting serialization_scope to nil, the index action no longer will need to make that query, only the show action will.

Testing

In order to test a Serializer, you can just call .new on it, passing the object to serialize:

MiniTest

class TestPostSerializer < Minitest::Test
  def setup
    @serializer = PostSerializer.new Post.new(id: 123, title: 'some title', body: 'some text')
  end

  def test_special_json_for_api
    assert_equal '{"post":{"id":123,"title":"some title","body":"some text"}}', @serializer.to_json
  end

RSpec

describe PostSerializer do
  it "creates special JSON for the API" do
    serializer = PostSerializer.new Post.new(id: 123, title: 'some title', body: 'some text')
    expect(serializer.to_json).to eql('{"post":{"id":123,"title":"some title","body":"some text"}}')
  end
end

Caching

NOTE: This functionality was removed from AMS and it's in the TODO list. We need to re-think and re-design the caching strategy for the next version of AMS.

To cache a serializer, call cached and define a cache_key method:

class PostSerializer < ActiveModel::Serializer
  cached  # enables caching for this serializer

  attributes :title, :body

  def cache_key
    [object, scope]
  end
end

The caching interface uses Rails.cache under the hood.

Design and Implementation Guidelines

Keep it Simple

ActiveModel::Serializers is capable of producing complex JSON views/large object trees, and it may be tempting to design in this way so that your client can make fewer requests to get data and so that related querying can be optimized. However, keeping things simple in your serializers and controllers may significantly reduce complexity and maintenance over the long-term development of your application. Please consider reducing the complexity of the JSON views you provide via the serializers as you build out your application, so that controllers/services can be more easily reused without a lot of complexity later.

Performance

As you develop your controllers or other code that utilizes serializers, try to avoid n+1 queries by ensuring that data loads in an optimal fashion, e.g. if you are using ActiveRecord, you might want to use query includes or joins as needed to make the data available that the serializer(s) need.