Skip to content

Commit ce7a839

Browse files
committed
Extended format for JSONAPI include option.
1 parent b594d14 commit ce7a839

File tree

12 files changed

+160
-57
lines changed

12 files changed

+160
-57
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
* remove root key option and split JSON adapter [@joaomdmoura]
1212
* adds FlattenJSON as default adapter [@joaomdmoura]
1313
* adds support for `pagination links` at top level of JsonApi adapter [@bacarini]
14+
* adds extended format for `include` option to JSONAPI adapter [@beauby]

docs/general/adapters.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ resources in the `"included"` member when the resource names are included in the
3030
render @posts, include: 'authors,comments'
3131
```
3232

33+
The format of the `include` option can be either a String composed of a comma-separated list of [relationship paths](http://jsonapi.org/format/#fetching-includes), an Array of Symbols and Hashes, or a mix of both.
34+
3335
## Choosing an adapter
3436

3537
If you want to use a specify a default adapter, such as JsonApi, you can change this in an initializer:

lib/active_model/serializer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class Serializer
1010
autoload :Lint
1111
autoload :Associations
1212
autoload :Fieldset
13+
autoload :Utils
1314
include Configuration
1415
include Associations
1516

lib/active_model/serializer/adapter/json_api.rb

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ def initialize(serializer, options = {})
77
super
88
@hash = { data: [] }
99

10-
@options[:include] ||= []
11-
if @options[:include].is_a?(String)
12-
@options[:include] = @options[:include].split(',')
13-
end
14-
10+
@included = ActiveModel::Serializer::Utils.include_args_to_hash(@options[:include])
1511
fields = options.delete(:fields)
1612
if fields
1713
@fieldset = ActiveModel::Serializer::Fieldset.new(fields, serializer.json_key)
@@ -117,46 +113,36 @@ def relationships_for(serializer)
117113
end
118114

119115
def included_for(serializer)
120-
serializer.associations.flat_map { |assoc| _included_for(assoc.key, assoc.serializer) }.uniq
116+
included = @included.flat_map do |inc|
117+
association = serializer.associations.find { |assoc| assoc.key == inc.first }
118+
_included_for(association.serializer, inc.second) if association
119+
end
120+
121+
included.uniq
121122
end
122123

123-
def _included_for(resource_name, serializer, parent = nil)
124+
def _included_for(serializer, includes)
124125
if serializer.respond_to?(:each)
125-
serializer.flat_map { |s| _included_for(resource_name, s, parent) }.uniq
126+
serializer.flat_map { |s| _included_for(s, includes) }.uniq
126127
else
127128
return [] unless serializer && serializer.object
128-
result = []
129-
resource_path = [parent, resource_name].compact.join('.')
130-
131-
if include_assoc?(resource_path)
132-
primary_data = primary_data_for(serializer, @options)
133-
relationships = relationships_for(serializer)
134-
primary_data[:relationships] = relationships if relationships.any?
135-
result.push(primary_data)
136-
end
137129

138-
if include_nested_assoc?(resource_path)
139-
non_empty_associations = serializer.associations.select(&:serializer)
130+
primary_data = primary_data_for(serializer, @options)
131+
relationships = relationships_for(serializer)
132+
primary_data[:relationships] = relationships if relationships.any?
133+
134+
included = [primary_data]
140135

141-
non_empty_associations.each do |association|
142-
result.concat(_included_for(association.key, association.serializer, resource_path))
143-
result.uniq!
136+
includes.each do |inc|
137+
association = serializer.associations.find { |assoc| assoc.key == inc.first }
138+
if association
139+
included.concat(_included_for(association.serializer, inc.second))
140+
included.uniq!
144141
end
145142
end
146-
result
147-
end
148-
end
149-
150-
def include_assoc?(assoc)
151-
check_assoc("#{assoc}$")
152-
end
153143

154-
def include_nested_assoc?(assoc)
155-
check_assoc("#{assoc}.")
156-
end
157-
158-
def check_assoc(assoc)
159-
@options[:include].any? { |s| s.match(/^#{assoc.gsub('.', '\.')}/) }
144+
included
145+
end
160146
end
161147

162148
def add_links(options)

lib/active_model/serializer/utils.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
module ActiveModel::Serializer::Utils
2+
module_function
3+
4+
# Translates a comma separated list of dot separated paths (JSONAPI format) into a Hash.
5+
# Example: `'posts.author, posts.comments.upvotes, posts.comments.author'` would become `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`.
6+
#
7+
# @param [String] included
8+
# @return [Hash] a Hash representing the same tree structure
9+
def include_string_to_hash(included)
10+
included.delete(' ').split(',').inject({}) do |hash, path|
11+
hash.deep_merge!(path.split('.').reverse_each.inject({}) { |a, e| { e.to_sym => a } })
12+
end
13+
end
14+
15+
# Translates the arguments passed to the include option into a Hash. The format can be either
16+
# a String (see #include_string_to_hash), an Array of Symbols and Hashes, or a mix of both.
17+
# Example: `posts: [:author, comments: [:author, :upvotes]]` would become `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`.
18+
#
19+
# @param [Symbol, Hash, Array, String] included
20+
# @return [Hash] a Hash representing the same tree structure
21+
def include_args_to_hash(included)
22+
case included
23+
when Symbol
24+
{ included => {} }
25+
when Hash
26+
included.each_with_object({}) { |(key, value), hash| hash[key] = include_args_to_hash(value) }
27+
when Array
28+
included.inject({}) { |a, e| a.merge!(include_args_to_hash(e)) }
29+
when String
30+
include_string_to_hash(included)
31+
else
32+
{}
33+
end
34+
end
35+
end

test/action_controller/json_api/linked_test.rb

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,29 +43,29 @@ def render_resource_without_include
4343

4444
def render_resource_with_include
4545
setup_post
46-
render json: @post, include: 'author', adapter: :json_api
46+
render json: @post, include: [:author], adapter: :json_api
4747
end
4848

4949
def render_resource_with_nested_include
5050
setup_post
51-
render json: @post, include: 'comments.author', adapter: :json_api
51+
render json: @post, include: [comments: [:author]], adapter: :json_api
5252
end
5353

5454
def render_resource_with_nested_has_many_include
5555
setup_post
56-
render json: @post, include: ['author', 'author.roles'], adapter: :json_api
56+
render json: @post, include: 'author.roles', adapter: :json_api
5757
end
5858

5959
def render_resource_with_missing_nested_has_many_include
6060
setup_post
6161
@post.author = @author2 # author2 has no roles.
62-
render json: @post, include: 'author,author.roles', adapter: :json_api
62+
render json: @post, include: [author: [:roles]], adapter: :json_api
6363
end
6464

6565
def render_collection_with_missing_nested_has_many_include
6666
setup_post
6767
@post.author = @author2
68-
render json: [@post, @post2], include: 'author,author.roles', adapter: :json_api
68+
render json: [@post, @post2], include: [author: [:roles]], adapter: :json_api
6969
end
7070

7171
def render_collection_without_include
@@ -75,7 +75,7 @@ def render_collection_without_include
7575

7676
def render_collection_with_include
7777
setup_post
78-
render json: [@post], include: %w(author comments), adapter: :json_api
78+
render json: [@post], include: 'author, comments', adapter: :json_api
7979
end
8080
end
8181

@@ -141,8 +141,7 @@ def test_render_resource_with_nested_include
141141
get :render_resource_with_nested_include
142142
response = JSON.parse(@response.body)
143143
assert response.key? 'included'
144-
assert_equal 1, response['included'].size
145-
assert_equal 'Anonymous', response['included'].first['attributes']['name']
144+
assert_equal 3, response['included'].size
146145
end
147146

148147
def test_render_collection_without_include

test/adapter/json_api/belongs_to_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_includes_post_id
3838
end
3939

4040
def test_includes_linked_post
41-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'post')
41+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:post])
4242
expected = [{
4343
id: '42',
4444
type: 'posts',
@@ -56,7 +56,7 @@ def test_includes_linked_post
5656
end
5757

5858
def test_limiting_linked_post_fields
59-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'post', fields: { post: [:title] })
59+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:post], fields: { post: [:title] })
6060
expected = [{
6161
id: '42',
6262
type: 'posts',
@@ -108,7 +108,7 @@ def test_include_type_for_association_when_different_than_name
108108

109109
def test_include_linked_resources_with_type_name
110110
serializer = BlogSerializer.new(@blog)
111-
adapter = ActiveModel::Serializer::Adapter::JsonApi.new(serializer, include: %w(writer articles))
111+
adapter = ActiveModel::Serializer::Adapter::JsonApi.new(serializer, include: [:writer, :articles])
112112
linked = adapter.serializable_hash[:included]
113113
expected = [
114114
{

test/adapter/json_api/has_many_explicit_serializer_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def setup
2424
@serializer = PostPreviewSerializer.new(@post)
2525
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(
2626
@serializer,
27-
include: %w(comments author)
27+
include: [:comments, :author]
2828
)
2929
end
3030

test/adapter/json_api/has_many_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_includes_comment_ids
4242
end
4343

4444
def test_includes_linked_comments
45-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'comments')
45+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:comments])
4646
expected = [{
4747
id: '1',
4848
type: 'comments',
@@ -68,7 +68,7 @@ def test_includes_linked_comments
6868
end
6969

7070
def test_limit_fields_of_linked_comments
71-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'comments', fields: { comment: [:id] })
71+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:comments], fields: { comment: [:id] })
7272
expected = [{
7373
id: '1',
7474
type: 'comments',

test/adapter/json_api/has_one_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def setup
2828
@virtual_value = VirtualValue.new(id: 1)
2929

3030
@serializer = AuthorSerializer.new(@author)
31-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'bio,posts')
31+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:bio, :posts])
3232
end
3333

3434
def test_includes_bio_id
@@ -38,7 +38,7 @@ def test_includes_bio_id
3838
end
3939

4040
def test_includes_linked_bio
41-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'bio')
41+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:bio])
4242

4343
expected = [
4444
{

0 commit comments

Comments
 (0)