Merge branch 'master' of github.com:josevalim/active_model_serializers

This commit is contained in:
Yehuda Katz 2012-09-02 21:21:05 -07:00
commit 84f8c1c3d5
8 changed files with 736 additions and 31 deletions

View File

@ -39,7 +39,7 @@ $ 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`:
the serializer generator:
```
$ rails g serializer post
@ -66,10 +66,108 @@ 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 `render_with`, which uses `to_json` under the hood. Also
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, there are 2 options:
#### 1. Specify the serializer in your model:
```ruby
class Post < ActiveRecord::Base
def active_model_serializer
FancyPostSerializer
end
end
```
#### 2. Specify the serializer when you render the object:
```ruby
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.
```ruby
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
```json
{
"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:
```ruby
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. To disable the root element for arrays, you have 3 options:
#### 1. Disable root globally for in `ArraySerializer`. In an initializer:
```ruby
ActiveModel::ArraySerializer.root = false
```
#### 2. Disable root per render call in your controller:
```ruby
render :json => @posts, :root => false
```
#### 3. Create a custom `ArraySerializer` and render arrays with it:
```ruby
class CustomArraySerializer < ActiveModel::ArraySerializer
self.root = false
end
# controller:
render :json => @posts, :serializer => CustomArraySerializer
```
Disabling the root element of the array with any of the above 3 methods
will produce
```json
[
{ "title": "Post 1", "body": "Hello!" },
{ "title": "Post 2", "body": "Goodbye!" }
]
```
To specify a custom serializer for the items within an array:
```ruby
render :json => @posts, :each_serializer => FancyPostSerializer
```
## Getting the old version
If you find that your project is already relying on the old rails to_json
@ -107,6 +205,45 @@ class PostSerializer < ActiveModel::Serializer
end
```
## Custom Attributes
If you would like customize your JSON to include things beyond the simple
attributes of the model, you can override its `attributes` method
to return anything you need.
The most common scenario to use this feature is when an attribute
depends on a serialization scope. By default, the current user of your
application will be available in your serializer under the method
`scope`. This allows you to check for permissions before adding
an attribute. For example:
```ruby
class Person < ActiveRecord::Base
def full_name
"#{first_name} #{last_name}"
end
end
class PersonSerializer < ActiveModel::Serializer
attributes :first_name, :last_name
def attributes
hash = super
hash["full_name"] = object.full_name if scope.admin?
hash
end
end
```
The serialization scope can be customized in your controller by
calling `serialization_scope`:
```ruby
class ApplicationController < ActionController::Base
serialization_scope :current_admin
end
```
## Associations
For specified associations, the serializer will look up the association and
@ -126,12 +263,12 @@ class PostSerializer < ActiveModel::Serializer
# only let the user see comments he created.
def comments
post.comments.where(:created_by => options[:scope])
post.comments.where(:created_by => scope)
end
end
```
In a serializer, `options[:scope]` is the current authorization scope (usually
In a serializer, `scope` is the current authorization scope (usually
`current_user`), which the controller gives to the serializer when you call
`render :json`

View File

@ -1,3 +1,14 @@
# VERSION 0.6 (to be released)
* Serialize sets properly
* Add root option to ArraySerializer
* Support polymorphic associations
* Support :each_serializer in ArraySerializer
* Add `scope` method to easily access the scope in the serializer
* Fix regression with Rails 3.2.6
* Allow serialization_scope to be disabled with serialization_scope nil
* Array serializer should support pure ruby objects besides serializers
# VERSION 0.5 (May 16, 2012)
* First tagged version

View File

@ -40,15 +40,19 @@ module ActionController
end
def _render_option_json(json, options)
if json.respond_to?(:to_ary)
options[:root] ||= controller_name unless options[:root] == false
end
serializer = options.delete(:serializer) ||
(json.respond_to?(:active_model_serializer) && json.active_model_serializer)
if json.respond_to?(:to_ary)
if options[:root] != false && serializer.root != false
# default root element for arrays is serializer's root or the controller name
# the serializer for an Array is ActiveModel::ArraySerializer
options[:root] ||= serializer.root || controller_name
end
end
if serializer
options[:scope] = serialization_scope
options[:scope] = serialization_scope unless options.has_key?(:scope)
options[:url_options] = url_options
json = serializer.new(json, options.merge(default_serializer_options || {}))
end

View File

@ -29,18 +29,31 @@ module ActiveModel
# Active Model Array Serializer
#
# It serializes an array checking if each element that implements
# It serializes an Array, checking if each element that implements
# the +active_model_serializer+ method.
#
# To disable serialization of root elements:
#
# ActiveModel::ArraySerializer.root = false
#
class ArraySerializer
attr_reader :object, :options
class_attribute :root
def initialize(object, options={})
@object, @options = object, options
end
def serializable_array
@object.map do |item|
if item.respond_to?(:active_model_serializer) && (serializer = item.active_model_serializer)
if @options.has_key? :each_serializer
serializer = @options[:each_serializer]
elsif item.respond_to?(:active_model_serializer)
serializer = item.active_model_serializer
end
if serializer
serializer.new(item, @options)
else
item
@ -72,7 +85,7 @@ module ActiveModel
#
# Provides a basic serializer implementation that allows you to easily
# control how a given object is going to be serialized. On initialization,
# it expects to object as arguments, a resource and options. For example,
# it expects two objects as arguments, a resource and options. For example,
# one may do in a controller:
#
# PostSerializer.new(@post, :scope => current_user).to_json
@ -81,7 +94,7 @@ module ActiveModel
# in for authorization purposes.
#
# We use the scope to check if a given attribute should be serialized or not.
# For example, some attributes maybe only be returned if +current_user+ is the
# For example, some attributes may only be returned if +current_user+ is the
# author of the post:
#
# class PostSerializer < ActiveModel::Serializer
@ -97,11 +110,23 @@ module ActiveModel
# end
#
# def author?
# post.author == options[:scope]
# post.author == scope
# end
# end
#
class Serializer
class IncludeError < StandardError
attr_reader :source, :association
def initialize(source, association)
@source, @association = source, association
end
def to_s
"Cannot serialize #{association} when #{source} does not have a root!"
end
end
module Associations #:nodoc:
class Config #:nodoc:
class_attribute :options
@ -176,6 +201,10 @@ module ActiveModel
option(:include, source_serializer._root_embed)
end
def embeddable?
!associated_object.nil?
end
protected
def find_serializable(object)
@ -210,13 +239,41 @@ module ActiveModel
end
class HasOne < Config #:nodoc:
def embeddable?
if polymorphic? && associated_object.nil?
false
else
true
end
end
def polymorphic?
option :polymorphic
end
def polymorphic_key
associated_object.class.to_s.demodulize.underscore.to_sym
end
def plural_key
key.to_s.pluralize.to_sym
if polymorphic?
associated_object.class.to_s.pluralize.demodulize.underscore.to_sym
else
key.to_s.pluralize.to_sym
end
end
def serialize
object = associated_object
object && find_serializable(object).serializable_hash
if object && polymorphic?
{
:type => polymorphic_key,
polymorphic_key => find_serializable(object).serializable_hash
}
elsif object
find_serializable(object).serializable_hash
end
end
def serialize_many
@ -226,7 +283,14 @@ module ActiveModel
end
def serialize_ids
if object = associated_object
object = associated_object
if object && polymorphic?
{
:type => polymorphic_key,
:id => object.read_attribute_for_serialization(:id)
}
elsif object
object.read_attribute_for_serialization(:id)
else
nil
@ -257,10 +321,12 @@ module ActiveModel
end
def attribute(attr, options={})
self._attributes = _attributes.merge(attr => options[:key] || attr)
self._attributes = _attributes.merge(attr => options[:key] || attr.to_s.gsub(/\?$/, '').to_sym)
unless method_defined?(attr)
class_eval "def #{attr}() object.read_attribute_for_serialization(:#{attr}) end", __FILE__, __LINE__
define_method attr do
object.read_attribute_for_serialization(attr.to_sym)
end
end
end
@ -270,7 +336,9 @@ module ActiveModel
attrs.each do |attr|
unless method_defined?(attr)
class_eval "def #{attr}() object.#{attr} end", __FILE__, __LINE__
define_method attr do
object.send attr
end
end
self._associations[attr] = klass.refine(attr, options)
@ -387,7 +455,7 @@ module ActiveModel
end
def url_options
@options[:url_options]
@options[:url_options] || {}
end
# Returns a json representation of the serializable
@ -475,7 +543,9 @@ module ActiveModel
if association.embed_ids?
node[association.key] = association.serialize_ids
if association.embed_in_root?
if association.embed_in_root? && hash.nil?
raise IncludeError.new(self.class, association.name)
elsif association.embed_in_root? && association.embeddable?
merge_association hash, association.root, association.serialize_many, unique_values
end
elsif association.embed_objects?
@ -514,6 +584,11 @@ module ActiveModel
hash
end
# Returns options[:scope]
def scope
@options[:scope]
end
alias :read_attribute_for_serialization :send
# Use ActiveSupport::Notifications to send events to external systems.
@ -523,10 +598,3 @@ module ActiveModel
end
end
end
class Array
# Array uses ActiveModel::ArraySerializer.
def active_model_serializer
ActiveModel::ArraySerializer
end
end

View File

@ -3,6 +3,7 @@ require "active_support/core_ext/string/inflections"
require "active_support/notifications"
require "active_model"
require "active_model/serializer"
require "set"
if defined?(Rails)
module ActiveModel
@ -57,6 +58,19 @@ ActiveSupport.on_load(:active_record) do
include ActiveModel::SerializerSupport
end
module ActiveModel::ArraySerializerSupport
def active_model_serializer
ActiveModel::ArraySerializer
end
end
Array.send(:include, ActiveModel::ArraySerializerSupport)
Set.send(:include, ActiveModel::ArraySerializerSupport)
ActiveSupport.on_load(:active_record) do
ActiveRecord::Relation.send(:include, ActiveModel::ArraySerializerSupport)
end
begin
require 'action_controller'
require 'action_controller/serialization'

View File

@ -66,6 +66,10 @@ class RenderJsonTest < ActionController::TestCase
end
end
class CustomArraySerializer < ActiveModel::ArraySerializer
self.root = "items"
end
class TestController < ActionController::Base
protect_from_forgery
@ -127,13 +131,25 @@ class RenderJsonTest < ActionController::TestCase
render :json => JsonSerializable.new, :options => true
end
def render_json_with_serializer_and_scope_option
@current_user = Struct.new(:as_json).new(:current_user => true)
scope = Struct.new(:as_json).new(:current_user => false)
render :json => JsonSerializable.new, :scope => scope
end
def render_json_with_serializer_api_but_without_serializer
@current_user = Struct.new(:as_json).new(:current_user => true)
render :json => JsonSerializable.new(true)
end
# To specify a custom serializer for an object, use :serializer.
def render_json_with_custom_serializer
render :json => [], :serializer => CustomSerializer
render :json => Object.new, :serializer => CustomSerializer
end
# To specify a custom serializer for each item in the Array, use :each_serializer.
def render_json_array_with_custom_serializer
render :json => [Object.new], :each_serializer => CustomSerializer
end
def render_json_with_links
@ -144,6 +160,14 @@ class RenderJsonTest < ActionController::TestCase
render :json => [], :root => false
end
def render_json_empty_array
render :json => []
end
def render_json_array_with_custom_array_serializer
render :json => [], :serializer => CustomArraySerializer
end
private
def default_serializer_options
@ -241,6 +265,11 @@ class RenderJsonTest < ActionController::TestCase
assert_match '"options":true', @response.body
end
def test_render_json_with_serializer_and_scope_option
get :render_json_with_serializer_and_scope_option
assert_match '"scope":{"current_user":false}', @response.body
end
def test_render_json_with_serializer_api_but_without_serializer
get :render_json_with_serializer_api_but_without_serializer
assert_match '{"serializable_object":true}', @response.body
@ -251,6 +280,11 @@ class RenderJsonTest < ActionController::TestCase
assert_match '{"hello":true}', @response.body
end
def test_render_json_array_with_custom_serializer
get :render_json_array_with_custom_serializer
assert_match '{"test":[{"hello":true}]}', @response.body
end
def test_render_json_with_links
get :render_json_with_links
assert_match '{"link":"http://www.nextangle.com/hypermedia"}', @response.body
@ -260,4 +294,23 @@ class RenderJsonTest < ActionController::TestCase
get :render_json_array_with_no_root
assert_equal '[]', @response.body
end
def test_render_json_empty_array
get :render_json_empty_array
assert_equal '{"test":[]}', @response.body
end
def test_render_json_empty_arry_with_array_serializer_root_false
ActiveModel::ArraySerializer.root = false
get :render_json_empty_array
assert_equal '[]', @response.body
ensure # teardown
ActiveModel::ArraySerializer.root = nil
end
def test_render_json_array_with_custom_array_serializer
get :render_json_array_with_custom_array_serializer
assert_equal '{"items":[]}', @response.body
end
end

View File

@ -4,8 +4,27 @@ class RandomModel
include ActiveModel::SerializerSupport
end
class RandomModelCollection
include ActiveModel::ArraySerializerSupport
end
module ActiveRecord
class Relation
end
end
class SerializerSupportTest < ActiveModel::TestCase
test "it returns nil if no serializer exists" do
assert_equal nil, RandomModel.new.active_model_serializer
end
end
test "it returns ArraySerializer for a collection" do
assert_equal ActiveModel::ArraySerializer, RandomModelCollection.new.active_model_serializer
end
test "it automatically includes array_serializer in active_record/relation" do
ActiveSupport.run_load_hooks(:active_record)
assert_equal ActiveModel::ArraySerializer, ActiveRecord::Relation.new.active_model_serializer
end
end

View File

@ -94,6 +94,11 @@ class SerializerTest < ActiveModel::TestCase
has_many :comments, :serializer => CommentSerializer
end
def test_scope_works_correct
serializer = ActiveModel::Serializer.new :foo, :scope => :bar
assert_equal serializer.scope, :bar
end
def test_attributes
user = User.new
user_serializer = DefaultUserSerializer.new(user, {})
@ -138,6 +143,12 @@ class SerializerTest < ActiveModel::TestCase
assert_equal({ :host => "test.local" }, user_serializer.url_options)
end
def test_serializer_returns_empty_hash_without_url_options
user = User.new
user_serializer = UserSerializer.new(user)
assert_equal({}, user_serializer.url_options)
end
def test_pretty_accessors
user = User.new
user.superuser = true
@ -411,6 +422,40 @@ class SerializerTest < ActiveModel::TestCase
assert_equal({ :items => [ hash.as_json ]}, serializer.as_json)
end
class CustomPostSerializer < ActiveModel::Serializer
attributes :title
end
def test_array_serializer_with_specified_seriailizer
post1 = Post.new(:title => "Post1", :author => "Author1", :id => 1)
post2 = Post.new(:title => "Post2", :author => "Author2", :id => 2)
array = [ post1, post2 ]
serializer = array.active_model_serializer.new array, :each_serializer => CustomPostSerializer
assert_equal([
{ :title => "Post1" },
{ :title => "Post2" }
], serializer.as_json)
end
def test_sets_can_be_serialized
post1 = Post.new(:title => "Post1", :author => "Author1", :id => 1)
post2 = Post.new(:title => "Post2", :author => "Author2", :id => 2)
set = Set.new
set << post1
set << post2
serializer = set.active_model_serializer.new set, :each_serializer => CustomPostSerializer
as_json = serializer.as_json
assert_equal 2, as_json.size
assert as_json.include?({ :title => "Post1" })
assert as_json.include?({ :title => "Post2" })
end
class CustomBlog < Blog
attr_accessor :public_posts, :public_user
end
@ -890,4 +935,358 @@ class SerializerTest < ActiveModel::TestCase
end
assert_equal ActiveModel::Serializer, loaded
end
def tests_query_attributes_strip_question_mark
todo = Class.new do
def overdue?
true
end
def read_attribute_for_serialization(name)
send name
end
end
serializer = Class.new(ActiveModel::Serializer) do
attribute :overdue?
end
actual = serializer.new(todo.new).as_json
assert_equal({
:overdue => true
}, actual)
end
def tests_query_attributes_allow_key_option
todo = Class.new do
def overdue?
true
end
def read_attribute_for_serialization(name)
send name
end
end
serializer = Class.new(ActiveModel::Serializer) do
attribute :overdue?, :key => :foo
end
actual = serializer.new(todo.new).as_json
assert_equal({
:foo => true
}, actual)
end
# Set up some classes for polymorphic testing
class Attachment < Model
def attachable
@attributes[:attachable]
end
def readable
@attributes[:readable]
end
def edible
@attributes[:edible]
end
end
def tests_can_handle_polymorphism
email_serializer = Class.new(ActiveModel::Serializer) do
attributes :subject, :body
end
email_class = Class.new(Model) do
def self.to_s
"Email"
end
define_method :active_model_serializer do
email_serializer
end
end
attachment_serializer = Class.new(ActiveModel::Serializer) do
attributes :name, :url
has_one :attachable, :polymorphic => true
end
email = email_class.new :subject => 'foo', :body => 'bar'
attachment = Attachment.new :name => 'logo.png', :url => 'http://example.com/logo.png', :attachable => email
actual = attachment_serializer.new(attachment, {}).as_json
assert_equal({
:name => 'logo.png',
:url => 'http://example.com/logo.png',
:attachable => {
:type => :email,
:email => { :subject => 'foo', :body => 'bar' }
}
}, actual)
end
def test_can_handle_polymoprhic_ids
email_serializer = Class.new(ActiveModel::Serializer) do
attributes :subject, :body
end
email_class = Class.new(Model) do
def self.to_s
"Email"
end
define_method :active_model_serializer do
email_serializer
end
end
attachment_serializer = Class.new(ActiveModel::Serializer) do
embed :ids
attributes :name, :url
has_one :attachable, :polymorphic => true
end
email = email_class.new :id => 1
attachment = Attachment.new :name => 'logo.png', :url => 'http://example.com/logo.png', :attachable => email
actual = attachment_serializer.new(attachment, {}).as_json
assert_equal({
:name => 'logo.png',
:url => 'http://example.com/logo.png',
:attachable => {
:type => :email,
:id => 1
}
}, actual)
end
def test_polymorphic_associations_are_included_at_root
email_serializer = Class.new(ActiveModel::Serializer) do
attributes :subject, :body, :id
end
email_class = Class.new(Model) do
def self.to_s
"Email"
end
define_method :active_model_serializer do
email_serializer
end
end
attachment_serializer = Class.new(ActiveModel::Serializer) do
root :attachment
embed :ids, :include => true
attributes :name, :url
has_one :attachable, :polymorphic => true
end
email = email_class.new :id => 1, :subject => "Hello", :body => "World"
attachment = Attachment.new :name => 'logo.png', :url => 'http://example.com/logo.png', :attachable => email
actual = attachment_serializer.new(attachment, {}).as_json
assert_equal({
:attachment => {
:name => 'logo.png',
:url => 'http://example.com/logo.png',
:attachable => {
:type => :email,
:id => 1
}},
:emails => [{
:id => 1,
:subject => "Hello",
:body => "World"
}]
}, actual)
end
def test_multiple_polymorphic_associations
email_serializer = Class.new(ActiveModel::Serializer) do
attributes :subject, :body, :id
end
orange_serializer = Class.new(ActiveModel::Serializer) do
embed :ids, :include => true
attributes :plu, :id
has_one :readable, :polymorphic => true
end
email_class = Class.new(Model) do
def self.to_s
"Email"
end
define_method :active_model_serializer do
email_serializer
end
end
orange_class = Class.new(Model) do
def self.to_s
"Orange"
end
def readable
@attributes[:readable]
end
define_method :active_model_serializer do
orange_serializer
end
end
attachment_serializer = Class.new(ActiveModel::Serializer) do
root :attachment
embed :ids, :include => true
attributes :name, :url
has_one :attachable, :polymorphic => true
has_one :readable, :polymorphic => true
has_one :edible, :polymorphic => true
end
email = email_class.new :id => 1, :subject => "Hello", :body => "World"
orange = orange_class.new :id => 1, :plu => "3027", :readable => email
attachment = Attachment.new({
:name => 'logo.png',
:url => 'http://example.com/logo.png',
:attachable => email,
:readable => email,
:edible => orange
})
actual = attachment_serializer.new(attachment, {}).as_json
assert_equal({
:emails => [{
:subject => "Hello",
:body => "World",
:id => 1
}],
:oranges => [{
:plu => "3027",
:id => 1,
:readable => { :type => :email, :id => 1 }
}],
:attachment => {
:name => 'logo.png',
:url => 'http://example.com/logo.png',
:attachable => { :type => :email, :id => 1 },
:readable => { :type => :email, :id => 1 },
:edible => { :type => :orange, :id => 1 }
}
}, actual)
end
def test_raises_an_error_when_a_child_serializer_includes_associations_when_the_source_doesnt
attachment_serializer = Class.new(ActiveModel::Serializer) do
attributes :name
end
fruit_serializer = Class.new(ActiveModel::Serializer) do
embed :ids, :include => true
has_one :attachment, :serializer => attachment_serializer
attribute :color
end
banana_class = Class.new Model do
def self.to_s
'banana'
end
def attachment
@attributes[:attachment]
end
define_method :active_model_serializer do
fruit_serializer
end
end
strawberry_class = Class.new Model do
def self.to_s
'strawberry'
end
def attachment
@attributes[:attachment]
end
define_method :active_model_serializer do
fruit_serializer
end
end
smoothie = Class.new do
attr_reader :base, :flavor
def initialize(base, flavor)
@base, @flavor = base, flavor
end
end
smoothie_serializer = Class.new(ActiveModel::Serializer) do
root false
embed :ids, :include => true
has_one :base, :polymorphic => true
has_one :flavor, :polymorphic => true
end
banana_attachment = Attachment.new({
:name => 'banana_blending.md',
:id => 3,
})
strawberry_attachment = Attachment.new({
:name => 'strawberry_cleaning.doc',
:id => 4
})
banana = banana_class.new :color => "yellow", :id => 1, :attachment => banana_attachment
strawberry = strawberry_class.new :color => "red", :id => 2, :attachment => strawberry_attachment
smoothie = smoothie_serializer.new(smoothie.new(banana, strawberry))
assert_raise ActiveModel::Serializer::IncludeError do
smoothie.as_json
end
end
def tests_includes_does_not_include_nil_polymoprhic_associations
post_serializer = Class.new(ActiveModel::Serializer) do
root :post
embed :ids, :include => true
has_one :author, :polymorphic => true
attributes :title
end
post = Post.new(:title => 'Foo')
actual = post_serializer.new(post).as_json
assert_equal({
:post => {
:title => 'Foo',
:author => nil
}
}, actual)
end
end