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.
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.