diff options
author | José Valim <jose.valim@gmail.com> | 2011-11-25 09:58:18 +0000 |
---|---|---|
committer | José Valim <jose.valim@gmail.com> | 2011-11-25 09:59:35 +0000 |
commit | fcacc6986ab60f1fb2e423a73bf47c7abd7b191d (patch) | |
tree | b4176f645915ac80d2ba43b54aa836d3bc4f7d21 | |
parent | 4565c871a63dddcb9543eb5be315f152ac551c17 (diff) | |
parent | 696d01f7f4a8ed787924a41cce6df836cd73c46f (diff) | |
download | rails-fcacc6986ab60f1fb2e423a73bf47c7abd7b191d.tar.gz rails-fcacc6986ab60f1fb2e423a73bf47c7abd7b191d.tar.bz2 rails-fcacc6986ab60f1fb2e423a73bf47c7abd7b191d.zip |
Merge branch 'serializers'
This implements the ActiveModel::Serializer object. Includes code, tests, generators and guides.
From José and Yehuda with love.
Conflicts:
railties/CHANGELOG.md
35 files changed, 2074 insertions, 482 deletions
diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb index f4eaa2fd1b..3829e60bb0 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -31,6 +31,7 @@ module ActionController autoload :RequestForgeryProtection autoload :Rescue autoload :Responder + autoload :Serialization autoload :SessionManagement autoload :Streaming autoload :Testing diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index 98bfe72fef..cfb9cf5e6e 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -190,6 +190,7 @@ module ActionController Redirecting, Rendering, Renderers::All, + Serialization, ConditionalGet, RackDelegation, SessionManagement, diff --git a/actionpack/lib/action_controller/metal/renderers.rb b/actionpack/lib/action_controller/metal/renderers.rb index 0ad9dbeda9..6e9ce450ac 100644 --- a/actionpack/lib/action_controller/metal/renderers.rb +++ b/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> @@ -12,16 +13,13 @@ module ActionController 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 @@ -31,10 +29,10 @@ module ActionController 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 @@ -42,7 +40,7 @@ module ActionController # 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 @@ -79,7 +77,7 @@ module ActionController # <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 diff --git a/actionpack/lib/action_controller/metal/serialization.rb b/actionpack/lib/action_controller/metal/serialization.rb new file mode 100644 index 0000000000..628d5996d7 --- /dev/null +++ b/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 diff --git a/actionpack/test/controller/render_json_test.rb b/actionpack/test/controller/render_json_test.rb index fc604a2db3..dc09812ba3 100644 --- a/actionpack/test/controller/render_json_test.rb +++ b/actionpack/test/controller/render_json_test.rb @@ -15,9 +15,36 @@ class RenderJsonTest < ActionController::TestCase 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 @@ -61,6 +88,16 @@ class RenderJsonTest < ActionController::TestCase 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 @@ -132,4 +169,15 @@ class RenderJsonTest < ActionController::TestCase 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 diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 71a737caff..53934f08e7 100644 --- a/activemodel/CHANGELOG.md +++ b/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* diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index d0e2a6f39c..6c4fb44b0f 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -29,6 +29,7 @@ require 'active_model/version' module ActiveModel extend ActiveSupport::Autoload + autoload :ArraySerializer, 'active_model/serializer' autoload :AttributeMethods autoload :BlockValidator, 'active_model/validator' autoload :Callbacks @@ -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 diff --git a/activemodel/lib/active_model/serializable.rb b/activemodel/lib/active_model/serializable.rb new file mode 100644 index 0000000000..70e27c5683 --- /dev/null +++ b/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 diff --git a/activemodel/lib/active_model/serializable/json.rb b/activemodel/lib/active_model/serializable/json.rb new file mode 100644 index 0000000000..79173929e4 --- /dev/null +++ b/activemodel/lib/active_model/serializable/json.rb @@ -0,0 +1,108 @@ +require 'active_support/json' +require 'active_support/core_ext/class/attribute' + +module ActiveModel + # == Active Model Serializable as JSON + module Serializable + module JSON + extend ActiveSupport::Concern + include ActiveModel::Serializable + + included do + extend ActiveModel::Naming + + class_attribute :include_root_in_json + self.include_root_in_json = true + end + + # Returns a hash representing the model. Some configuration can be + # passed through +options+. + # + # The option <tt>include_root_in_json</tt> controls the top-level behavior + # of +as_json+. If true (the default) +as_json+ will emit a single root + # node named after the object's type. For example: + # + # user = User.find(1) + # user.as_json + # # => { "user": {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true} } + # + # ActiveRecord::Base.include_root_in_json = false + # user.as_json + # # => {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true} + # + # This behavior can also be achieved by setting the <tt>:root</tt> option to +false+ as in: + # + # user = User.find(1) + # user.as_json(root: false) + # # => {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true} + # + # The remainder of the examples in this section assume include_root_in_json is set to + # <tt>false</tt>. + # + # Without any +options+, the returned Hash will include all the model's + # attributes. For example: + # + # user = User.find(1) + # user.as_json + # # => {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true} + # + # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit the attributes + # included, and work similar to the +attributes+ method. For example: + # + # user.as_json(:only => [ :id, :name ]) + # # => {"id": 1, "name": "Konata Izumi"} + # + # user.as_json(:except => [ :id, :created_at, :age ]) + # # => {"name": "Konata Izumi", "awesome": true} + # + # To include the result of some method calls on the model use <tt>:methods</tt>: + # + # user.as_json(:methods => :permalink) + # # => {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true, + # "permalink": "1-konata-izumi"} + # + # To include associations use <tt>:include</tt>: + # + # user.as_json(:include => :posts) + # # => {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true, + # "posts": [{"id": 1, "author_id": 1, "title": "Welcome to the weblog"}, + # {"id": 2, author_id: 1, "title": "So I was thinking"}]} + # + # Second level and higher order associations work as well: + # + # user.as_json(:include => { :posts => { + # :include => { :comments => { + # :only => :body } }, + # :only => :title } }) + # # => {"id": 1, "name": "Konata Izumi", "age": 16, + # "created_at": "2006/08/01", "awesome": true, + # "posts": [{"comments": [{"body": "1st post!"}, {"body": "Second!"}], + # "title": "Welcome to the weblog"}, + # {"comments": [{"body": "Don't think too hard"}], + # "title": "So I was thinking"}]} + def as_json(options = nil) + root = include_root_in_json + root = options[:root] if options.try(:key?, :root) + if root + root = self.class.model_name.element if root == true + { root => serializable_hash(options) } + else + serializable_hash(options) + end + end + + def from_json(json, include_root=include_root_in_json) + hash = ActiveSupport::JSON.decode(json) + hash = hash.values.first if include_root + self.attributes = hash + self + end + end + end +end diff --git a/activemodel/lib/active_model/serializable/xml.rb b/activemodel/lib/active_model/serializable/xml.rb new file mode 100644 index 0000000000..d11cee9b42 --- /dev/null +++ b/activemodel/lib/active_model/serializable/xml.rb @@ -0,0 +1,195 @@ +require 'active_support/core_ext/array/wrap' +require 'active_support/core_ext/class/attribute_accessors' +require 'active_support/core_ext/array/conversions' +require 'active_support/core_ext/hash/conversions' +require 'active_support/core_ext/hash/slice' + +module ActiveModel + # == Active Model Serializable as XML + module Serializable + module XML + extend ActiveSupport::Concern + include ActiveModel::Serializable + + class Serializer #:nodoc: + class Attribute #:nodoc: + attr_reader :name, :value, :type + + def initialize(name, serializable, value) + @name, @serializable = name, serializable + value = value.in_time_zone if value.respond_to?(:in_time_zone) + @value = value + @type = compute_type + end + + def decorations + decorations = {} + decorations[:encoding] = 'base64' if type == :binary + decorations[:type] = (type == :string) ? nil : type + decorations[:nil] = true if value.nil? + decorations + end + + protected + + def compute_type + return if value.nil? + type = ActiveSupport::XmlMini::TYPE_NAMES[value.class.name] + type ||= :string if value.respond_to?(:to_str) + type ||= :yaml + type + end + end + + class MethodAttribute < Attribute #:nodoc: + end + + attr_reader :options + + def initialize(serializable, options = nil) + @serializable = serializable + @options = options ? options.dup : {} + end + + def serializable_hash + @serializable.serializable_hash(@options.except(:include)) + end + + def serializable_collection + methods = Array.wrap(options[:methods]).map(&:to_s) + serializable_hash.map do |name, value| + name = name.to_s + if methods.include?(name) + self.class::MethodAttribute.new(name, @serializable, value) + else + self.class::Attribute.new(name, @serializable, value) + end + end + end + + def serialize + require 'builder' unless defined? ::Builder + + options[:indent] ||= 2 + options[:builder] ||= ::Builder::XmlMarkup.new(:indent => options[:indent]) + + @builder = options[:builder] + @builder.instruct! unless options[:skip_instruct] + + root = (options[:root] || @serializable.class.model_name.element).to_s + root = ActiveSupport::XmlMini.rename_key(root, options) + + args = [root] + args << {:xmlns => options[:namespace]} if options[:namespace] + args << {:type => options[:type]} if options[:type] && !options[:skip_types] + + @builder.tag!(*args) do + add_attributes_and_methods + add_includes + add_extra_behavior + add_procs + yield @builder if block_given? + end + end + + private + + def add_extra_behavior + end + + def add_attributes_and_methods + serializable_collection.each do |attribute| + key = ActiveSupport::XmlMini.rename_key(attribute.name, options) + ActiveSupport::XmlMini.to_tag(key, attribute.value, + options.merge(attribute.decorations)) + end + end + + def add_includes + @serializable.send(:serializable_add_includes, options) do |association, records, opts| + add_associations(association, records, opts) + end + end + + # TODO This can likely be cleaned up to simple use ActiveSupport::XmlMini.to_tag as well. + def add_associations(association, records, opts) + merged_options = opts.merge(options.slice(:builder, :indent)) + merged_options[:skip_instruct] = true + + if records.is_a?(Enumerable) + tag = ActiveSupport::XmlMini.rename_key(association.to_s, options) + type = options[:skip_types] ? { } : {:type => "array"} + association_name = association.to_s.singularize + merged_options[:root] = association_name + + if records.empty? + @builder.tag!(tag, type) + else + @builder.tag!(tag, type) do + records.each do |record| + if options[:skip_types] + record_type = {} + else + record_class = (record.class.to_s.underscore == association_name) ? nil : record.class.name + record_type = {:type => record_class} + end + + record.to_xml merged_options.merge(record_type) + end + end + end + else + merged_options[:root] = association.to_s + records.to_xml(merged_options) + end + end + + def add_procs + if procs = options.delete(:procs) + Array.wrap(procs).each do |proc| + if proc.arity == 1 + proc.call(options) + else + proc.call(options, @serializable) + end + end + end + end + end + + # Returns XML representing the model. Configuration can be + # passed through +options+. + # + # Without any +options+, the returned XML string will include all the model's + # attributes. For example: + # + # user = User.find(1) + # user.to_xml + # + # <?xml version="1.0" encoding="UTF-8"?> + # <user> + # <id type="integer">1</id> + # <name>David</name> + # <age type="integer">16</age> + # <created-at type="datetime">2011-01-30T22:29:23Z</created-at> + # </user> + # + # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit the attributes + # included, and work similar to the +attributes+ method. + # + # To include the result of some method calls on the model use <tt>:methods</tt>. + # + # To include associations use <tt>:include</tt>. + # + # For further documentation see activerecord/lib/active_record/serializers/xml_serializer.xml. + def to_xml(options = {}, &block) + Serializer.new(self, options).serialize(&block) + end + + def from_xml(xml) + self.attributes = Hash.from_xml(xml).values.first + self + end + end + end +end diff --git a/activemodel/lib/active_model/serialization.rb b/activemodel/lib/active_model/serialization.rb index a4b58ab456..439302c632 100644 --- a/activemodel/lib/active_model/serialization.rb +++ b/activemodel/lib/active_model/serialization.rb @@ -1,139 +1,10 @@ -require 'active_support/core_ext/hash/except' -require 'active_support/core_ext/hash/slice' -require 'active_support/core_ext/array/wrap' - - module ActiveModel - # == Active Model Serialization - # - # Provides a basic serialization to a serializable_hash for your object. - # - # A minimal implementation could be: - # - # class Person - # - # include ActiveModel::Serialization - # - # 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::Serializers::JSON - # include ActiveModel::Serializers::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 Serialization - 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 + extend ActiveSupport::Concern + include ActiveModel::Serializable - hash + included do + ActiveSupport::Deprecation.warn "ActiveModel::Serialization is deprecated in favor of ActiveModel::Serializable" 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 +end
\ No newline at end of file diff --git a/activemodel/lib/active_model/serializer.rb b/activemodel/lib/active_model/serializer.rb new file mode 100644 index 0000000000..0e23df2f2b --- /dev/null +++ b/activemodel/lib/active_model/serializer.rb @@ -0,0 +1,253 @@ +require "active_support/core_ext/class/attribute" +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/module/anonymous" +require "set" + +module ActiveModel + # Active Model Array Serializer + # + # It serializes an array checking if each element that implements + # the +active_model_serializer+ method passing down the current scope. + class ArraySerializer + attr_reader :object, :scope + + def initialize(object, scope) + @object, @scope = object, scope + end + + def serializable_array + @object.map do |item| + if item.respond_to?(:active_model_serializer) && (serializer = item.active_model_serializer) + serializer.new(item, scope) + else + item + end + end + end + + def as_json(*args) + serializable_array.as_json(*args) + end + end + + # Active Model Serializer + # + # 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 a scope. For example, + # one may do in a controller: + # + # PostSerializer.new(@post, current_user).to_json + # + # The object to be serialized is the +@post+ and the scope is +current_user+. + # + # 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 + # author of the post: + # + # class PostSerializer < ActiveModel::Serializer + # attributes :title, :body + # has_many :comments + # + # private + # + # def attributes + # hash = super + # hash.merge!(:email => post.email) if author? + # hash + # end + # + # def author? + # post.author == scope + # end + # end + # + class Serializer + module Associations #:nodoc: + class Config < Struct.new(:name, :options) #:nodoc: + def serializer + options[:serializer] + end + end + + class HasMany < Config #:nodoc: + def serialize(collection, scope) + collection.map do |item| + serializer.new(item, scope).serializable_hash + end + end + + def serialize_ids(collection, scope) + # use named scopes if they are present + # return collection.ids if collection.respond_to?(:ids) + + collection.map do |item| + item.read_attribute_for_serialization(:id) + end + end + end + + class HasOne < Config #:nodoc: + def serialize(object, scope) + object && serializer.new(object, scope).serializable_hash + end + + def serialize_ids(object, scope) + object && object.read_attribute_for_serialization(:id) + end + end + end + + class_attribute :_attributes + self._attributes = Set.new + + class_attribute :_associations + self._associations = [] + + class_attribute :_root + class_attribute :_embed + self._embed = :objects + class_attribute :_root_embed + + class << self + # Define attributes to be used in the serialization. + def attributes(*attrs) + self._attributes += attrs + end + + def associate(klass, attrs) #:nodoc: + options = attrs.extract_options! + self._associations += attrs.map do |attr| + unless method_defined?(attr) + class_eval "def #{attr}() object.#{attr} end", __FILE__, __LINE__ + end + + options[:serializer] ||= const_get("#{attr.to_s.camelize}Serializer") + klass.new(attr, options) + end + end + + # Defines an association in the object should be rendered. + # + # The serializer object should implement the association name + # as a method which should return an array when invoked. If a method + # with the association name does not exist, the association name is + # dispatched to the serialized object. + def has_many(*attrs) + associate(Associations::HasMany, attrs) + end + + # Defines an association in the object should be rendered. + # + # The serializer object should implement the association name + # as a method which should return an object when invoked. If a method + # with the association name does not exist, the association name is + # dispatched to the serialized object. + def has_one(*attrs) + associate(Associations::HasOne, attrs) + end + + # Define how associations should be embedded. + # + # embed :objects # Embed associations as full objects + # embed :ids # Embed only the association ids + # embed :ids, :include => true # Embed the association ids and include objects in the root + # + def embed(type, options={}) + self._embed = type + self._root_embed = true if options[:include] + end + + # Defines the root used on serialization. If false, disables the root. + def root(name) + self._root = name + end + + def inherited(klass) #:nodoc: + return if klass.anonymous? + + name = klass.name.demodulize.underscore.sub(/_serializer$/, '') + + klass.class_eval do + alias_method name.to_sym, :object + root name.to_sym unless self._root == false + end + end + end + + attr_reader :object, :scope + + def initialize(object, scope) + @object, @scope = object, scope + end + + # Returns a json representation of the serializable + # object including the root. + def as_json(*) + if _root + hash = { _root => serializable_hash } + hash.merge!(associations) if _root_embed + hash + else + serializable_hash + end + end + + # Returns a hash representation of the serializable + # object without the root. + def serializable_hash + if _embed == :ids + attributes.merge(association_ids) + elsif _embed == :objects + attributes.merge(associations) + else + attributes + end + end + + # Returns a hash representation of the serializable + # object associations. + def associations + hash = {} + + _associations.each do |association| + associated_object = send(association.name) + hash[association.name] = association.serialize(associated_object, scope) + end + + hash + end + + # Returns a hash representation of the serializable + # object associations ids. + def association_ids + hash = {} + + _associations.each do |association| + associated_object = send(association.name) + hash[association.name] = association.serialize_ids(associated_object, scope) + end + + hash + end + + # Returns a hash representation of the serializable + # object attributes. + def attributes + hash = {} + + _attributes.each do |name| + hash[name] = @object.read_attribute_for_serialization(name) + end + + hash + end + end +end + +class Array + # Array uses ActiveModel::ArraySerializer. + def active_model_serializer + ActiveModel::ArraySerializer + end +end
\ No newline at end of file diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb index c845440120..9efd7c5f69 100644 --- a/activemodel/lib/active_model/serializers/json.rb +++ b/activemodel/lib/active_model/serializers/json.rb @@ -1,108 +1,12 @@ -require 'active_support/json' -require 'active_support/core_ext/class/attribute' - module ActiveModel - # == Active Model JSON Serializer module Serializers module JSON extend ActiveSupport::Concern - include ActiveModel::Serialization + include ActiveModel::Serializable::JSON included do - extend ActiveModel::Naming - - class_attribute :include_root_in_json - self.include_root_in_json = true - end - - # Returns a hash representing the model. Some configuration can be - # passed through +options+. - # - # The option <tt>include_root_in_json</tt> controls the top-level behavior - # of +as_json+. If true (the default) +as_json+ will emit a single root - # node named after the object's type. For example: - # - # user = User.find(1) - # user.as_json - # # => { "user": {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true} } - # - # ActiveRecord::Base.include_root_in_json = false - # user.as_json - # # => {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true} - # - # This behavior can also be achieved by setting the <tt>:root</tt> option to +false+ as in: - # - # user = User.find(1) - # user.as_json(root: false) - # # => {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true} - # - # The remainder of the examples in this section assume include_root_in_json is set to - # <tt>false</tt>. - # - # Without any +options+, the returned Hash will include all the model's - # attributes. For example: - # - # user = User.find(1) - # user.as_json - # # => {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true} - # - # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit the attributes - # included, and work similar to the +attributes+ method. For example: - # - # user.as_json(:only => [ :id, :name ]) - # # => {"id": 1, "name": "Konata Izumi"} - # - # user.as_json(:except => [ :id, :created_at, :age ]) - # # => {"name": "Konata Izumi", "awesome": true} - # - # To include the result of some method calls on the model use <tt>:methods</tt>: - # - # user.as_json(:methods => :permalink) - # # => {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true, - # "permalink": "1-konata-izumi"} - # - # To include associations use <tt>:include</tt>: - # - # user.as_json(:include => :posts) - # # => {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true, - # "posts": [{"id": 1, "author_id": 1, "title": "Welcome to the weblog"}, - # {"id": 2, author_id: 1, "title": "So I was thinking"}]} - # - # Second level and higher order associations work as well: - # - # user.as_json(:include => { :posts => { - # :include => { :comments => { - # :only => :body } }, - # :only => :title } }) - # # => {"id": 1, "name": "Konata Izumi", "age": 16, - # "created_at": "2006/08/01", "awesome": true, - # "posts": [{"comments": [{"body": "1st post!"}, {"body": "Second!"}], - # "title": "Welcome to the weblog"}, - # {"comments": [{"body": "Don't think too hard"}], - # "title": "So I was thinking"}]} - def as_json(options = nil) - root = include_root_in_json - root = options[:root] if options.try(:key?, :root) - if root - root = self.class.model_name.element if root == true - { root => serializable_hash(options) } - else - serializable_hash(options) - end - end - - def from_json(json, include_root=include_root_in_json) - hash = ActiveSupport::JSON.decode(json) - hash = hash.values.first if include_root - self.attributes = hash - self + ActiveSupport::Deprecation.warn "ActiveModel::Serializers::JSON is deprecated in favor of ActiveModel::Serializable::JSON" end end end -end +end
\ No newline at end of file diff --git a/activemodel/lib/active_model/serializers/xml.rb b/activemodel/lib/active_model/serializers/xml.rb index d61d9d7119..620390da6b 100644 --- a/activemodel/lib/active_model/serializers/xml.rb +++ b/activemodel/lib/active_model/serializers/xml.rb @@ -1,195 +1,14 @@ -require 'active_support/core_ext/array/wrap' -require 'active_support/core_ext/class/attribute_accessors' -require 'active_support/core_ext/array/conversions' -require 'active_support/core_ext/hash/conversions' -require 'active_support/core_ext/hash/slice' - module ActiveModel - # == Active Model XML Serializer module Serializers module Xml extend ActiveSupport::Concern - include ActiveModel::Serialization - - class Serializer #:nodoc: - class Attribute #:nodoc: - attr_reader :name, :value, :type - - def initialize(name, serializable, value) - @name, @serializable = name, serializable - value = value.in_time_zone if value.respond_to?(:in_time_zone) - @value = value - @type = compute_type - end - - def decorations - decorations = {} - decorations[:encoding] = 'base64' if type == :binary - decorations[:type] = (type == :string) ? nil : type - decorations[:nil] = true if value.nil? - decorations - end - - protected - - def compute_type - return if value.nil? - type = ActiveSupport::XmlMini::TYPE_NAMES[value.class.name] - type ||= :string if value.respond_to?(:to_str) - type ||= :yaml - type - end - end - - class MethodAttribute < Attribute #:nodoc: - end - - attr_reader :options - - def initialize(serializable, options = nil) - @serializable = serializable - @options = options ? options.dup : {} - end - - def serializable_hash - @serializable.serializable_hash(@options.except(:include)) - end - - def serializable_collection - methods = Array.wrap(options[:methods]).map(&:to_s) - serializable_hash.map do |name, value| - name = name.to_s - if methods.include?(name) - self.class::MethodAttribute.new(name, @serializable, value) - else - self.class::Attribute.new(name, @serializable, value) - end - end - end - - def serialize - require 'builder' unless defined? ::Builder - - options[:indent] ||= 2 - options[:builder] ||= ::Builder::XmlMarkup.new(:indent => options[:indent]) - - @builder = options[:builder] - @builder.instruct! unless options[:skip_instruct] + include ActiveModel::Serializable::XML - root = (options[:root] || @serializable.class.model_name.element).to_s - root = ActiveSupport::XmlMini.rename_key(root, options) - - args = [root] - args << {:xmlns => options[:namespace]} if options[:namespace] - args << {:type => options[:type]} if options[:type] && !options[:skip_types] - - @builder.tag!(*args) do - add_attributes_and_methods - add_includes - add_extra_behavior - add_procs - yield @builder if block_given? - end - end - - private - - def add_extra_behavior - end - - def add_attributes_and_methods - serializable_collection.each do |attribute| - key = ActiveSupport::XmlMini.rename_key(attribute.name, options) - ActiveSupport::XmlMini.to_tag(key, attribute.value, - options.merge(attribute.decorations)) - end - end - - def add_includes - @serializable.send(:serializable_add_includes, options) do |association, records, opts| - add_associations(association, records, opts) - end - end - - # TODO This can likely be cleaned up to simple use ActiveSupport::XmlMini.to_tag as well. - def add_associations(association, records, opts) - merged_options = opts.merge(options.slice(:builder, :indent)) - merged_options[:skip_instruct] = true - - if records.is_a?(Enumerable) - tag = ActiveSupport::XmlMini.rename_key(association.to_s, options) - type = options[:skip_types] ? { } : {:type => "array"} - association_name = association.to_s.singularize - merged_options[:root] = association_name - - if records.empty? - @builder.tag!(tag, type) - else - @builder.tag!(tag, type) do - records.each do |record| - if options[:skip_types] - record_type = {} - else - record_class = (record.class.to_s.underscore == association_name) ? nil : record.class.name - record_type = {:type => record_class} - end - - record.to_xml merged_options.merge(record_type) - end - end - end - else - merged_options[:root] = association.to_s - records.to_xml(merged_options) - end - end - - def add_procs - if procs = options.delete(:procs) - Array.wrap(procs).each do |proc| - if proc.arity == 1 - proc.call(options) - else - proc.call(options, @serializable) - end - end - end - end - end - - # Returns XML representing the model. Configuration can be - # passed through +options+. - # - # Without any +options+, the returned XML string will include all the model's - # attributes. For example: - # - # user = User.find(1) - # user.to_xml - # - # <?xml version="1.0" encoding="UTF-8"?> - # <user> - # <id type="integer">1</id> - # <name>David</name> - # <age type="integer">16</age> - # <created-at type="datetime">2011-01-30T22:29:23Z</created-at> - # </user> - # - # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit the attributes - # included, and work similar to the +attributes+ method. - # - # To include the result of some method calls on the model use <tt>:methods</tt>. - # - # To include associations use <tt>:include</tt>. - # - # For further documentation see activerecord/lib/active_record/serializers/xml_serializer.xml. - def to_xml(options = {}, &block) - Serializer.new(self, options).serialize(&block) - end + Serializer = ActiveModel::Serializable::XML::Serializer - def from_xml(xml) - self.attributes = Hash.from_xml(xml).values.first - self + included do + ActiveSupport::Deprecation.warn "ActiveModel::Serializers::Xml is deprecated in favor of ActiveModel::Serializable::XML" end end end -end +end
\ No newline at end of file diff --git a/activemodel/test/cases/serializers/json_serialization_test.rb b/activemodel/test/cases/serializable/json_test.rb index a754d610b9..ad5b04091e 100644 --- a/activemodel/test/cases/serializers/json_serialization_test.rb +++ b/activemodel/test/cases/serializable/json_test.rb @@ -5,7 +5,7 @@ require 'active_support/core_ext/object/instance_variables' class Contact extend ActiveModel::Naming - include ActiveModel::Serializers::JSON + include ActiveModel::Serializable::JSON include ActiveModel::Validations def attributes=(hash) diff --git a/activemodel/test/cases/serializers/xml_serialization_test.rb b/activemodel/test/cases/serializable/xml_test.rb index fc73d9dcd8..817ca1e736 100644 --- a/activemodel/test/cases/serializers/xml_serialization_test.rb +++ b/activemodel/test/cases/serializable/xml_test.rb @@ -5,7 +5,7 @@ require 'ostruct' class Contact extend ActiveModel::Naming - include ActiveModel::Serializers::Xml + include ActiveModel::Serializable::XML attr_accessor :address, :friends @@ -24,7 +24,7 @@ end class Address extend ActiveModel::Naming - include ActiveModel::Serializers::Xml + include ActiveModel::Serializable::XML attr_accessor :street, :city, :state, :zip diff --git a/activemodel/test/cases/serialization_test.rb b/activemodel/test/cases/serializable_test.rb index b8dad9d51f..46ee372c6f 100644 --- a/activemodel/test/cases/serialization_test.rb +++ b/activemodel/test/cases/serializable_test.rb @@ -3,7 +3,7 @@ require 'active_support/core_ext/object/instance_variables' class SerializationTest < ActiveModel::TestCase class User - include ActiveModel::Serialization + include ActiveModel::Serializable attr_accessor :name, :email, :gender, :address, :friends @@ -22,7 +22,7 @@ class SerializationTest < ActiveModel::TestCase end class Address - include ActiveModel::Serialization + include ActiveModel::Serializable attr_accessor :street, :city, :state, :zip diff --git a/activemodel/test/cases/serializer_test.rb b/activemodel/test/cases/serializer_test.rb new file mode 100644 index 0000000000..2b2e21540f --- /dev/null +++ b/activemodel/test/cases/serializer_test.rb @@ -0,0 +1,432 @@ +require "cases/helper" + +class SerializerTest < ActiveModel::TestCase + class Model + def initialize(hash={}) + @attributes = hash + end + + def read_attribute_for_serialization(name) + @attributes[name] + end + + def as_json(*) + { :model => "Model" } + end + end + + class User + include ActiveModel::Serializable + + attr_accessor :superuser + + def initialize(hash={}) + @attributes = hash.merge(:first_name => "Jose", :last_name => "Valim", :password => "oh noes yugive my password") + end + + def read_attribute_for_serialization(name) + @attributes[name] + end + + def super_user? + @superuser + end + end + + class Post < Model + attr_accessor :comments + def active_model_serializer; PostSerializer; end + end + + class Comment < Model + def active_model_serializer; CommentSerializer; end + end + + class UserSerializer < ActiveModel::Serializer + attributes :first_name, :last_name + + def serializable_hash + attributes.merge(:ok => true).merge(scope) + end + end + + class DefaultUserSerializer < ActiveModel::Serializer + attributes :first_name, :last_name + end + + class MyUserSerializer < ActiveModel::Serializer + attributes :first_name, :last_name + + def serializable_hash + hash = attributes + hash = hash.merge(:super_user => true) if my_user.super_user? + hash + end + end + + class CommentSerializer + def initialize(comment, scope) + @comment, @scope = comment, scope + end + + def serializable_hash + { :title => @comment.read_attribute_for_serialization(:title) } + end + + def as_json + { :comment => serializable_hash } + end + end + + class PostSerializer < ActiveModel::Serializer + attributes :title, :body + has_many :comments, :serializer => CommentSerializer + end + + def test_attributes + user = User.new + user_serializer = DefaultUserSerializer.new(user, {}) + + hash = user_serializer.as_json + + assert_equal({ + :default_user => { :first_name => "Jose", :last_name => "Valim" } + }, hash) + end + + def test_attributes_method + user = User.new + user_serializer = UserSerializer.new(user, {}) + + hash = user_serializer.as_json + + assert_equal({ + :user => { :first_name => "Jose", :last_name => "Valim", :ok => true } + }, hash) + end + + def test_serializer_receives_scope + user = User.new + user_serializer = UserSerializer.new(user, {:scope => true}) + + hash = user_serializer.as_json + + assert_equal({ + :user => { + :first_name => "Jose", + :last_name => "Valim", + :ok => true, + :scope => true + } + }, hash) + end + + def test_pretty_accessors + user = User.new + user.superuser = true + user_serializer = MyUserSerializer.new(user, nil) + + hash = user_serializer.as_json + + assert_equal({ + :my_user => { + :first_name => "Jose", :last_name => "Valim", :super_user => true + } + }, hash) + end + + def test_has_many + user = User.new + + post = Post.new(:title => "New Post", :body => "Body of new post", :email => "tenderlove@tenderlove.com") + comments = [Comment.new(:title => "Comment1"), Comment.new(:title => "Comment2")] + post.comments = comments + + post_serializer = PostSerializer.new(post, user) + + assert_equal({ + :post => { + :title => "New Post", + :body => "Body of new post", + :comments => [ + { :title => "Comment1" }, + { :title => "Comment2" } + ] + } + }, post_serializer.as_json) + end + + class Blog < Model + attr_accessor :author + end + + class AuthorSerializer < ActiveModel::Serializer + attributes :first_name, :last_name + end + + class BlogSerializer < ActiveModel::Serializer + has_one :author, :serializer => AuthorSerializer + end + + def test_has_one + user = User.new + blog = Blog.new + blog.author = user + + json = BlogSerializer.new(blog, user).as_json + assert_equal({ + :blog => { + :author => { + :first_name => "Jose", + :last_name => "Valim" + } + } + }, json) + end + + def test_implicit_serializer + author_serializer = Class.new(ActiveModel::Serializer) do + attributes :first_name + end + + blog_serializer = Class.new(ActiveModel::Serializer) do + const_set(:AuthorSerializer, author_serializer) + has_one :author + end + + user = User.new + blog = Blog.new + blog.author = user + + json = blog_serializer.new(blog, user).as_json + assert_equal({ + :author => { + :first_name => "Jose" + } + }, json) + end + + def test_overridden_associations + author_serializer = Class.new(ActiveModel::Serializer) do + attributes :first_name + end + + blog_serializer = Class.new(ActiveModel::Serializer) do + const_set(:PersonSerializer, author_serializer) + + def person + object.author + end + + has_one :person + end + + user = User.new + blog = Blog.new + blog.author = user + + json = blog_serializer.new(blog, user).as_json + assert_equal({ + :person => { + :first_name => "Jose" + } + }, json) + end + + def post_serializer(type) + Class.new(ActiveModel::Serializer) do + attributes :title, :body + has_many :comments, :serializer => CommentSerializer + + if type != :super + define_method :serializable_hash do + post_hash = attributes + post_hash.merge!(send(type)) + post_hash + end + end + end + end + + def test_associations + post = Post.new(:title => "New Post", :body => "Body of new post", :email => "tenderlove@tenderlove.com") + comments = [Comment.new(:title => "Comment1"), Comment.new(:title => "Comment2")] + post.comments = comments + + serializer = post_serializer(:associations).new(post, nil) + + assert_equal({ + :title => "New Post", + :body => "Body of new post", + :comments => [ + { :title => "Comment1" }, + { :title => "Comment2" } + ] + }, serializer.as_json) + end + + def test_association_ids + serializer = post_serializer(:association_ids) + + serializer.class_eval do + def as_json(*) + { :post => serializable_hash }.merge(associations) + end + end + + post = Post.new(:title => "New Post", :body => "Body of new post", :email => "tenderlove@tenderlove.com") + comments = [Comment.new(:title => "Comment1", :id => 1), Comment.new(:title => "Comment2", :id => 2)] + post.comments = comments + + serializer = serializer.new(post, nil) + + assert_equal({ + :post => { + :title => "New Post", + :body => "Body of new post", + :comments => [1, 2] + }, + :comments => [ + { :title => "Comment1" }, + { :title => "Comment2" } + ] + }, serializer.as_json) + end + + def test_associations_with_nil_association + user = User.new + blog = Blog.new + + json = BlogSerializer.new(blog, user).as_json + assert_equal({ + :blog => { :author => nil } + }, json) + + serializer = Class.new(BlogSerializer) do + root :blog + + def serializable_hash + attributes.merge(association_ids) + end + end + + json = serializer.new(blog, user).as_json + assert_equal({ :blog => { :author => nil } }, json) + end + + def test_custom_root + user = User.new + blog = Blog.new + + serializer = Class.new(BlogSerializer) do + root :my_blog + end + + assert_equal({ :my_blog => { :author => nil } }, serializer.new(blog, user).as_json) + end + + def test_false_root + user = User.new + blog = Blog.new + + serializer = Class.new(BlogSerializer) do + root false + end + + assert_equal({ :author => nil }, serializer.new(blog, user).as_json) + + # test inherited false root + serializer = Class.new(serializer) + assert_equal({ :author => nil }, serializer.new(blog, user).as_json) + end + + def test_embed_ids + serializer = post_serializer(:super) + + serializer.class_eval do + root :post + embed :ids + end + + post = Post.new(:title => "New Post", :body => "Body of new post", :email => "tenderlove@tenderlove.com") + comments = [Comment.new(:title => "Comment1", :id => 1), Comment.new(:title => "Comment2", :id => 2)] + post.comments = comments + + serializer = serializer.new(post, nil) + + assert_equal({ + :post => { + :title => "New Post", + :body => "Body of new post", + :comments => [1, 2] + } + }, serializer.as_json) + end + + def test_embed_ids_include_true + serializer = post_serializer(:super) + + serializer.class_eval do + root :post + embed :ids, :include => true + end + + post = Post.new(:title => "New Post", :body => "Body of new post", :email => "tenderlove@tenderlove.com") + comments = [Comment.new(:title => "Comment1", :id => 1), Comment.new(:title => "Comment2", :id => 2)] + post.comments = comments + + serializer = serializer.new(post, nil) + + assert_equal({ + :post => { + :title => "New Post", + :body => "Body of new post", + :comments => [1, 2] + }, + :comments => [ + { :title => "Comment1" }, + { :title => "Comment2" } + ] + }, serializer.as_json) + end + + def test_embed_objects + serializer = post_serializer(:super) + + serializer.class_eval do + root :post + embed :objects + end + + post = Post.new(:title => "New Post", :body => "Body of new post", :email => "tenderlove@tenderlove.com") + comments = [Comment.new(:title => "Comment1", :id => 1), Comment.new(:title => "Comment2", :id => 2)] + post.comments = comments + + serializer = serializer.new(post, nil) + + assert_equal({ + :post => { + :title => "New Post", + :body => "Body of new post", + :comments => [ + { :title => "Comment1" }, + { :title => "Comment2" } + ] + } + }, serializer.as_json) + end + + def test_array_serializer + model = Model.new + user = User.new + comments = Comment.new(:title => "Comment1", :id => 1) + + array = [model, user, comments] + serializer = array.active_model_serializer.new(array, {:scope => true}) + assert_equal([ + { :model => "Model" }, + { :user => { :last_name=>"Valim", :ok=>true, :first_name=>"Jose", :scope => true } }, + { :comment => { :title => "Comment1" } } + ], serializer.as_json) + end +end
\ No newline at end of file diff --git a/activerecord/lib/active_record/serialization.rb b/activerecord/lib/active_record/serialization.rb index 5ad40d8cd9..c23514c465 100644 --- a/activerecord/lib/active_record/serialization.rb +++ b/activerecord/lib/active_record/serialization.rb @@ -2,7 +2,7 @@ module ActiveRecord #:nodoc: # = Active Record Serialization module Serialization extend ActiveSupport::Concern - include ActiveModel::Serializers::JSON + include ActiveModel::Serializable::JSON def serializable_hash(options = nil) options = options.try(:clone) || {} diff --git a/activerecord/lib/active_record/serializers/xml_serializer.rb b/activerecord/lib/active_record/serializers/xml_serializer.rb index 0e7f57aa43..2da836ef0c 100644 --- a/activerecord/lib/active_record/serializers/xml_serializer.rb +++ b/activerecord/lib/active_record/serializers/xml_serializer.rb @@ -3,7 +3,7 @@ require 'active_support/core_ext/hash/conversions' module ActiveRecord #:nodoc: module Serialization - include ActiveModel::Serializers::Xml + include ActiveModel::Serializable::XML # Builds an XML document to represent the model. Some configuration is # available through +options+. However more complicated cases should @@ -176,13 +176,13 @@ module ActiveRecord #:nodoc: end end - class XmlSerializer < ActiveModel::Serializers::Xml::Serializer #:nodoc: + class XmlSerializer < ActiveModel::Serializable::XML::Serializer #:nodoc: def initialize(*args) super options[:except] = Array.wrap(options[:except]) | Array.wrap(@serializable.class.inheritance_column) end - class Attribute < ActiveModel::Serializers::Xml::Serializer::Attribute #:nodoc: + class Attribute < ActiveModel::Serializable::XML::Serializer::Attribute #:nodoc: def compute_type klass = @serializable.class type = if klass.serialized_attributes.key?(name) diff --git a/activesupport/lib/active_support/core_ext/object/to_json.rb b/activesupport/lib/active_support/core_ext/object/to_json.rb index 14ef27340e..e7dc60a612 100644 --- a/activesupport/lib/active_support/core_ext/object/to_json.rb +++ b/activesupport/lib/active_support/core_ext/object/to_json.rb @@ -10,10 +10,10 @@ end # several cases (for instance, the JSON implementation for Hash does not work) with inheritance # and consequently classes as ActiveSupport::OrderedHash cannot be serialized to json. [Object, Array, FalseClass, Float, Hash, Integer, NilClass, String, TrueClass].each do |klass| - klass.class_eval <<-RUBY, __FILE__, __LINE__ + klass.class_eval do # Dumps object in JSON (JavaScript Object Notation). See www.json.org for more info. def to_json(options = nil) ActiveSupport::JSON.encode(self, options) end - RUBY + end end diff --git a/activesupport/lib/active_support/dependencies.rb b/activesupport/lib/active_support/dependencies.rb index db90de4682..1372e71a61 100644 --- a/activesupport/lib/active_support/dependencies.rb +++ b/activesupport/lib/active_support/dependencies.rb @@ -527,7 +527,7 @@ module ActiveSupport #:nodoc: class ClassCache def initialize - @store = Hash.new { |h, k| h[k] = Inflector.constantize(k) } + @store = Hash.new end def empty? @@ -538,23 +538,24 @@ module ActiveSupport #:nodoc: @store.key?(key) end - def []=(key, value) - return unless key.respond_to?(:name) - - raise(ArgumentError, 'anonymous classes cannot be cached') if key.name.blank? - - @store[key.name] = value + def get(key) + key = key.name if key.respond_to?(:name) + @store[key] ||= Inflector.constantize(key) end + alias :[] :get - def [](key) + def safe_get(key) key = key.name if key.respond_to?(:name) - - @store[key] + @store[key] || begin + klass = Inflector.safe_constantize(key) + @store[key] = klass + end end - alias :get :[] - def store(name) - self[name] = name + def store(klass) + return self unless klass.respond_to?(:name) + raise(ArgumentError, 'anonymous classes cannot be cached') if klass.name.empty? + @store[klass.name] = klass self end @@ -571,10 +572,17 @@ module ActiveSupport #:nodoc: end # Get the reference for class named +name+. + # Raises an exception if referenced class does not exist. def constantize(name) Reference.get(name) end + # Get the reference for class named +name+ if one exists. + # Otherwise returns nil. + def safe_constantize(name) + Reference.safe_get(name) + end + # Determine if the given constant has been automatically loaded. def autoloaded?(desc) # No name => anonymous module. diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 469ae69258..b0fd3a3c0e 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -50,9 +50,9 @@ module ActiveSupport end # like encode, but only calls as_json, without encoding to string - def as_json(value) + def as_json(value, use_options = true) check_for_circular_references(value) do - value.as_json(options_for(value)) + use_options ? value.as_json(options_for(value)) : value.as_json end end @@ -212,7 +212,7 @@ class Array def as_json(options = nil) #:nodoc: # use encoder as a proxy to call as_json on all elements, to protect from circular references encoder = options && options[:encoder] || ActiveSupport::JSON::Encoding::Encoder.new(options) - map { |v| encoder.as_json(v) } + map { |v| encoder.as_json(v, options) } end def encode_json(encoder) #:nodoc: @@ -239,7 +239,7 @@ class Hash # use encoder as a proxy to call as_json on all values in the subset, to protect from circular references encoder = options && options[:encoder] || ActiveSupport::JSON::Encoding::Encoder.new(options) result = self.is_a?(ActiveSupport::OrderedHash) ? ActiveSupport::OrderedHash : Hash - result[subset.map { |k, v| [k.to_s, encoder.as_json(v)] }] + result[subset.map { |k, v| [k.to_s, encoder.as_json(v, options)] }] end def encode_json(encoder) diff --git a/activesupport/test/class_cache_test.rb b/activesupport/test/class_cache_test.rb index 752c0ee478..b96f476ce6 100644 --- a/activesupport/test/class_cache_test.rb +++ b/activesupport/test/class_cache_test.rb @@ -10,45 +10,58 @@ module ActiveSupport def test_empty? assert @cache.empty? - @cache[ClassCacheTest] = ClassCacheTest + @cache.store(ClassCacheTest) assert !@cache.empty? end def test_clear! assert @cache.empty? - @cache[ClassCacheTest] = ClassCacheTest + @cache.store(ClassCacheTest) assert !@cache.empty? @cache.clear! assert @cache.empty? end def test_set_key - @cache[ClassCacheTest] = ClassCacheTest + @cache.store(ClassCacheTest) assert @cache.key?(ClassCacheTest.name) end - def test_set_rejects_strings - @cache[ClassCacheTest.name] = ClassCacheTest - assert @cache.empty? - end - def test_get_with_class - @cache[ClassCacheTest] = ClassCacheTest - assert_equal ClassCacheTest, @cache[ClassCacheTest] + @cache.store(ClassCacheTest) + assert_equal ClassCacheTest, @cache.get(ClassCacheTest) end def test_get_with_name - @cache[ClassCacheTest] = ClassCacheTest - assert_equal ClassCacheTest, @cache[ClassCacheTest.name] + @cache.store(ClassCacheTest) + assert_equal ClassCacheTest, @cache.get(ClassCacheTest.name) end def test_get_constantizes assert @cache.empty? - assert_equal ClassCacheTest, @cache[ClassCacheTest.name] + assert_equal ClassCacheTest, @cache.get(ClassCacheTest.name) end - def test_get_is_an_alias - assert_equal @cache[ClassCacheTest], @cache.get(ClassCacheTest.name) + def test_get_constantizes_fails_on_invalid_names + assert @cache.empty? + assert_raise NameError do + @cache.get("OmgTotallyInvalidConstantName") + end + end + + def test_get_alias + assert @cache.empty? + assert_equal @cache[ClassCacheTest.name], @cache.get(ClassCacheTest.name) + end + + def test_safe_get_constantizes + assert @cache.empty? + assert_equal ClassCacheTest, @cache.safe_get(ClassCacheTest.name) + end + + def test_safe_get_constantizes_doesnt_fail_on_invalid_names + assert @cache.empty? + assert_equal nil, @cache.safe_get("OmgTotallyInvalidConstantName") end def test_new_rejects_strings diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index a7afb72562..25d45d886c 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,27 +1,29 @@ ## Rails 3.2.0 (unreleased) ## -* Added displaying of mounted engine's routes with `rake routes ENGINES=true`. *Piotr Sarnacki* +* Add displaying of mounted engine's routes with `rake routes ENGINES=true` *Piotr Sarnacki* -* Allow to change the loading order of railties with `config.railties_order=`. *Piotr Sarnacki* +* Allow to change the loading order of railties with `config.railties_order=` *Piotr Sarnacki* Example: config.railties_order = [Blog::Engine, :main_app, :all] +* Add a serializer generator and add a hook for it in the scaffold generators *José Valim* + * Scaffold returns 204 No Content for API requests without content. This makes scaffold work with jQuery out of the box. *José Valim* -* Updated Rails::Rack::Logger middleware to apply any tags set in config.log_tags to the newly ActiveSupport::TaggedLogging Rails.logger. This makes it easy to tag log lines with debug information like subdomain and request id -- both very helpful in debugging multi-user production applications *DHH* +* Update Rails::Rack::Logger middleware to apply any tags set in config.log_tags to the newly ActiveSupport::TaggedLogging Rails.logger. This makes it easy to tag log lines with debug information like subdomain and request id -- both very helpful in debugging multi-user production applications *DHH* * Default options to `rails new` can be set in ~/.railsrc *Guillermo Iguaran* -* Added destroy alias to Rails engines. *Guillermo Iguaran* +* Add destroy alias to Rails engines *Guillermo Iguaran* -* Added destroy alias for Rails command line. This allows the following: `rails d model post`. *Andrey Ognevsky* +* Add destroy alias for Rails command line. This allows the following: `rails d model post` *Andrey Ognevsky* * Attributes on scaffold and model generators default to string. This allows the following: "rails g scaffold Post title body:text author" *José Valim* -* Removed old plugin generator (`rails generate plugin`) in favor of `rails plugin new` command. *Guillermo Iguaran* +* Remove old plugin generator (`rails generate plugin`) in favor of `rails plugin new` command *Guillermo Iguaran* -* Removed old 'config.paths.app.controller' API in favor of 'config.paths["app/controller"]' API. *Guillermo Iguaran* +* Remove old 'config.paths.app.controller' API in favor of 'config.paths["app/controller"]' API *Guillermo Iguaran* * Rails 3.1.1 diff --git a/railties/guides/source/serializers.textile b/railties/guides/source/serializers.textile new file mode 100644 index 0000000000..efc7cbf248 --- /dev/null +++ b/railties/guides/source/serializers.textile @@ -0,0 +1,563 @@ +h2. Rails Serializers + +This guide describes how to use Active Model serializers to build non-trivial JSON services in Rails. By reading this guide, you will learn: + +* When to use the built-in Active Model serialization +* When to use a custom serializer for your models +* How to use serializers to encapsulate authorization concerns +* How to create serializer templates to describe the application-wide structure of your serialized JSON +* How to build resources not backed by a single database table for use with JSON services + +This guide covers an intermediate topic and assumes familiarity with Rails conventions. It is suitable for applications that expose a +JSON API that may return different results based on the authorization status of the user. + +endprologue. + +h3. Serialization + +By default, Active Record objects can serialize themselves into JSON by using the `to_json` method. This method takes a series of additional +parameter to control which properties and associations Rails should include in the serialized output. + +When building a web application that uses JavaScript to retrieve JSON data from the server, this mechanism has historically been the primary +way that Rails developers prepared their responses. This works great for simple cases, as the logic for serializing an Active Record object +is neatly encapsulated in Active Record itself. + +However, this solution quickly falls apart in the face of serialization requirements based on authorization. For instance, a web service +may choose to expose additional information about a resource only if the user is entitled to access it. In addition, a JavaScript front-end +may want information that is not neatly described in terms of serializing a single Active Record object, or in a different format than. + +In addition, neither the controller nor the model seems like the correct place for logic that describes how to serialize an model object +*for the current user*. + +Serializers solve these problems by encapsulating serialization in an object designed for this purpose. If the default +to_json+ semantics, +with at most a few configuration options serve your needs, by all means continue to use the built-in +to_json+. If you find yourself doing +hash-driven-development in your controllers, juggling authorization logic and other concerns, serializers are for you! + +h3. The Most Basic Serializer + +A basic serializer is a simple Ruby object named after the model class it is serializing. + +<ruby> +class PostSerializer + def initialize(post, scope) + @post, @scope = post, scope + end + + def as_json + { post: { title: @post.name, body: @post.body } } + end +end +</ruby> + +A serializer is initialized with two parameters: the model object it should serialize and an authorization scope. By default, the +authorization scope is the current user (+current_user+) but you can use a different object if you want. The serializer also +implements an +as_json+ method, which returns a Hash that will be sent to the JSON encoder. + +Rails will transparently use your serializer when you use +render :json+ in your controller. + +<ruby> +class PostsController < ApplicationController + def show + @post = Post.find(params[:id]) + render json: @post + end +end +</ruby> + +Because +respond_with+ uses +render :json+ under the hood for JSON requests, Rails will automatically use your serializer when +you use +respond_with+ as well. + +h4. +serializable_hash+ + +In general, you will want to implement +serializable_hash+ and +as_json+ to allow serializers to embed associated content +directly. The easiest way to implement these two methods is to have +as_json+ call +serializable_hash+ and insert the root. + +<ruby> +class PostSerializer + def initialize(post, scope) + @post, @scope = post, scope + end + + def serializable_hash + { title: @post.name, body: @post.body } + end + + def as_json + { post: serializable_hash } + end +end +</ruby> + +h4. Authorization + +Let's update our serializer to include the email address of the author of the post, but only if the current user has superuser +access. + +<ruby> +class PostSerializer + def initialize(post, scope) + @post, @scope = post, scope + end + + def as_json + { post: serializable_hash } + end + + def serializable_hash + hash = post + hash.merge!(super_data) if super? + hash + end + +private + def post + { title: @post.name, body: @post.body } + end + + def super_data + { email: @post.email } + end + + def super? + @scope.superuser? + end +end +</ruby> + +h4. Testing + +One benefit of encapsulating our objects this way is that it becomes extremely straight-forward to test the serialization +logic in isolation. + +<ruby> +require "ostruct" + +class PostSerializerTest < ActiveSupport::TestCase + # For now, we use a very simple authorization structure. These tests will need + # refactoring if we change that. + plebe = OpenStruct.new(super?: false) + god = OpenStruct.new(super?: true) + + post = OpenStruct.new(title: "Welcome to my blog!", body: "Blah blah blah", email: "tenderlove@gmail.com") + + test "a regular user sees just the title and body" do + json = PostSerializer.new(post, plebe).to_json + hash = JSON.parse(json) + + assert_equal post.title, hash.delete("title") + assert_equal post.body, hash.delete("body") + assert_empty hash + end + + test "a superuser sees the title, body and email" do + json = PostSerializer.new(post, god).to_json + hash = JSON.parse(json) + + assert_equal post.title, hash.delete("title") + assert_equal post.body, hash.delete("body") + assert_equal post.email, hash.delete("email") + assert_empty hash + end +end +</ruby> + +It's important to note that serializer objects define a clear interface specifically for serializing an existing object. +In this case, the serializer expects to receive a post object with +name+, +body+ and +email+ attributes and an authorization +scope with a +super?+ method. + +By defining a clear interface, it's must easier to ensure that your authorization logic is behaving correctly. In this case, +the serializer doesn't need to concern itself with how the authorization scope decides whether to set the +super?+ flag, just +whether it is set. In general, you should document these requirements in your serializer files and programatically via tests. +The documentation library +YARD+ provides excellent tools for describing this kind of requirement: + +<ruby> +class PostSerializer + # @param [~body, ~title, ~email] post the post to serialize + # @param [~super] scope the authorization scope for this serializer + def initialize(post, scope) + @post, @scope = post, scope + end + + # ... +end +</ruby> + +h3. Attribute Sugar + +To simplify this process for a number of common cases, Rails provides a default superclass named +ActiveModel::Serializer+ +that you can use to implement your serializers. + +For example, you will sometimes want to simply include a number of existing attributes from the source model into the outputted +JSON. In the above example, the +title+ and +body+ attributes were always included in the JSON. Let's see how to use ++ActiveModel::Serializer+ to simplify our post serializer. + +<ruby> +class PostSerializer < ActiveModel::Serializer + attributes :title, :body + + def initialize(post, scope) + @post, @scope = post, scope + end + + def serializable_hash + hash = attributes + hash.merge!(super_data) if super? + hash + end + +private + def super_data + { email: @post.email } + end + + def super? + @scope.superuser? + end +end +</ruby> + +First, we specified the list of included attributes at the top of the class. This will create an instance method called ++attributes+ that extracts those attributes from the post model. + +NOTE: Internally, +ActiveModel::Serializer+ uses +read_attribute_for_serialization+, which defaults to +read_attribute+, which defaults to +send+. So if you're rolling your own models for use with the serializer, you can use simple Ruby accessors for your attributes if you like. + +Next, we use the attributes methood in our +serializable_hash+ method, which allowed us to eliminate the +post+ method we hand-rolled +earlier. We could also eliminate the +as_json+ method, as +ActiveModel::Serializer+ provides a default +as_json+ method for +us that calls our +serializable_hash+ method and inserts a root. But we can go a step further! + +<ruby> +class PostSerializer < ActiveModel::Serializer + attributes :title, :body + +private + def attributes + hash = super + hash.merge!(email: post.email) if super? + hash + end + + def super? + @scope.superuser? + end +end +</ruby> + +The superclass provides a default +initialize+ method as well as a default +serializable_hash+ method, which uses ++attributes+. We can call +super+ to get the hash based on the attributes we declared, and then add in any additional +attributes we want to use. + +NOTE: +ActiveModel::Serializer+ will create an accessor matching the name of the current class for the resource you pass in. In this case, because we have defined a PostSerializer, we can access the resource with the +post+ accessor. + +h3. Associations + +In most JSON APIs, you will want to include associated objects with your serialized object. In this case, let's include +the comments with the current post. + +<ruby> +class PostSerializer < ActiveModel::Serializer + attributes :title, :body + has_many :comments + +private + def attributes + hash = super + hash.merge!(email: post.email) if super? + hash + end + + def super? + @scope.superuser? + end +end +</ruby> + +The default +serializable_hash+ method will include the comments as embedded objects inside the post. + +<javascript> +{ + post: { + title: "Hello Blog!", + body: "This is my first post. Isn't it fabulous!", + comments: [ + { + title: "Awesome", + body: "Your first post is great" + } + ] + } +} +</javascript> + +Rails uses the same logic to generate embedded serializations as it does when you use +render :json+. In this case, +because you didn't define a +CommentSerializer+, Rails used the default +as_json+ on your comment object. + +If you define a serializer, Rails will automatically instantiate it with the existing authorization scope. + +<ruby> +class CommentSerializer + def initialize(comment, scope) + @comment, @scope = comment, scope + end + + def serializable_hash + { title: @comment.title } + end + + def as_json + { comment: serializable_hash } + end +end +</ruby> + +If we define the above comment serializer, the outputted JSON will change to: + +<javascript> +{ + post: { + title: "Hello Blog!", + body: "This is my first post. Isn't it fabulous!", + comments: [{ title: "Awesome" }] + } +} +</javascript> + +Let's imagine that our comment system allows an administrator to kill a comment, and we only want to allow +users to see the comments they're entitled to see. By default, +has_many :comments+ will simply use the ++comments+ accessor on the post object. We can override the +comments+ accessor to limit the comments used +to just the comments we want to allow for the current user. + +<ruby> +class PostSerializer < ActiveModel::Serializer + attributes :title. :body + has_many :comments + +private + def attributes + hash = super + hash.merge!(email: post.email) if super? + hash + end + + def comments + post.comments_for(scope) + end + + def super? + @scope.superuser? + end +end +</ruby> + ++ActiveModel::Serializer+ will still embed the comments, but this time it will use just the comments +for the current user. + +NOTE: The logic for deciding which comments a user should see still belongs in the model layer. In general, you should encapsulate concerns that require making direct Active Record queries in scopes or public methods on your models. + +h3. Customizing Associations + +Not all front-ends expect embedded documents in the same form. In these cases, you can override the +default +serializable_hash+, and use conveniences provided by +ActiveModel::Serializer+ to avoid having to +build up the hash manually. + +For example, let's say our front-end expects the posts and comments in the following format: + +<plain> +{ + post: { + id: 1 + title: "Hello Blog!", + body: "This is my first post. Isn't it fabulous!", + comments: [1,2] + }, + comments: [ + { + id: 1 + title: "Awesome", + body: "Your first post is great" + }, + { + id: 2 + title: "Not so awesome", + body: "Why is it so short!" + } + ] +} +</plain> + +We could achieve this with a custom +as_json+ method. We will also need to define a serializer for comments. + +<ruby> +class CommentSerializer < ActiveModel::Serializer + attributes :id, :title, :body + + # define any logic for dealing with authorization-based attributes here +end + +class PostSerializer < ActiveModel::Serializer + attributes :title, :body + has_many :comments + + def as_json + { post: serializable_hash }.merge!(associations) + end + + def serializable_hash + post_hash = attributes + post_hash.merge!(association_ids) + post_hash + end + +private + def attributes + hash = super + hash.merge!(email: post.email) if super? + hash + end + + def comments + post.comments_for(scope) + end + + def super? + @scope.superuser? + end +end +</ruby> + +Here, we used two convenience methods: +associations+ and +association_ids+. The first, ++associations+, creates a hash of all of the define associations, using their defined +serializers. The second, +association_ids+, generates a hash whose key is the association +name and whose value is an Array of the association's keys. + +The +association_ids+ helper will use the overridden version of the association, so in +this case, +association_ids+ will only include the ids of the comments provided by the ++comments+ method. + +h3. Special Association Serializers + +So far, associations defined in serializers use either the +as_json+ method on the model +or the defined serializer for the association type. Sometimes, you may want to serialize +associated models differently when they are requested as part of another resource than +when they are requested on their own. + +For instance, we might want to provide the full comment when it is requested directly, +but only its title when requested as part of the post. To achieve this, you can define +a serializer for associated objects nested inside the main serializer. + +<ruby> +class PostSerializer < ActiveModel::Serializer + class CommentSerializer < ActiveModel::Serializer + attributes :id, :title + end + + # same as before + # ... +end +</ruby> + +In other words, if a +PostSerializer+ is trying to serialize comments, it will first +look for +PostSerializer::CommentSerializer+ before falling back to +CommentSerializer+ +and finally +comment.as_json+. + +h3. Overriding the Defaults + +h4. Authorization Scope + +By default, the authorization scope for serializers is +:current_user+. This means +that when you call +render json: @post+, the controller will automatically call +its +current_user+ method and pass that along to the serializer's initializer. + +If you want to change that behavior, simply use the +serialization_scope+ class +method. + +<ruby> +class PostsController < ApplicationController + serialization_scope :current_app +end +</ruby> + +You can also implement an instance method called (no surprise) +serialization_scope+, +which allows you to define a dynamic authorization scope based on the current request. + +WARNING: If you use different objects as authorization scopes, make sure that they all implement whatever interface you use in your serializers to control what the outputted JSON looks like. + +h3. Using Serializers Outside of a Request + +The serialization API encapsulates the concern of generating a JSON representation of +a particular model for a particular user. As a result, you should be able to easily use +serializers, whether you define them yourself or whether you use +ActiveModel::Serializer+ +outside a request. + +For instance, if you want to generate the JSON representation of a post for a user outside +of a request: + +<ruby> +user = get_user # some logic to get the user in question +PostSerializer.new(post, user).to_json # reliably generate JSON output +</ruby> + +If you want to generate JSON for an anonymous user, you should be able to use whatever +technique you use in your application to generate anonymous users outside of a request. +Typically, that means creating a new user and not saving it to the database: + +<ruby> +user = User.new # create a new anonymous user +PostSerializer.new(post, user).to_json +</ruby> + +In general, the better you encapsulate your authorization logic, the more easily you +will be able to use the serializer outside of the context of a request. For instance, +if you use an authorization library like Cancan, which uses a uniform +user.can?(action, model)+, +the authorization interface can very easily be replaced by a plain Ruby object for +testing or usage outside the context of a request. + +h3. Collections + +So far, we've talked about serializing individual model objects. By default, Rails +will serialize collections, including when using the +associations+ helper, by +looping over each element of the collection, calling +serializable_hash+ on the element, +and then grouping them by their type (using the plural version of their class name +as the root). + +For example, an Array of post objects would serialize as: + +<plain> +{ + posts: [ + { + title: "FIRST POST!", + body: "It's my first pooooost" + }, + { title: "Second post!", + body: "Zomg I made it to my second post" + } + ] +} +</plain> + +If you want to change the behavior of serialized Arrays, you need to create +a custom Array serializer. + +<ruby> +class ArraySerializer < ActiveModel::ArraySerializer + def serializable_array + serializers.map do |serializer| + serializer.serializable_hash + end + end + + def as_json + hash = { root => serializable_array } + hash.merge!(associations) + hash + end +end +</ruby> + +When generating embedded associations using the +associations+ helper inside a +regular serializer, it will create a new <code>ArraySerializer</code> with the +associated content and call its +serializable_array+ method. In this case, those +embedded associations will not recursively include associations. + +When generating an Array using +render json: posts+, the controller will invoke +the +as_json+ method, which will include its associations and its root. diff --git a/railties/lib/rails/generators.rb b/railties/lib/rails/generators.rb index 27f8d13ce8..f1ca9080ff 100644 --- a/railties/lib/rails/generators.rb +++ b/railties/lib/rails/generators.rb @@ -33,7 +33,8 @@ module Rails :stylesheets => '-y', :stylesheet_engine => '-se', :template_engine => '-e', - :test_framework => '-t' + :test_framework => '-t', + :serializer => '-z' }, :test_unit => { @@ -58,6 +59,7 @@ module Rails :performance_tool => nil, :resource_controller => :controller, :scaffold_controller => :scaffold_controller, + :serializer => false, :stylesheets => true, :stylesheet_engine => :css, :test_framework => false, diff --git a/railties/lib/rails/generators/rails/scaffold/scaffold_generator.rb b/railties/lib/rails/generators/rails/scaffold/scaffold_generator.rb index 03a61a035e..7353a67c83 100644 --- a/railties/lib/rails/generators/rails/scaffold/scaffold_generator.rb +++ b/railties/lib/rails/generators/rails/scaffold/scaffold_generator.rb @@ -10,6 +10,7 @@ module Rails class_option :stylesheet_engine, :desc => "Engine for Stylesheets" hook_for :scaffold_controller, :required => true + hook_for :serializer hook_for :assets do |assets| invoke assets, [controller_name] diff --git a/railties/lib/rails/generators/rails/serializer/USAGE b/railties/lib/rails/generators/rails/serializer/USAGE new file mode 100644 index 0000000000..a49f7ea1fb --- /dev/null +++ b/railties/lib/rails/generators/rails/serializer/USAGE @@ -0,0 +1,9 @@ +Description: + Generates a serializer for the given resource with tests. + +Example: + `rails generate serializer Account name created_at` + + For TestUnit it creates: + Serializer: app/serializers/account_serializer.rb + TestUnit: test/unit/account_serializer_test.rb diff --git a/railties/lib/rails/generators/rails/serializer/serializer_generator.rb b/railties/lib/rails/generators/rails/serializer/serializer_generator.rb new file mode 100644 index 0000000000..2118906227 --- /dev/null +++ b/railties/lib/rails/generators/rails/serializer/serializer_generator.rb @@ -0,0 +1,39 @@ +module Rails + module Generators + class SerializerGenerator < NamedBase + check_class_collision :suffix => "Serializer" + + argument :attributes, :type => :array, :default => [], :banner => "field:type field:type" + + class_option :parent, :type => :string, :desc => "The parent class for the generated serializer" + + def create_serializer_file + template 'serializer.rb', File.join('app/serializers', class_path, "#{file_name}_serializer.rb") + end + + hook_for :test_framework + + private + + def attributes_names + attributes.select { |attr| !attr.reference? }.map { |a| a.name.to_sym } + end + + def association_names + attributes.select { |attr| attr.reference? }.map { |a| a.name.to_sym } + end + + def parent_class_name + if options[:parent] + options[:parent] + elsif (n = Rails::Generators.namespace) && n.const_defined?(:ApplicationSerializer) + "ApplicationSerializer" + elsif Object.const_defined?(:ApplicationSerializer) + "ApplicationSerializer" + else + "ActiveModel::Serializer" + end + end + end + end +end diff --git a/railties/lib/rails/generators/rails/serializer/templates/serializer.rb b/railties/lib/rails/generators/rails/serializer/templates/serializer.rb new file mode 100644 index 0000000000..30c058c7e9 --- /dev/null +++ b/railties/lib/rails/generators/rails/serializer/templates/serializer.rb @@ -0,0 +1,9 @@ +<% module_namespacing do -%> +class <%= class_name %>Serializer < <%= parent_class_name %> +<% if attributes.any? -%> attributes <%= attributes_names.map(&:inspect).join(", ") %> +<% end -%> +<% association_names.each do |attribute| -%> + has_one :<%= attribute %> +<% end -%> +end +<% end -%>
\ No newline at end of file diff --git a/railties/lib/rails/generators/test_unit/serializer/serializer_generator.rb b/railties/lib/rails/generators/test_unit/serializer/serializer_generator.rb new file mode 100644 index 0000000000..533c032c3b --- /dev/null +++ b/railties/lib/rails/generators/test_unit/serializer/serializer_generator.rb @@ -0,0 +1,13 @@ +require 'rails/generators/test_unit' + +module TestUnit + module Generators + class SerializerGenerator < Base + check_class_collision :suffix => "SerializerTest" + + def create_test_files + template 'unit_test.rb', File.join('test/unit', class_path, "#{file_name}_serializer_test.rb") + end + end + end +end diff --git a/railties/lib/rails/generators/test_unit/serializer/templates/unit_test.rb b/railties/lib/rails/generators/test_unit/serializer/templates/unit_test.rb new file mode 100644 index 0000000000..0b1bbdcaa5 --- /dev/null +++ b/railties/lib/rails/generators/test_unit/serializer/templates/unit_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +<% module_namespacing do -%> +class <%= class_name %>SerializerTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end +<% end -%> diff --git a/railties/test/generators/scaffold_generator_test.rb b/railties/test/generators/scaffold_generator_test.rb index 2db8090621..2e8b03fbef 100644 --- a/railties/test/generators/scaffold_generator_test.rb +++ b/railties/test/generators/scaffold_generator_test.rb @@ -264,6 +264,15 @@ class ScaffoldGeneratorTest < Rails::Generators::TestCase assert_file "app/assets/stylesheets/posts.css" end + def test_scaffold_also_generators_serializer + run_generator [ "posts", "name:string", "author:references", "--serializer" ] + assert_file "app/serializers/post_serializer.rb" do |serializer| + assert_match /class PostSerializer < ActiveModel::Serializer/, serializer + assert_match /^ attributes :name$/, serializer + assert_match /^ has_one :author$/, serializer + end + end + def test_scaffold_generator_outputs_error_message_on_missing_attribute_type run_generator ["post", "title", "body:text", "author"] diff --git a/railties/test/generators/serializer_generator_test.rb b/railties/test/generators/serializer_generator_test.rb new file mode 100644 index 0000000000..2afaa65693 --- /dev/null +++ b/railties/test/generators/serializer_generator_test.rb @@ -0,0 +1,63 @@ +require 'generators/generators_test_helper' +require 'rails/generators/rails/serializer/serializer_generator' + +class SerializerGeneratorTest < Rails::Generators::TestCase + include GeneratorsTestHelper + arguments %w(account name:string description:text business:references) + + def test_generates_a_serializer + run_generator + assert_file "app/serializers/account_serializer.rb", /class AccountSerializer < ActiveModel::Serializer/ + end + + def test_generates_a_namespaced_serializer + run_generator ["admin/account"] + assert_file "app/serializers/admin/account_serializer.rb", /class Admin::AccountSerializer < ActiveModel::Serializer/ + end + + def test_uses_application_serializer_if_one_exists + Object.const_set(:ApplicationSerializer, Class.new) + run_generator + assert_file "app/serializers/account_serializer.rb", /class AccountSerializer < ApplicationSerializer/ + ensure + Object.send :remove_const, :ApplicationSerializer + end + + def test_uses_namespace_application_serializer_if_one_exists + Object.const_set(:SerializerNamespace, Module.new) + SerializerNamespace.const_set(:ApplicationSerializer, Class.new) + Rails::Generators.namespace = SerializerNamespace + run_generator + assert_file "app/serializers/serializer_namespace/account_serializer.rb", + /module SerializerNamespace\n class AccountSerializer < ApplicationSerializer/ + ensure + Object.send :remove_const, :SerializerNamespace + Rails::Generators.namespace = nil + end + + def test_uses_given_parent + Object.const_set(:ApplicationSerializer, Class.new) + run_generator ["Account", "--parent=MySerializer"] + assert_file "app/serializers/account_serializer.rb", /class AccountSerializer < MySerializer/ + ensure + Object.send :remove_const, :ApplicationSerializer + end + + def test_generates_attributes_and_associations + run_generator + assert_file "app/serializers/account_serializer.rb" do |serializer| + assert_match(/^ attributes :name, :description$/, serializer) + assert_match(/^ has_one :business$/, serializer) + end + end + + def test_with_no_attributes_does_not_add_extra_space + run_generator ["account"] + assert_file "app/serializers/account_serializer.rb", /class AccountSerializer < ActiveModel::Serializer\nend/ + end + + def test_invokes_default_test_framework + run_generator + assert_file "test/unit/account_serializer_test.rb", /class AccountSerializerTest < ActiveSupport::TestCase/ + end +end |