diff --git a/.gitignore b/.gitignore index ad7f37d1..43b0fba4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .config .yardoc Gemfile.lock +Gemfile.local InstalledFiles _yardoc coverage diff --git a/.rubocop.yml b/.rubocop.yml index e7d729cc..d808271c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,9 @@ Lint/NestedMethodDefinition: Style/StringLiterals: EnforcedStyle: single_quotes +Style/SpecialGlobalVars: + Enabled: false + Metrics/AbcSize: Max: 35 # TODO: Lower to 15 diff --git a/Gemfile b/Gemfile index 7b93d844..7899d8df 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,8 @@ source 'https://rubygems.org' +# +# Add a Gemfile.local to locally bundle gems outside of version control +local_gemfile = File.join(File.expand_path('..', __FILE__), 'Gemfile.local') +eval_gemfile local_gemfile if File.readable?(local_gemfile) # Specify your gem's dependencies in active_model_serializers.gemspec gemspec diff --git a/docs/general/adapters.md b/docs/general/adapters.md index 60dc9842..d7363a9f 100644 --- a/docs/general/adapters.md +++ b/docs/general/adapters.md @@ -32,7 +32,7 @@ resources in the `"included"` member when the resource names are included in the ## Choosing an adapter -If you want to use a different adapter, such as JsonApi, you can change this in an initializer: +If you want to use a specify a default adapter, such as JsonApi, you can change this in an initializer: ```ruby ActiveModel::Serializer.config.adapter = ActiveModel::Serializer::Adapter::JsonApi @@ -44,8 +44,59 @@ or ActiveModel::Serializer.config.adapter = :json_api ``` -If you want to have a root key in your responses you should use the Json adapter, instead of the default FlattenJson: +If you want to have a root key for each resource in your responses, you should use the Json or +JsonApi adapters instead of the default FlattenJson: ```ruby ActiveModel::Serializer.config.adapter = :json ``` + +## Advanced adapter configuration + +### Registering an adapter + +The default adapter can be configured, as above, to use any class given to it. + +An adapter may also be specified, e.g. when rendering, as a class or as a symbol. +If a symbol, then the adapter must be, e.g. `:great_example`, +`ActiveModel::Serializer::Adapter::GreatExample`, or registered. + +There are two ways to register an adapter: + +1) The simplest, is to subclass `ActiveModel::Serializer::Adapter`, e.g. the below will +register the `Example::UsefulAdapter` as `:useful_adapter`. + +```ruby +module Example + class UsefulAdapter < ActiveModel::Serializer::Adapter + end +end +``` + +You'll notice that the name it registers is the class name underscored, not the full namespace. + +Under the covers, when the `ActiveModel::Serializer::Adapter` is subclassed, it registers +the subclass as `register(:useful_adapter, Example::UsefulAdapter)` + +2) Any class can be registered as an adapter by calling `register` directly on the +`ActiveModel::Serializer::Adapter` class. e.g., the below registers `MyAdapter` as +`:special_adapter`. + +```ruby +class MyAdapter; end +ActiveModel::Serializer::Adapter.register(:special_adapter, MyAdapter) +``` + +### Looking up an adapter + +| `ActiveModel::Serializer::Adapter.adapter_map` | A Hash of all known adapters { adapter_name => adapter_class } | +| `ActiveModel::Serializer::Adapter.adapters` | A (sorted) Array of all known adapter_names | +| `ActiveModel::Serializer::Adapter.get(name_or_klass)` | The adapter_class, else raises an `ActiveModel::Serializer::Adapter::UnknownAdapter` error | +| `ActiveModel::Serializer::Adapter.adapter_class(adapter)` | delegates to `ActiveModel::Serializer::Adapter.get(adapter)` | +| `ActiveModel::Serializer.adapter` | a convenience method for `ActiveModel::Serializer::Adapter.get(config.adapter)` | + +The registered adapter name is always a String, but may be looked up as a Symbol or String. +Helpfully, the Symbol or String is underscored, so that `get(:my_adapter)` and `get("MyAdapter")` +may both be used. + +For more information, see [the Adapter class on GitHub](https://github.com/rails-api/active_model_serializers/blob/master/lib/active_model/serializer/adapter.rb) diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index 8b4f02ab..f6bef4a1 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -94,19 +94,9 @@ module ActiveModel end end + # @see ActiveModel::Serializer::Adapter.get def self.adapter - adapter_class = case config.adapter - when Symbol - ActiveModel::Serializer::Adapter.adapter_class(config.adapter) - when Class - config.adapter - end - unless adapter_class - valid_adapters = Adapter.constants.map { |klass| ":#{klass.to_s.downcase}" } - raise ArgumentError, "Unknown adapter: #{config.adapter}. Valid adapters are: #{valid_adapters}" - end - - adapter_class + ActiveModel::Serializer::Adapter.get(config.adapter) end def self.root_name diff --git a/lib/active_model/serializer/adapter.rb b/lib/active_model/serializer/adapter.rb index 1cbeb9b7..2b9fb350 100644 --- a/lib/active_model/serializer/adapter.rb +++ b/lib/active_model/serializer/adapter.rb @@ -1,6 +1,9 @@ module ActiveModel class Serializer class Adapter + UnknownAdapterError = Class.new(ArgumentError) + ADAPTER_MAP = {} + private_constant :ADAPTER_MAP if defined?(private_constant) extend ActiveSupport::Autoload require 'active_model/serializer/adapter/json' require 'active_model/serializer/adapter/json_api' @@ -14,9 +17,71 @@ module ActiveModel klass.new(resource, options) end + # @see ActiveModel::Serializer::Adapter.get def self.adapter_class(adapter) - adapter_name = adapter.to_s.classify.sub('API', 'Api') - "ActiveModel::Serializer::Adapter::#{adapter_name}".safe_constantize + ActiveModel::Serializer::Adapter.get(adapter) + end + + # Only the Adapter class has these methods. + # None of the sublasses have them. + class << ActiveModel::Serializer::Adapter + # @return Hash + def adapter_map + ADAPTER_MAP + end + + # @return [Array] list of adapter names + def adapters + adapter_map.keys.sort + end + + # Adds an adapter 'klass' with 'name' to the 'adapter_map' + # Names are stringified and underscored + # @param [Symbol, String] name of the registered adapter + # @param [Class] klass - adapter class itself + # @example + # AMS::Adapter.register(:my_adapter, MyAdapter) + def register(name, klass) + adapter_map.update(name.to_s.underscore => klass) + self + end + + # @param adapter [String, Symbol, Class] name to fetch adapter by + # @return [ActiveModel::Serializer::Adapter] subclass of Adapter + # @raise [UnknownAdapterError] + def get(adapter) + # 1. return if is a class + return adapter if adapter.is_a?(Class) + adapter_name = adapter.to_s.underscore + # 2. return if registered + adapter_map.fetch(adapter_name) { + # 3. try to find adapter class from environment + adapter_class = find_by_name(adapter_name) + register(adapter_name, adapter_class) + adapter_class + } + rescue ArgumentError + failure_message = + "Unknown adapter: #{adapter.inspect}. Valid adapters are: #{adapters}" + raise UnknownAdapterError, failure_message, $!.backtrace + rescue NameError + failure_message = + "NameError: #{$!.message}. Unknown adapter: #{adapter.inspect}. Valid adapters are: #{adapters}" + raise UnknownAdapterError, failure_message, $!.backtrace + end + + # @api private + def find_by_name(adapter_name) + adapter_name = adapter_name.to_s.classify.tr('API', 'Api') + "ActiveModel::Serializer::Adapter::#{adapter_name}".safe_constantize or # rubocop:disable Style/AndOr + fail UnknownAdapterError + end + private :find_by_name + end + + # Automatically register adapters when subclassing + def self.inherited(subclass) + ActiveModel::Serializer::Adapter.register(subclass.to_s.demodulize, subclass) end attr_reader :serializer diff --git a/test/adapter_test.rb b/test/adapter_test.rb index 73bacdd5..75efac15 100644 --- a/test/adapter_test.rb +++ b/test/adapter_test.rb @@ -19,16 +19,6 @@ module ActiveModel assert_equal @serializer, @adapter.serializer end - def test_adapter_class_for_known_adapter - klass = ActiveModel::Serializer::Adapter.adapter_class(:json_api) - assert_equal ActiveModel::Serializer::Adapter::JsonApi, klass - end - - def test_adapter_class_for_unknown_adapter - klass = ActiveModel::Serializer::Adapter.adapter_class(:json_simple) - assert_nil klass - end - def test_create_adapter adapter = ActiveModel::Serializer::Adapter.create(@serializer) assert_equal ActiveModel::Serializer::Adapter::FlattenJson, adapter.class diff --git a/test/serializers/adapter_for_test.rb b/test/serializers/adapter_for_test.rb index 3a17cd24..c55c3a9e 100644 --- a/test/serializers/adapter_for_test.rb +++ b/test/serializers/adapter_for_test.rb @@ -1,6 +1,8 @@ module ActiveModel class Serializer class AdapterForTest < Minitest::Test + UnknownAdapterError = ::ActiveModel::Serializer::Adapter::UnknownAdapterError + def setup @previous_adapter = ActiveModel::Serializer.config.adapter end @@ -20,6 +22,7 @@ module ActiveModel adapter = ActiveModel::Serializer.adapter assert_equal ActiveModel::Serializer::Adapter::Null, adapter ensure + ActiveModel::Serializer.config.adapter = @previous_adapter end def test_overwrite_adapter_with_class @@ -32,7 +35,7 @@ module ActiveModel def test_raises_exception_if_invalid_symbol_given ActiveModel::Serializer.config.adapter = :unknown - assert_raises ArgumentError do + assert_raises UnknownAdapterError do ActiveModel::Serializer.adapter end end @@ -40,10 +43,123 @@ module ActiveModel def test_raises_exception_if_it_does_not_know_hot_to_infer_adapter ActiveModel::Serializer.config.adapter = 42 - assert_raises ArgumentError do + assert_raises UnknownAdapterError do ActiveModel::Serializer.adapter end end + + def test_adapter_class_for_known_adapter + klass = ActiveModel::Serializer::Adapter.adapter_class(:json_api) + assert_equal ActiveModel::Serializer::Adapter::JsonApi, klass + end + + def test_adapter_class_for_unknown_adapter + assert_raises UnknownAdapterError do + ActiveModel::Serializer::Adapter.adapter_class(:json_simple) + end + end + + def test_adapter_map + expected_adapter_map = { + 'json'.freeze => ActiveModel::Serializer::Adapter::Json, + 'json_api'.freeze => ActiveModel::Serializer::Adapter::JsonApi, + 'flatten_json'.freeze => ActiveModel::Serializer::Adapter::FlattenJson, + 'null'.freeze => ActiveModel::Serializer::Adapter::Null + } + assert_equal ActiveModel::Serializer::Adapter.adapter_map, expected_adapter_map + end + + def test_adapters + assert_equal ActiveModel::Serializer::Adapter.adapters.sort, [ + 'flatten_json'.freeze, + 'json'.freeze, + 'json_api'.freeze, + 'null'.freeze + ] + end + + def test_get_adapter_by_string_name + assert_equal ActiveModel::Serializer::Adapter.get('json'.freeze), ActiveModel::Serializer::Adapter::Json + end + + def test_get_adapter_by_symbol_name + assert_equal ActiveModel::Serializer::Adapter.get(:json), ActiveModel::Serializer::Adapter::Json + end + + def test_get_adapter_by_class + klass = ActiveModel::Serializer::Adapter::Json + assert_equal ActiveModel::Serializer::Adapter.get(klass), klass + end + + def test_get_adapter_from_environment_registers_adapter + ActiveModel::Serializer::Adapter.const_set(:AdapterFromEnvironment, Class.new) + klass = ::ActiveModel::Serializer::Adapter::AdapterFromEnvironment + name = 'adapter_from_environment'.freeze + assert_equal ActiveModel::Serializer::Adapter.get(name), klass + assert ActiveModel::Serializer::Adapter.adapters.include?(name) + ensure + ActiveModel::Serializer::Adapter.adapter_map.delete(name) + ActiveModel::Serializer::Adapter.send(:remove_const, :AdapterFromEnvironment) + end + + def test_get_adapter_for_unknown_name + assert_raises UnknownAdapterError do + ActiveModel::Serializer::Adapter.get(:json_simple) + end + end + + def test_adapter + assert_equal ActiveModel::Serializer.config.adapter, :flatten_json + assert_equal ActiveModel::Serializer.adapter, ActiveModel::Serializer::Adapter::FlattenJson + end + + def test_register_adapter + new_adapter_name = :foo + new_adapter_klass = Class.new + ActiveModel::Serializer::Adapter.register(new_adapter_name, new_adapter_klass) + assert ActiveModel::Serializer::Adapter.adapters.include?('foo'.freeze) + assert ActiveModel::Serializer::Adapter.get(:foo), new_adapter_klass + ensure + ActiveModel::Serializer::Adapter.adapter_map.delete(new_adapter_name.to_s) + end + + def test_inherited_adapter_hooks_register_adapter + Object.const_set(:MyAdapter, Class.new) + my_adapter = MyAdapter + ActiveModel::Serializer::Adapter.inherited(my_adapter) + assert_equal ActiveModel::Serializer::Adapter.get(:my_adapter), my_adapter + ensure + ActiveModel::Serializer::Adapter.adapter_map.delete('my_adapter'.freeze) + Object.send(:remove_const, :MyAdapter) + end + + def test_inherited_adapter_hooks_register_demodulized_adapter + Object.const_set(:MyNamespace, Module.new) + MyNamespace.const_set(:MyAdapter, Class.new) + my_adapter = MyNamespace::MyAdapter + ActiveModel::Serializer::Adapter.inherited(my_adapter) + assert_equal ActiveModel::Serializer::Adapter.get(:my_adapter), my_adapter + ensure + ActiveModel::Serializer::Adapter.adapter_map.delete('my_adapter'.freeze) + MyNamespace.send(:remove_const, :MyAdapter) + Object.send(:remove_const, :MyNamespace) + end + + def test_inherited_adapter_hooks_register_subclass_of_registered_adapter + Object.const_set(:MyAdapter, Class.new) + my_adapter = MyAdapter + Object.const_set(:MySubclassedAdapter, Class.new(MyAdapter)) + my_subclassed_adapter = MySubclassedAdapter + ActiveModel::Serializer::Adapter.inherited(my_adapter) + ActiveModel::Serializer::Adapter.inherited(my_subclassed_adapter) + assert_equal ActiveModel::Serializer::Adapter.get(:my_adapter), my_adapter + assert_equal ActiveModel::Serializer::Adapter.get(:my_subclassed_adapter), my_subclassed_adapter + ensure + ActiveModel::Serializer::Adapter.adapter_map.delete('my_adapter'.freeze) + ActiveModel::Serializer::Adapter.adapter_map.delete('my_subclassed_adapter'.freeze) + Object.send(:remove_const, :MyAdapter) + Object.send(:remove_const, :MySubclassedAdapter) + end end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index e6ba0dff..c523041d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -39,6 +39,15 @@ else end require 'active_model_serializers' +# eager load autoloaded adapters +# rubocop:disable Lint/Void +require 'active_model/serializer/adapter' +ActiveModel::Serializer::Adapter::Null +ActiveModel::Serializer::Adapter::Json +ActiveModel::Serializer::Adapter::FlattenJson +ActiveModel::Serializer::Adapter::JsonApi +# rubocop:enable Lint/Void +require 'active_model/serializer/adapter' require 'support/stream_capture'