diff --git a/CHANGELOG.md b/CHANGELOG.md index b70c2e9a..a5a8a744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ * remove root key option and split JSON adapter [@joaomdmoura] * adds FlattenJSON as default adapter [@joaomdmoura] * adds support for `pagination links` at top level of JsonApi adapter [@bacarini] + * adds extended format for `include` option to JSONAPI adapter [@beauby] diff --git a/docs/general/adapters.md b/docs/general/adapters.md index b505a7f5..9a8e83a1 100644 --- a/docs/general/adapters.md +++ b/docs/general/adapters.md @@ -30,6 +30,8 @@ resources in the `"included"` member when the resource names are included in the render @posts, include: 'authors,comments' ``` +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. + ## Choosing an adapter If you want to use a specify a default adapter, such as JsonApi, you can change this in an initializer: diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index 29acbabf..2a3465cb 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -10,6 +10,7 @@ module ActiveModel autoload :Lint autoload :Associations autoload :Fieldset + autoload :Utils include Configuration include Associations diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb index f273fc65..c1e5c94f 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -7,11 +7,7 @@ class ActiveModel::Serializer::Adapter::JsonApi < ActiveModel::Serializer::Adapt super @hash = { data: [] } - @options[:include] ||= [] - if @options[:include].is_a?(String) - @options[:include] = @options[:include].split(',') - end - + @included = ActiveModel::Serializer::Utils.include_args_to_hash(@options[:include]) fields = options.delete(:fields) if fields @fieldset = ActiveModel::Serializer::Fieldset.new(fields, serializer.json_key) @@ -117,48 +113,38 @@ class ActiveModel::Serializer::Adapter::JsonApi < ActiveModel::Serializer::Adapt end def included_for(serializer) - serializer.associations.flat_map { |assoc| _included_for(assoc.key, assoc.serializer) }.uniq + included = @included.flat_map do |inc| + association = serializer.associations.find { |assoc| assoc.key == inc.first } + _included_for(association.serializer, inc.second) if association + end + + included.uniq end - def _included_for(resource_name, serializer, parent = nil) + def _included_for(serializer, includes) if serializer.respond_to?(:each) - serializer.flat_map { |s| _included_for(resource_name, s, parent) }.uniq + serializer.flat_map { |s| _included_for(s, includes) }.uniq else return [] unless serializer && serializer.object - result = [] - resource_path = [parent, resource_name].compact.join('.') - if include_assoc?(resource_path) - primary_data = primary_data_for(serializer, @options) - relationships = relationships_for(serializer) - primary_data[:relationships] = relationships if relationships.any? - result.push(primary_data) - end + primary_data = primary_data_for(serializer, @options) + relationships = relationships_for(serializer) + primary_data[:relationships] = relationships if relationships.any? - if include_nested_assoc?(resource_path) - non_empty_associations = serializer.associations.select(&:serializer) + included = [primary_data] - non_empty_associations.each do |association| - result.concat(_included_for(association.key, association.serializer, resource_path)) - result.uniq! + includes.each do |inc| + association = serializer.associations.find { |assoc| assoc.key == inc.first } + if association + included.concat(_included_for(association.serializer, inc.second)) + included.uniq! end end - result + + included end end - def include_assoc?(assoc) - check_assoc("#{assoc}$") - end - - def include_nested_assoc?(assoc) - check_assoc("#{assoc}.") - end - - def check_assoc(assoc) - @options[:include].any? { |s| s.match(/^#{assoc.gsub('.', '\.')}/) } - end - def add_links(options) links = @hash.fetch(:links) { {} } collection = serializer.object diff --git a/lib/active_model/serializer/utils.rb b/lib/active_model/serializer/utils.rb new file mode 100644 index 00000000..689f48ca --- /dev/null +++ b/lib/active_model/serializer/utils.rb @@ -0,0 +1,35 @@ +module ActiveModel::Serializer::Utils + module_function + + # Translates a comma separated list of dot separated paths (JSONAPI format) into a Hash. + # Example: `'posts.author, posts.comments.upvotes, posts.comments.author'` would become `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`. + # + # @param [String] included + # @return [Hash] a Hash representing the same tree structure + def include_string_to_hash(included) + included.delete(' ').split(',').inject({}) do |hash, path| + hash.deep_merge!(path.split('.').reverse_each.inject({}) { |a, e| { e.to_sym => a } }) + end + end + + # Translates the arguments passed to the include option into a Hash. The format can be either + # a String (see #include_string_to_hash), an Array of Symbols and Hashes, or a mix of both. + # Example: `posts: [:author, comments: [:author, :upvotes]]` would become `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`. + # + # @param [Symbol, Hash, Array, String] included + # @return [Hash] a Hash representing the same tree structure + def include_args_to_hash(included) + case included + when Symbol + { included => {} } + when Hash + included.each_with_object({}) { |(key, value), hash| hash[key] = include_args_to_hash(value) } + when Array + included.inject({}) { |a, e| a.merge!(include_args_to_hash(e)) } + when String + include_string_to_hash(included) + else + {} + end + end +end diff --git a/test/action_controller/json_api/linked_test.rb b/test/action_controller/json_api/linked_test.rb index fc0c8793..ba317e21 100644 --- a/test/action_controller/json_api/linked_test.rb +++ b/test/action_controller/json_api/linked_test.rb @@ -43,29 +43,29 @@ module ActionController def render_resource_with_include setup_post - render json: @post, include: 'author', adapter: :json_api + render json: @post, include: [:author], adapter: :json_api end def render_resource_with_nested_include setup_post - render json: @post, include: 'comments.author', adapter: :json_api + render json: @post, include: [comments: [:author]], adapter: :json_api end def render_resource_with_nested_has_many_include setup_post - render json: @post, include: ['author', 'author.roles'], adapter: :json_api + render json: @post, include: 'author.roles', adapter: :json_api end def render_resource_with_missing_nested_has_many_include setup_post @post.author = @author2 # author2 has no roles. - render json: @post, include: 'author,author.roles', adapter: :json_api + render json: @post, include: [author: [:roles]], adapter: :json_api end def render_collection_with_missing_nested_has_many_include setup_post @post.author = @author2 - render json: [@post, @post2], include: 'author,author.roles', adapter: :json_api + render json: [@post, @post2], include: [author: [:roles]], adapter: :json_api end def render_collection_without_include @@ -75,7 +75,7 @@ module ActionController def render_collection_with_include setup_post - render json: [@post], include: %w(author comments), adapter: :json_api + render json: [@post], include: 'author, comments', adapter: :json_api end end @@ -141,8 +141,7 @@ module ActionController get :render_resource_with_nested_include response = JSON.parse(@response.body) assert response.key? 'included' - assert_equal 1, response['included'].size - assert_equal 'Anonymous', response['included'].first['attributes']['name'] + assert_equal 3, response['included'].size end def test_render_collection_without_include diff --git a/test/adapter/json_api/belongs_to_test.rb b/test/adapter/json_api/belongs_to_test.rb index 5cb6cdeb..5bfdc52b 100644 --- a/test/adapter/json_api/belongs_to_test.rb +++ b/test/adapter/json_api/belongs_to_test.rb @@ -38,7 +38,7 @@ module ActiveModel end def test_includes_linked_post - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'post') + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:post]) expected = [{ id: '42', type: 'posts', @@ -56,7 +56,7 @@ module ActiveModel end def test_limiting_linked_post_fields - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'post', fields: { post: [:title] }) + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:post], fields: { post: [:title] }) expected = [{ id: '42', type: 'posts', @@ -108,7 +108,7 @@ module ActiveModel def test_include_linked_resources_with_type_name serializer = BlogSerializer.new(@blog) - adapter = ActiveModel::Serializer::Adapter::JsonApi.new(serializer, include: %w(writer articles)) + adapter = ActiveModel::Serializer::Adapter::JsonApi.new(serializer, include: [:writer, :articles]) linked = adapter.serializable_hash[:included] expected = [ { diff --git a/test/adapter/json_api/has_many_explicit_serializer_test.rb b/test/adapter/json_api/has_many_explicit_serializer_test.rb index aedde98c..5c53fa01 100644 --- a/test/adapter/json_api/has_many_explicit_serializer_test.rb +++ b/test/adapter/json_api/has_many_explicit_serializer_test.rb @@ -24,7 +24,7 @@ module ActiveModel @serializer = PostPreviewSerializer.new(@post) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new( @serializer, - include: %w(comments author) + include: [:comments, :author] ) end diff --git a/test/adapter/json_api/has_many_test.rb b/test/adapter/json_api/has_many_test.rb index 277380a0..699126c2 100644 --- a/test/adapter/json_api/has_many_test.rb +++ b/test/adapter/json_api/has_many_test.rb @@ -42,7 +42,7 @@ module ActiveModel end def test_includes_linked_comments - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'comments') + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:comments]) expected = [{ id: '1', type: 'comments', @@ -68,7 +68,7 @@ module ActiveModel end def test_limit_fields_of_linked_comments - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'comments', fields: { comment: [:id] }) + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:comments], fields: { comment: [:id] }) expected = [{ id: '1', type: 'comments', diff --git a/test/adapter/json_api/has_one_test.rb b/test/adapter/json_api/has_one_test.rb index 3fb2bf5f..29582ddf 100644 --- a/test/adapter/json_api/has_one_test.rb +++ b/test/adapter/json_api/has_one_test.rb @@ -28,7 +28,7 @@ module ActiveModel @virtual_value = VirtualValue.new(id: 1) @serializer = AuthorSerializer.new(@author) - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'bio,posts') + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:bio, :posts]) end def test_includes_bio_id @@ -38,7 +38,7 @@ module ActiveModel end def test_includes_linked_bio - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'bio') + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: [:bio]) expected = [ { diff --git a/test/adapter/json_api/linked_test.rb b/test/adapter/json_api/linked_test.rb index 261c001a..7b4b43f1 100644 --- a/test/adapter/json_api/linked_test.rb +++ b/test/adapter/json_api/linked_test.rb @@ -43,11 +43,11 @@ module ActiveModel serializer = ArraySerializer.new([@first_post, @second_post]) adapter = ActiveModel::Serializer::Adapter::JsonApi.new( serializer, - include: ['author', 'author.bio', 'comments'] + include: [:comments, author: [:bio]] ) alt_adapter = ActiveModel::Serializer::Adapter::JsonApi.new( serializer, - include: 'author,author.bio,comments' + include: [:comments, author: [:bio]] ) expected = { @@ -153,11 +153,11 @@ module ActiveModel serializer = BioSerializer.new @bio1 adapter = ActiveModel::Serializer::Adapter::JsonApi.new( serializer, - include: ['author', 'author.posts'] + include: [author: [:posts]] ) alt_adapter = ActiveModel::Serializer::Adapter::JsonApi.new( serializer, - include: 'author,author.posts' + include: [author: [:posts]] ) expected = [ @@ -224,7 +224,7 @@ module ActiveModel serializer = ArraySerializer.new([@first_comment, @second_comment]) adapter = ActiveModel::Serializer::Adapter::JsonApi.new( serializer, - include: ['post'] + include: [:post] ) expected = [ @@ -257,7 +257,7 @@ module ActiveModel serializer = PostPreviewSerializer.new(@first_post) adapter = ActiveModel::Serializer::Adapter::JsonApi.new( serializer, - include: ['author'] + include: [:author] ) expected = { diff --git a/test/utils/include_args_to_hash_test.rb b/test/utils/include_args_to_hash_test.rb new file mode 100644 index 00000000..deb87f1c --- /dev/null +++ b/test/utils/include_args_to_hash_test.rb @@ -0,0 +1,79 @@ +require 'test_helper' + +module ActiveModel + class Serializer + module Utils + class IncludeArgsToHashTest < Minitest::Test + def test_nil + input = nil + expected = {} + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_empty_string + input = '' + expected = {} + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_single_string + input = 'author' + expected = { author: {} } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_multiple_strings + input = 'author,comments' + expected = { author: {}, comments: {} } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_multiple_strings_with_space + input = 'author, comments' + expected = { author: {}, comments: {} } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_nested_string + input = 'posts.author' + expected = { posts: { author: {} } } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_multiple_nested_string + input = 'posts.author,posts.comments.author,comments' + expected = { posts: { author: {}, comments: { author: {} } }, comments: {} } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_empty_array + input = [] + expected = {} + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_simple_array + input = [:comments, :author] + expected = { author: {}, comments: {} } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + + def test_nested_array + input = [:comments, posts: [:author, comments: [:author]]] + expected = { posts: { author: {}, comments: { author: {} } }, comments: {} } + actual = ActiveModel::Serializer::Utils.include_args_to_hash(input) + assert_equal(expected, actual) + end + end + end + end +end