GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS

Written by thoughtbot

Better serialization, less as_json

Suppose you have the following in your Rails app:

# app/models/user.rb
class User < ActiveRecord::Base
  has_secure_password
  has_many :posts
end

# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user
end

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    render json: @post.as_json(
      only: [:id, :content, :created_at],
      include: { user: { only: [:id, :username] } }
    )
  end
end

Your show action returns a JSON representation of a post, along with some limited information about the user it belongs to, using as_json. Works like a champ.

However… that nested hash you're passing into as_json is a bit clunky. It'll get clunkier if you add more associations, and you'll have to repeat yourself if you add more actions that render posts (like index).

"A-ha!" you think, "I'll just carefully override as_json in my models to include only the attributes I want by default, and everything will be rainbows and kittens!"

# app/models/user.rb
class User < ActiveRecord::Base
  has_secure_password
  has_many :posts

  def as_json(options = nil)
    super({ only: [:id, :username] }.merge(options || {}))
  end
end

# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user

  def as_json(options = nil)
    super({ only: [:id, :content, :created_at], include: :user }.merge(options || {}))
  end
end

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  respond_to :json

  def show
    respond_with Post.find(params[:id])
  end
end

You fire up a console for a quick sanity check:

> pp Post.first.as_json
{"content"=>"test post, please ignore",
 "created_at"=>Thu, 04 Oct 2012 21:05:50 UTC +00:00,
 "id"=>1,
 :user=>
  {"created_at"=>Thu, 04 Oct 2012 17:07:29 UTC +00:00,
   "id"=>1,
   "password_digest"=>
    "$2a$10$NKW95m6zQnPJiaOXz4u5LeMHKnRmrHjLLCUsTu8yMma/XNmJDi6qy",
   "updated_at"=>Thu, 04 Oct 2012 17:07:29 UTC +00:00,
   "username"=>"tester"}}

Cripes! You just exposed the user's password digest! What's going on here?

A bit of Rails source-diving reveals that the original as_json you're overriding is a thin wrapper around serializable_hash, which does the actual work of serializing attributes and included associations. The latter is done by, of course, calling serializable_hash on the associated models… not as_json.

Armed with this knowledge the quick fix is obvious, and indeed, replacing "def as_json" with "def serializable_hash" in the example above will make it work as intended. But two things about this should make you slightly queasy. For one, your solution is coupled to the implementation details of an internal Rails method that could change in the future (what if serializable_hash itself becomes a wrapper like as_json?). And for two, serialization seems like it should be happening closer to the view layer – that concern doesn't belong in our model classes.

active_model_serializers to the rescue!

If the JSON you're emitting is hairy enough to merit the use of actual templates, you might want something like Jbuilder or RABL. But before diving into that world, consider the underrated active_model_serializers. It's easy to use and doesn't add much complexity over the old-and-busted approach – you just specify the attributes and associations to include in a different way.

Here's our example again, now using active_model_serializers:

# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :id, :username
end

# app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
  has_one :user
  attributes :id, :content, :created_at
end

# app/models/user.rb
class User < ActiveRecord::Base
  has_secure_password
  has_many :posts
end

# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user
end

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  respond_to :json

  def show
    respond_with Post.find(params[:id])
  end
end

The only quirk is the use of has_one in serializers for all singular associations, regardless of whether the real association is has_one or belongs_to.

While your integration tests should hit the route and assert that the JSON response contains the expected set of attributes, let's just revisit our sanity check from earlier:

> pp PostSerializer.new(Post.first).as_json
{:id=>1,
 :content=>"test post, please ignore",
 :created_at=>Thu, 04 Oct 2012 21:05:50 UTC +00:00,
 :user=>{:id=>1, :username=>"tester"}}

Perfect! Now everything is rainbows and kittens… and you didn't have to override as_json.