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.
activemodelserializers 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 activemodelserializers. 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 activemodelserializers:
# 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
.