diff --git a/CHANGELOG.md b/CHANGELOG.md index 8433c6ea5..69e0b21ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Fixes: Features: +- [#1757](https://github.com/rails-api/active_model_serializers/pull/1757) Make serializer lookup chain configurable. (@NullVoxPopuli) - [#1968](https://github.com/rails-api/active_model_serializers/pull/1968) (@NullVoxPopuli) - Add controller namespace to default controller lookup - Provide a `namespace` render option diff --git a/docs/general/configuration_options.md b/docs/general/configuration_options.md index b83399a20..83f8890d7 100644 --- a/docs/general/configuration_options.md +++ b/docs/general/configuration_options.md @@ -60,6 +60,56 @@ application, setting `config.key_transform` to `:unaltered` will provide a perfo What relationships to serialize by default. Default: `'*'`, which includes one level of related objects. See [includes](adapters.md#included) for more info. + +##### serializer_lookup_chain + +Configures how serializers are searched for. By default, the lookup chain is + +```ruby +ActiveModelSerializers::LookupChain::DEFAULT +``` + +which is shorthand for + +```ruby +[ + ActiveModelSerializers::LookupChain::BY_PARENT_SERIALIZER, + ActiveModelSerializers::LookupChain::BY_NAMESPACE, + ActiveModelSerializers::LookupChain::BY_RESOURCE_NAMESPACE, + ActiveModelSerializers::LookupChain::BY_RESOURCE +] +``` + +Each of the array entries represent a proc. A serializer lookup proc will be yielded 3 arguments. `resource_class`, `serializer_class`, and `namespace`. + +Note that: + - `resource_class` is the class of the resource being rendered + - by default `serializer_class` is `ActiveModel::Serializer` + - for association lookup it's the "parent" serializer + - `namespace` correspond to either the controller namespace or the [optionally] specified [namespace render option](./rendering.md#namespace) + +An example config could be: + +```ruby +ActiveModelSerializers.config.serializer_lookup_chain = [ + lambda do |resource_class, serializer_class, namespace| + "API::#{namespace}::#{resource_class}" + end +] +``` + +If you simply want to add to the existing lookup_chain. Use `unshift`. + +```ruby +ActiveModelSerializers.config.serializer_lookup_chain.unshift( + lambda do |resource_class, serializer_class, namespace| + # ... + end +) +``` + +See [lookup_chain.rb](https://github.com/rails-api/active_model_serializers/blob/master/lib/active_model_serializers/lookup_chain.rb) for further explanations and examples. + ## JSON API ##### jsonapi_resource_type diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index 81beffd7c..0d94bfb50 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -60,17 +60,10 @@ class << self # @api private def self.serializer_lookup_chain_for(klass, namespace = nil) - chain = [] - - resource_class_name = klass.name.demodulize - resource_namespace = klass.name.deconstantize - serializer_class_name = "#{resource_class_name}Serializer" - - chain.push("#{namespace}::#{serializer_class_name}") if namespace - chain.push("#{name}::#{serializer_class_name}") if self != ActiveModel::Serializer - chain.push("#{resource_namespace}::#{serializer_class_name}") - - chain + lookups = ActiveModelSerializers.config.serializer_lookup_chain + Array[*lookups].flat_map do |lookup| + lookup.call(klass, self, namespace) + end.compact end # Used to cache serializer name => serializer class diff --git a/lib/active_model/serializer/concerns/configuration.rb b/lib/active_model/serializer/concerns/configuration.rb index c5e73e5b9..d6d3c6106 100644 --- a/lib/active_model/serializer/concerns/configuration.rb +++ b/lib/active_model/serializer/concerns/configuration.rb @@ -32,6 +32,26 @@ def config.array_serializer config.jsonapi_include_toplevel_object = false config.include_data_default = true + # For configuring how serializers are found. + # This should be an array of procs. + # + # The priority of the output is that the first item + # in the evaluated result array will take precedence + # over other possible serializer paths. + # + # i.e.: First match wins. + # + # @example output + # => [ + # "CustomNamespace::ResourceSerializer", + # "ParentSerializer::ResourceSerializer", + # "ResourceNamespace::ResourceSerializer" , + # "ResourceSerializer"] + # + # If CustomNamespace::ResourceSerializer exists, it will be used + # for serialization + config.serializer_lookup_chain = ActiveModelSerializers::LookupChain::DEFAULT.dup + config.schema_path = 'test/support/schemas' end end diff --git a/lib/active_model_serializers.rb b/lib/active_model_serializers.rb index 76a32c497..b55dae35a 100644 --- a/lib/active_model_serializers.rb +++ b/lib/active_model_serializers.rb @@ -14,6 +14,7 @@ module ActiveModelSerializers autoload :Adapter autoload :JsonPointer autoload :Deprecate + autoload :LookupChain class << self; attr_accessor :logger; end self.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT)) diff --git a/lib/active_model_serializers/lookup_chain.rb b/lib/active_model_serializers/lookup_chain.rb new file mode 100644 index 000000000..25db8e138 --- /dev/null +++ b/lib/active_model_serializers/lookup_chain.rb @@ -0,0 +1,80 @@ +module ActiveModelSerializers + module LookupChain + # Standard appending of Serializer to the resource name. + # + # Example: + # Author => AuthorSerializer + BY_RESOURCE = lambda do |resource_class, _serializer_class, _namespace| + serializer_from(resource_class) + end + + # Uses the namespace of the resource to find the serializer + # + # Example: + # British::Author => British::AuthorSerializer + BY_RESOURCE_NAMESPACE = lambda do |resource_class, _serializer_class, _namespace| + resource_namespace = namespace_for(resource_class) + serializer_name = serializer_from(resource_class) + + "#{resource_namespace}::#{serializer_name}" + end + + # Uses the controller namespace of the resource to find the serializer + # + # Example: + # Api::V3::AuthorsController => Api::V3::AuthorSerializer + BY_NAMESPACE = lambda do |resource_class, _serializer_class, namespace| + resource_name = resource_class_name(resource_class) + namespace ? "#{namespace}::#{resource_name}Serializer" : nil + end + + # Allows for serializers to be defined in parent serializers + # - useful if a relationship only needs a different set of attributes + # than if it were rendered independently. + # + # Example: + # class BlogSerializer < ActiveModel::Serializer + # class AuthorSerialier < ActiveModel::Serializer + # ... + # end + # + # belongs_to :author + # ... + # end + # + # The belongs_to relationship would be rendered with + # BlogSerializer::AuthorSerialier + BY_PARENT_SERIALIZER = lambda do |resource_class, serializer_class, _namespace| + return if serializer_class == ActiveModel::Serializer + + serializer_name = serializer_from(resource_class) + "#{serializer_class}::#{serializer_name}" + end + + DEFAULT = [ + BY_PARENT_SERIALIZER, + BY_NAMESPACE, + BY_RESOURCE_NAMESPACE, + BY_RESOURCE + ].freeze + + module_function + + def namespace_for(klass) + klass.name.deconstantize + end + + def resource_class_name(klass) + klass.name.demodulize + end + + def serializer_from_resource_name(name) + "#{name}Serializer" + end + + def serializer_from(klass) + name = resource_class_name(klass) + serializer_from_resource_name(name) + end + end +end diff --git a/test/action_controller/lookup_proc_test.rb b/test/action_controller/lookup_proc_test.rb new file mode 100644 index 000000000..4d2ad0b10 --- /dev/null +++ b/test/action_controller/lookup_proc_test.rb @@ -0,0 +1,49 @@ +require 'test_helper' + +module ActionController + module Serialization + class LookupProcTest < ActionController::TestCase + module Api + module V3 + class PostCustomSerializer < ActiveModel::Serializer + attributes :title, :body + + belongs_to :author + end + + class AuthorCustomSerializer < ActiveModel::Serializer + attributes :name + end + + class LookupProcTestController < ActionController::Base + def implicit_namespaced_serializer + author = Author.new(name: 'Bob') + post = Post.new(title: 'New Post', body: 'Body', author: author) + + render json: post + end + end + end + end + + tests Api::V3::LookupProcTestController + + test 'implicitly uses namespaced serializer' do + controller_namespace = lambda do |resource_class, _parent_serializer_class, namespace| + "#{namespace}::#{resource_class}CustomSerializer" if namespace + end + + with_prepended_lookup(controller_namespace) do + get :implicit_namespaced_serializer + + assert_serializer Api::V3::PostCustomSerializer + + expected = { 'title' => 'New Post', 'body' => 'Body', 'author' => { 'name' => 'Bob' } } + actual = JSON.parse(@response.body) + + assert_equal expected, actual + end + end + end + end +end diff --git a/test/action_controller/namespace_lookup_test.rb b/test/action_controller/namespace_lookup_test.rb index ed5bb7eed..3203fd0b1 100644 --- a/test/action_controller/namespace_lookup_test.rb +++ b/test/action_controller/namespace_lookup_test.rb @@ -15,6 +15,16 @@ class BookSerializer < ActiveModel::Serializer end end + module VHeader + class BookSerializer < ActiveModel::Serializer + attributes :title, :body + + def body + 'header' + end + end + end + module V3 class BookSerializer < ActiveModel::Serializer attributes :title, :body @@ -92,6 +102,14 @@ def namespace_set_in_before_filter book = Book.new(title: 'New Post', body: 'Body') render json: book end + + def namespace_set_by_request_headers + book = Book.new(title: 'New Post', body: 'Body') + version_from_header = request.headers['X-API_VERSION'] + namespace = "ActionController::Serialization::NamespaceLookupTest::#{version_from_header}" + + render json: book, namespace: namespace + end end end end @@ -102,6 +120,13 @@ def namespace_set_in_before_filter @test_namespace = self.class.parent end + test 'uses request headers to determine the namespace' do + request.env['X-API_VERSION'] = 'Api::VHeader' + get :namespace_set_by_request_headers + + assert_serializer Api::VHeader::BookSerializer + end + test 'implicitly uses namespaced serializer' do get :implicit_namespaced_serializer diff --git a/test/benchmark/bm_lookup_chain.rb b/test/benchmark/bm_lookup_chain.rb new file mode 100644 index 000000000..3b32727f5 --- /dev/null +++ b/test/benchmark/bm_lookup_chain.rb @@ -0,0 +1,83 @@ +require_relative './benchmarking_support' +require_relative './app' + +time = 10 +disable_gc = true +ActiveModelSerializers.config.key_transform = :unaltered + +module AmsBench + module Api + module V1 + class PrimaryResourceSerializer < ActiveModel::Serializer + attributes :title, :body + + has_many :has_many_relationships + end + + class HasManyRelationshipSerializer < ActiveModel::Serializer + attribute :body + end + end + end + class PrimaryResourceSerializer < ActiveModel::Serializer + attributes :title, :body + + has_many :has_many_relationships + + class HasManyRelationshipSerializer < ActiveModel::Serializer + attribute :body + end + end +end + +resource = PrimaryResource.new( + id: 1, + title: 'title', + body: 'body', + has_many_relationships: [ + HasManyRelationship.new(id: 1, body: 'body1'), + HasManyRelationship.new(id: 2, body: 'body1') + ] +) + +serialization = lambda do + ActiveModelSerializers::SerializableResource.new(resource, serializer: AmsBench::PrimaryResourceSerializer).as_json + ActiveModelSerializers::SerializableResource.new(resource, namespace: AmsBench::Api::V1).as_json + ActiveModelSerializers::SerializableResource.new(resource).as_json +end + +def clear_cache + AmsBench::PrimaryResourceSerializer.serializers_cache.clear + AmsBench::Api::V1::PrimaryResourceSerializer.serializers_cache.clear + ActiveModel::Serializer.serializers_cache.clear +end + +configurable = lambda do + clear_cache + Benchmark.ams('Configurable Lookup Chain', time: time, disable_gc: disable_gc, &serialization) +end + +old = lambda do + clear_cache + module ActiveModel + class Serializer + def self.serializer_lookup_chain_for(klass, namespace = nil) + chain = [] + + resource_class_name = klass.name.demodulize + resource_namespace = klass.name.deconstantize + serializer_class_name = "#{resource_class_name}Serializer" + + chain.push("#{namespace}::#{serializer_class_name}") if namespace + chain.push("#{name}::#{serializer_class_name}") if self != ActiveModel::Serializer + chain.push("#{resource_namespace}::#{serializer_class_name}") + chain + end + end + end + + Benchmark.ams('Old Lookup Chain (v0.10)', time: time, disable_gc: disable_gc, &serialization) +end + +configurable.call +old.call diff --git a/test/support/serialization_testing.rb b/test/support/serialization_testing.rb index d91ef2b36..524a32976 100644 --- a/test/support/serialization_testing.rb +++ b/test/support/serialization_testing.rb @@ -17,6 +17,14 @@ def with_namespace_separator(separator) ActiveModelSerializers.config.jsonapi_namespace_separator = original_separator end + def with_prepended_lookup(lookup_proc) + original_lookup = ActiveModelSerializers.config.serializer_lookup_cahin + ActiveModelSerializers.config.serializer_lookup_chain.unshift lookup_proc + yield + ensure + ActiveModelSerializers.config.serializer_lookup_cahin = original_lookup + end + # Aliased as :with_configured_adapter to clarify that # this method tests the configured adapter. # When not testing configuration, it may be preferable