Skip to content

Commit

Permalink
Merge branch 'serializers'
Browse files Browse the repository at this point in the history
This implements the ActiveModel::Serializer object. Includes code, tests, generators and guides.

From José and Yehuda with love.

Conflicts:
	railties/CHANGELOG.md
  • Loading branch information
josevalim committed Nov 25, 2011
2 parents 4565c87 + 696d01f commit fcacc69
Show file tree
Hide file tree
Showing 35 changed files with 2,074 additions and 482 deletions.
1 change: 1 addition & 0 deletions actionpack/lib/action_controller.rb
Expand Up @@ -31,6 +31,7 @@ module ActionController
autoload :RequestForgeryProtection
autoload :Rescue
autoload :Responder
autoload :Serialization
autoload :SessionManagement
autoload :Streaming
autoload :Testing
Expand Down
1 change: 1 addition & 0 deletions actionpack/lib/action_controller/base.rb
Expand Up @@ -190,6 +190,7 @@ def self.without_modules(*modules)
Redirecting,
Rendering,
Renderers::All,
Serialization,
ConditionalGet,
RackDelegation,
SessionManagement,
Expand Down
20 changes: 9 additions & 11 deletions actionpack/lib/action_controller/metal/renderers.rb
@@ -1,5 +1,6 @@
require 'active_support/core_ext/class/attribute'
require 'active_support/core_ext/object/blank'
require 'set'

module ActionController
# See <tt>Renderers.add</tt>
Expand All @@ -12,16 +13,13 @@ module Renderers

included do
class_attribute :_renderers
self._renderers = {}.freeze
self._renderers = Set.new.freeze
end

module ClassMethods
def use_renderers(*args)
new = _renderers.dup
args.each do |key|
new[key] = RENDERERS[key]
end
self._renderers = new.freeze
renderers = _renderers + args
self._renderers = renderers.freeze
end
alias use_renderer use_renderers
end
Expand All @@ -31,18 +29,18 @@ def render_to_body(options)
end

def _handle_render_options(options)
_renderers.each do |name, value|
if options.key?(name.to_sym)
_renderers.each do |name|
if options.key?(name)
_process_options(options)
return send("_render_option_#{name}", options.delete(name.to_sym), options)
return send("_render_option_#{name}", options.delete(name), options)
end
end
nil
end

# Hash of available renderers, mapping a renderer name to its proc.
# Default keys are :json, :js, :xml.
RENDERERS = {}
RENDERERS = Set.new

# Adds a new renderer to call within controller actions.
# A renderer is invoked by passing its name as an option to
Expand Down Expand Up @@ -79,7 +77,7 @@ def _handle_render_options(options)
# <tt>ActionController::MimeResponds#respond_with</tt>
def self.add(key, &block)
define_method("_render_option_#{key}", &block)
RENDERERS[key] = block
RENDERERS << key.to_sym
end

module All
Expand Down
51 changes: 51 additions & 0 deletions actionpack/lib/action_controller/metal/serialization.rb
@@ -0,0 +1,51 @@
module ActionController
# Action Controller Serialization
#
# Overrides render :json to check if the given object implements +active_model_serializer+
# as a method. If so, use the returned serializer instead of calling +to_json+ in the object.
#
# This module also provides a serialization_scope method that allows you to configure the
# +serialization_scope+ of the serializer. Most apps will likely set the +serialization_scope+
# to the current user:
#
# class ApplicationController < ActionController::Base
# serialization_scope :current_user
# end
#
# If you need more complex scope rules, you can simply override the serialization_scope:
#
# class ApplicationController < ActionController::Base
# private
#
# def serialization_scope
# current_user
# end
# end
#
module Serialization
extend ActiveSupport::Concern

include ActionController::Renderers

included do
class_attribute :_serialization_scope
end

def serialization_scope
send(_serialization_scope)
end

def _render_option_json(json, options)
if json.respond_to?(:active_model_serializer) && (serializer = json.active_model_serializer)
json = serializer.new(json, serialization_scope)
end
super
end

module ClassMethods
def serialization_scope(scope)
self._serialization_scope = scope
end
end
end
end
48 changes: 48 additions & 0 deletions actionpack/test/controller/render_json_test.rb
Expand Up @@ -15,9 +15,36 @@ def to_json(options = {})
end
end

class JsonSerializer
def initialize(object, scope)
@object, @scope = object, scope
end

def as_json(*)
{ :object => @object.as_json, :scope => @scope.as_json }
end
end

class JsonSerializable
def initialize(skip=false)
@skip = skip
end

def active_model_serializer
JsonSerializer unless @skip
end

def as_json(*)
{ :serializable_object => true }
end
end

class TestController < ActionController::Base
protect_from_forgery

serialization_scope :current_user
attr_reader :current_user

def self.controller_path
'test'
end
Expand Down Expand Up @@ -61,6 +88,16 @@ def render_json_with_extra_options
def render_json_without_options
render :json => JsonRenderable.new
end

def render_json_with_serializer
@current_user = Struct.new(:as_json).new(:current_user => true)
render :json => JsonSerializable.new
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
end

tests TestController
Expand Down Expand Up @@ -132,4 +169,15 @@ def test_render_json_calls_to_json_from_object
get :render_json_without_options
assert_equal '{"a":"b"}', @response.body
end

def test_render_json_with_serializer
get :render_json_with_serializer
assert_match '"scope":{"current_user":true}', @response.body
assert_match '"object":{"serializable_object":true}', @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
end
end
14 changes: 13 additions & 1 deletion activemodel/CHANGELOG.md
@@ -1,4 +1,16 @@
* Added ActiveModel::Errors#added? to check if a specific error has been added *Martin Svalin*
## Rails 3.2.0 (unreleased) ##

* Add ActiveModel::Serializer that encapsulates an ActiveModel object serialization *José Valim*

* Renamed (with a deprecation the following constants):

ActiveModel::Serialization => ActiveModel::Serializable
ActiveModel::Serializers::JSON => ActiveModel::Serializable::JSON
ActiveModel::Serializers::Xml => ActiveModel::Serializable::XML

*José Valim*

* Add ActiveModel::Errors#added? to check if a specific error has been added *Martin Svalin*

* Add ability to define strict validation(with :strict => true option) that always raises exception when fails *Bogdan Gusiev*

Expand Down
3 changes: 3 additions & 0 deletions activemodel/lib/active_model.rb
Expand Up @@ -29,6 +29,7 @@
module ActiveModel
extend ActiveSupport::Autoload

autoload :ArraySerializer, 'active_model/serializer'
autoload :AttributeMethods
autoload :BlockValidator, 'active_model/validator'
autoload :Callbacks
Expand All @@ -43,7 +44,9 @@ module ActiveModel
autoload :Observer, 'active_model/observing'
autoload :Observing
autoload :SecurePassword
autoload :Serializable
autoload :Serialization
autoload :Serializer
autoload :TestCase
autoload :Translation
autoload :Validations
Expand Down
156 changes: 156 additions & 0 deletions activemodel/lib/active_model/serializable.rb
@@ -0,0 +1,156 @@
require 'active_support/core_ext/hash/except'
require 'active_support/core_ext/hash/slice'
require 'active_support/core_ext/array/wrap'
require 'active_support/core_ext/string/inflections'

module ActiveModel
# == Active Model Serializable
#
# Provides a basic serialization to a serializable_hash for your object.
#
# A minimal implementation could be:
#
# class Person
#
# include ActiveModel::Serializable
#
# attr_accessor :name
#
# def attributes
# {'name' => name}
# end
#
# end
#
# Which would provide you with:
#
# person = Person.new
# person.serializable_hash # => {"name"=>nil}
# person.name = "Bob"
# person.serializable_hash # => {"name"=>"Bob"}
#
# You need to declare some sort of attributes hash which contains the attributes
# you want to serialize and their current value.
#
# Most of the time though, you will want to include the JSON or XML
# serializations. Both of these modules automatically include the
# ActiveModel::Serialization module, so there is no need to explicitly
# include it.
#
# So a minimal implementation including XML and JSON would be:
#
# class Person
#
# include ActiveModel::Serializable::JSON
# include ActiveModel::Serializable::XML
#
# attr_accessor :name
#
# def attributes
# {'name' => name}
# end
#
# end
#
# Which would provide you with:
#
# person = Person.new
# person.serializable_hash # => {"name"=>nil}
# person.as_json # => {"name"=>nil}
# person.to_json # => "{\"name\":null}"
# person.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person...
#
# person.name = "Bob"
# person.serializable_hash # => {"name"=>"Bob"}
# person.as_json # => {"name"=>"Bob"}
# person.to_json # => "{\"name\":\"Bob\"}"
# person.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person...
#
# Valid options are <tt>:only</tt>, <tt>:except</tt> and <tt>:methods</tt> .
module Serializable
extend ActiveSupport::Concern

autoload :JSON, "active_model/serializable/json"
autoload :XML, "active_model/serializable/xml"

module ClassMethods #:nodoc:
def active_model_serializer
return @active_model_serializer if defined?(@active_model_serializer)
@active_model_serializer = "#{self.name}Serializer".safe_constantize
end
end

def serializable_hash(options = nil)
options ||= {}

attribute_names = attributes.keys.sort
if only = options[:only]
attribute_names &= Array.wrap(only).map(&:to_s)
elsif except = options[:except]
attribute_names -= Array.wrap(except).map(&:to_s)
end

hash = {}
attribute_names.each { |n| hash[n] = read_attribute_for_serialization(n) }

method_names = Array.wrap(options[:methods]).select { |n| respond_to?(n) }
method_names.each { |n| hash[n] = send(n) }

serializable_add_includes(options) do |association, records, opts|
hash[association] = if records.is_a?(Enumerable)
records.map { |a| a.serializable_hash(opts) }
else
records.serializable_hash(opts)
end
end

hash
end

# Returns a model serializer for this object considering its namespace.
def active_model_serializer
self.class.active_model_serializer
end

private

# Hook method defining how an attribute value should be retrieved for
# serialization. By default this is assumed to be an instance named after
# the attribute. Override this method in subclasses should you need to
# retrieve the value for a given attribute differently:
#
# class MyClass
# include ActiveModel::Validations
#
# def initialize(data = {})
# @data = data
# end
#
# def read_attribute_for_serialization(key)
# @data[key]
# end
# end
#
alias :read_attribute_for_serialization :send

# Add associations specified via the <tt>:include</tt> option.
#
# Expects a block that takes as arguments:
# +association+ - name of the association
# +records+ - the association record(s) to be serialized
# +opts+ - options for the association records
def serializable_add_includes(options = {}) #:nodoc:
return unless include = options[:include]

unless include.is_a?(Hash)
include = Hash[Array.wrap(include).map { |n| n.is_a?(Hash) ? n.to_a.first : [n, {}] }]
end

include.each do |association, opts|
if records = send(association)
yield association, records, opts
end
end
end
end
end

0 comments on commit fcacc69

Please sign in to comment.