Skip to content

Commit d31d741

Browse files
NullVoxPopuliYohan Robert
authored and
Yohan Robert
committed
Make serializer lookup configurable (#1757)
1 parent d0de53c commit d31d741

File tree

10 files changed

+321
-11
lines changed

10 files changed

+321
-11
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Fixes:
1414

1515
Features:
1616

17+
- [#1757](https://github.com/rails-api/active_model_serializers/pull/1757) Make serializer lookup chain configurable. (@NullVoxPopuli)
1718
- [#1968](https://github.com/rails-api/active_model_serializers/pull/1968) (@NullVoxPopuli)
1819
- Add controller namespace to default controller lookup
1920
- Provide a `namespace` render option

docs/general/configuration_options.md

+50
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,56 @@ application, setting `config.key_transform` to `:unaltered` will provide a perfo
6060
What relationships to serialize by default. Default: `'*'`, which includes one level of related
6161
objects. See [includes](adapters.md#included) for more info.
6262

63+
64+
##### serializer_lookup_chain
65+
66+
Configures how serializers are searched for. By default, the lookup chain is
67+
68+
```ruby
69+
ActiveModelSerializers::LookupChain::DEFAULT
70+
```
71+
72+
which is shorthand for
73+
74+
```ruby
75+
[
76+
ActiveModelSerializers::LookupChain::BY_PARENT_SERIALIZER,
77+
ActiveModelSerializers::LookupChain::BY_NAMESPACE,
78+
ActiveModelSerializers::LookupChain::BY_RESOURCE_NAMESPACE,
79+
ActiveModelSerializers::LookupChain::BY_RESOURCE
80+
]
81+
```
82+
83+
Each of the array entries represent a proc. A serializer lookup proc will be yielded 3 arguments. `resource_class`, `serializer_class`, and `namespace`.
84+
85+
Note that:
86+
- `resource_class` is the class of the resource being rendered
87+
- by default `serializer_class` is `ActiveModel::Serializer`
88+
- for association lookup it's the "parent" serializer
89+
- `namespace` correspond to either the controller namespace or the [optionally] specified [namespace render option](./rendering.md#namespace)
90+
91+
An example config could be:
92+
93+
```ruby
94+
ActiveModelSerializers.config.serializer_lookup_chain = [
95+
lambda do |resource_class, serializer_class, namespace|
96+
"API::#{namespace}::#{resource_class}"
97+
end
98+
]
99+
```
100+
101+
If you simply want to add to the existing lookup_chain. Use `unshift`.
102+
103+
```ruby
104+
ActiveModelSerializers.config.serializer_lookup_chain.unshift(
105+
lambda do |resource_class, serializer_class, namespace|
106+
# ...
107+
end
108+
)
109+
```
110+
111+
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.
112+
63113
## JSON API
64114

65115
##### jsonapi_resource_type

lib/active_model/serializer.rb

+4-11
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,10 @@ class << self
6060

6161
# @api private
6262
def self.serializer_lookup_chain_for(klass, namespace = nil)
63-
chain = []
64-
65-
resource_class_name = klass.name.demodulize
66-
resource_namespace = klass.name.deconstantize
67-
serializer_class_name = "#{resource_class_name}Serializer"
68-
69-
chain.push("#{namespace}::#{serializer_class_name}") if namespace
70-
chain.push("#{name}::#{serializer_class_name}") if self != ActiveModel::Serializer
71-
chain.push("#{resource_namespace}::#{serializer_class_name}")
72-
73-
chain
63+
lookups = ActiveModelSerializers.config.serializer_lookup_chain
64+
Array[*lookups].flat_map do |lookup|
65+
lookup.call(klass, self, namespace)
66+
end.compact
7467
end
7568

7669
# Used to cache serializer name => serializer class

lib/active_model/serializer/concerns/configuration.rb

+20
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ def config.array_serializer
3232
config.jsonapi_include_toplevel_object = false
3333
config.include_data_default = true
3434

35+
# For configuring how serializers are found.
36+
# This should be an array of procs.
37+
#
38+
# The priority of the output is that the first item
39+
# in the evaluated result array will take precedence
40+
# over other possible serializer paths.
41+
#
42+
# i.e.: First match wins.
43+
#
44+
# @example output
45+
# => [
46+
# "CustomNamespace::ResourceSerializer",
47+
# "ParentSerializer::ResourceSerializer",
48+
# "ResourceNamespace::ResourceSerializer" ,
49+
# "ResourceSerializer"]
50+
#
51+
# If CustomNamespace::ResourceSerializer exists, it will be used
52+
# for serialization
53+
config.serializer_lookup_chain = ActiveModelSerializers::LookupChain::DEFAULT.dup
54+
3555
config.schema_path = 'test/support/schemas'
3656
end
3757
end

lib/active_model_serializers.rb

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module ActiveModelSerializers
1414
autoload :Adapter
1515
autoload :JsonPointer
1616
autoload :Deprecate
17+
autoload :LookupChain
1718

1819
class << self; attr_accessor :logger; end
1920
self.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
module ActiveModelSerializers
2+
module LookupChain
3+
# Standard appending of Serializer to the resource name.
4+
#
5+
# Example:
6+
# Author => AuthorSerializer
7+
BY_RESOURCE = lambda do |resource_class, _serializer_class, _namespace|
8+
serializer_from(resource_class)
9+
end
10+
11+
# Uses the namespace of the resource to find the serializer
12+
#
13+
# Example:
14+
# British::Author => British::AuthorSerializer
15+
BY_RESOURCE_NAMESPACE = lambda do |resource_class, _serializer_class, _namespace|
16+
resource_namespace = namespace_for(resource_class)
17+
serializer_name = serializer_from(resource_class)
18+
19+
"#{resource_namespace}::#{serializer_name}"
20+
end
21+
22+
# Uses the controller namespace of the resource to find the serializer
23+
#
24+
# Example:
25+
# Api::V3::AuthorsController => Api::V3::AuthorSerializer
26+
BY_NAMESPACE = lambda do |resource_class, _serializer_class, namespace|
27+
resource_name = resource_class_name(resource_class)
28+
namespace ? "#{namespace}::#{resource_name}Serializer" : nil
29+
end
30+
31+
# Allows for serializers to be defined in parent serializers
32+
# - useful if a relationship only needs a different set of attributes
33+
# than if it were rendered independently.
34+
#
35+
# Example:
36+
# class BlogSerializer < ActiveModel::Serializer
37+
# class AuthorSerialier < ActiveModel::Serializer
38+
# ...
39+
# end
40+
#
41+
# belongs_to :author
42+
# ...
43+
# end
44+
#
45+
# The belongs_to relationship would be rendered with
46+
# BlogSerializer::AuthorSerialier
47+
BY_PARENT_SERIALIZER = lambda do |resource_class, serializer_class, _namespace|
48+
return if serializer_class == ActiveModel::Serializer
49+
50+
serializer_name = serializer_from(resource_class)
51+
"#{serializer_class}::#{serializer_name}"
52+
end
53+
54+
DEFAULT = [
55+
BY_PARENT_SERIALIZER,
56+
BY_NAMESPACE,
57+
BY_RESOURCE_NAMESPACE,
58+
BY_RESOURCE
59+
].freeze
60+
61+
module_function
62+
63+
def namespace_for(klass)
64+
klass.name.deconstantize
65+
end
66+
67+
def resource_class_name(klass)
68+
klass.name.demodulize
69+
end
70+
71+
def serializer_from_resource_name(name)
72+
"#{name}Serializer"
73+
end
74+
75+
def serializer_from(klass)
76+
name = resource_class_name(klass)
77+
serializer_from_resource_name(name)
78+
end
79+
end
80+
end
+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
require 'test_helper'
2+
3+
module ActionController
4+
module Serialization
5+
class LookupProcTest < ActionController::TestCase
6+
module Api
7+
module V3
8+
class PostCustomSerializer < ActiveModel::Serializer
9+
attributes :title, :body
10+
11+
belongs_to :author
12+
end
13+
14+
class AuthorCustomSerializer < ActiveModel::Serializer
15+
attributes :name
16+
end
17+
18+
class LookupProcTestController < ActionController::Base
19+
def implicit_namespaced_serializer
20+
author = Author.new(name: 'Bob')
21+
post = Post.new(title: 'New Post', body: 'Body', author: author)
22+
23+
render json: post
24+
end
25+
end
26+
end
27+
end
28+
29+
tests Api::V3::LookupProcTestController
30+
31+
test 'implicitly uses namespaced serializer' do
32+
controller_namespace = lambda do |resource_class, _parent_serializer_class, namespace|
33+
"#{namespace}::#{resource_class}CustomSerializer" if namespace
34+
end
35+
36+
with_prepended_lookup(controller_namespace) do
37+
get :implicit_namespaced_serializer
38+
39+
assert_serializer Api::V3::PostCustomSerializer
40+
41+
expected = { 'title' => 'New Post', 'body' => 'Body', 'author' => { 'name' => 'Bob' } }
42+
actual = JSON.parse(@response.body)
43+
44+
assert_equal expected, actual
45+
end
46+
end
47+
end
48+
end
49+
end

test/action_controller/namespace_lookup_test.rb

+25
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ class BookSerializer < ActiveModel::Serializer
1515
end
1616
end
1717

18+
module VHeader
19+
class BookSerializer < ActiveModel::Serializer
20+
attributes :title, :body
21+
22+
def body
23+
'header'
24+
end
25+
end
26+
end
27+
1828
module V3
1929
class BookSerializer < ActiveModel::Serializer
2030
attributes :title, :body
@@ -92,6 +102,14 @@ def namespace_set_in_before_filter
92102
book = Book.new(title: 'New Post', body: 'Body')
93103
render json: book
94104
end
105+
106+
def namespace_set_by_request_headers
107+
book = Book.new(title: 'New Post', body: 'Body')
108+
version_from_header = request.headers['X-API_VERSION']
109+
namespace = "ActionController::Serialization::NamespaceLookupTest::#{version_from_header}"
110+
111+
render json: book, namespace: namespace
112+
end
95113
end
96114
end
97115
end
@@ -102,6 +120,13 @@ def namespace_set_in_before_filter
102120
@test_namespace = self.class.parent
103121
end
104122

123+
test 'uses request headers to determine the namespace' do
124+
request.env['X-API_VERSION'] = 'Api::VHeader'
125+
get :namespace_set_by_request_headers
126+
127+
assert_serializer Api::VHeader::BookSerializer
128+
end
129+
105130
test 'implicitly uses namespaced serializer' do
106131
get :implicit_namespaced_serializer
107132

test/benchmark/bm_lookup_chain.rb

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
require_relative './benchmarking_support'
2+
require_relative './app'
3+
4+
time = 10
5+
disable_gc = true
6+
ActiveModelSerializers.config.key_transform = :unaltered
7+
8+
module AmsBench
9+
module Api
10+
module V1
11+
class PrimaryResourceSerializer < ActiveModel::Serializer
12+
attributes :title, :body
13+
14+
has_many :has_many_relationships
15+
end
16+
17+
class HasManyRelationshipSerializer < ActiveModel::Serializer
18+
attribute :body
19+
end
20+
end
21+
end
22+
class PrimaryResourceSerializer < ActiveModel::Serializer
23+
attributes :title, :body
24+
25+
has_many :has_many_relationships
26+
27+
class HasManyRelationshipSerializer < ActiveModel::Serializer
28+
attribute :body
29+
end
30+
end
31+
end
32+
33+
resource = PrimaryResource.new(
34+
id: 1,
35+
title: 'title',
36+
body: 'body',
37+
has_many_relationships: [
38+
HasManyRelationship.new(id: 1, body: 'body1'),
39+
HasManyRelationship.new(id: 2, body: 'body1')
40+
]
41+
)
42+
43+
serialization = lambda do
44+
ActiveModelSerializers::SerializableResource.new(resource, serializer: AmsBench::PrimaryResourceSerializer).as_json
45+
ActiveModelSerializers::SerializableResource.new(resource, namespace: AmsBench::Api::V1).as_json
46+
ActiveModelSerializers::SerializableResource.new(resource).as_json
47+
end
48+
49+
def clear_cache
50+
AmsBench::PrimaryResourceSerializer.serializers_cache.clear
51+
AmsBench::Api::V1::PrimaryResourceSerializer.serializers_cache.clear
52+
ActiveModel::Serializer.serializers_cache.clear
53+
end
54+
55+
configurable = lambda do
56+
clear_cache
57+
Benchmark.ams('Configurable Lookup Chain', time: time, disable_gc: disable_gc, &serialization)
58+
end
59+
60+
old = lambda do
61+
clear_cache
62+
module ActiveModel
63+
class Serializer
64+
def self.serializer_lookup_chain_for(klass, namespace = nil)
65+
chain = []
66+
67+
resource_class_name = klass.name.demodulize
68+
resource_namespace = klass.name.deconstantize
69+
serializer_class_name = "#{resource_class_name}Serializer"
70+
71+
chain.push("#{namespace}::#{serializer_class_name}") if namespace
72+
chain.push("#{name}::#{serializer_class_name}") if self != ActiveModel::Serializer
73+
chain.push("#{resource_namespace}::#{serializer_class_name}")
74+
chain
75+
end
76+
end
77+
end
78+
79+
Benchmark.ams('Old Lookup Chain (v0.10)', time: time, disable_gc: disable_gc, &serialization)
80+
end
81+
82+
configurable.call
83+
old.call

test/support/serialization_testing.rb

+8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ def with_namespace_separator(separator)
1717
ActiveModelSerializers.config.jsonapi_namespace_separator = original_separator
1818
end
1919

20+
def with_prepended_lookup(lookup_proc)
21+
original_lookup = ActiveModelSerializers.config.serializer_lookup_cahin
22+
ActiveModelSerializers.config.serializer_lookup_chain.unshift lookup_proc
23+
yield
24+
ensure
25+
ActiveModelSerializers.config.serializer_lookup_cahin = original_lookup
26+
end
27+
2028
# Aliased as :with_configured_adapter to clarify that
2129
# this method tests the configured adapter.
2230
# When not testing configuration, it may be preferable

0 commit comments

Comments
 (0)