Skip to content

Commit

Permalink
Support JSONAPI include params with links/loading
Browse files Browse the repository at this point in the history
The current implementation does support conditionally sideloading
relationships based on the 'include' URL param. However, omitting the
relationship still loads the relationship (to populate the type/id
'relationships' payload), somewhat defeating the purpose. Instead, this
changes the flow to:

1. If the relationship is included, load it and include it the response.
2. If the relationship is not included but there is a JSONAPI link,
include the link in the response but do not load the relationship or
include data.
3. If the relationship is not in the URL param and there is no link, do
not include this node in the 'relationships' response.

The `current_include_tree` edits in json_api.rb are to pass the current
nested includes. This is to support when multiple entities have the same
relationship, e.g. `/blogs/?include=posts.tags,tags` should include both
blog tags and post tags, but `/blogs/?include=posts.tags` should only
include post tags.

This API is opt-in to support users who always want to load
`relationships` data. To opt-in:

```ruby
class BlogSerializer < ActiveModel::Serializer
  associations_via_include_param(true) # opt-in to this pattern

  has_many :tags
  has_many :posts do
    link :self, '//example.com/blogs/relationships/posts'
  end
end
```

JSONAPI include parameters (http://jsonapi.org/format/#fetching-includes).

Fixes rails-api#1707
Fixes rails-api#1555
  • Loading branch information
Lee Richmond committed May 9, 2016
1 parent 2b49899 commit 1664fbc
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 23 deletions.
10 changes: 8 additions & 2 deletions lib/active_model/serializer/associations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module Associations
included do
with_options instance_writer: false, instance_reader: true do |serializer|
serializer.class_attribute :_reflections
serializer.class_attribute :_associations_via_include_param
self._reflections ||= []
end

Expand Down Expand Up @@ -67,6 +68,10 @@ def has_one(name, options = {}, &block)
associate(HasOneReflection.new(name, options, block))
end

def associations_via_include_param(val)
self._associations_via_include_param = val
end

private

# Add reflection and define {name} accessor.
Expand All @@ -83,15 +88,16 @@ def associate(reflection)
# @param [IncludeTree] include_tree (defaults to all associations when not provided)
# @return [Enumerator<Association>]
#
def associations(include_tree = DEFAULT_INCLUDE_TREE)
def associations(include_tree = DEFAULT_INCLUDE_TREE, current_include_tree = {})
return unless object

Enumerator.new do |y|
self.class._reflections.each do |reflection|
next if reflection.excluded?(self)
key = reflection.options.fetch(:key, reflection.name)
next unless include_tree.key?(key)
y.yield reflection.build_association(self, instance_options)

y.yield reflection.build_association(self, instance_options, current_include_tree)
end
end
end
Expand Down
49 changes: 39 additions & 10 deletions lib/active_model/serializer/reflection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def initialize(*)
super
@_links = {}
@_include_data = true
@_load_data = false
@_meta = nil
end

Expand All @@ -56,6 +57,11 @@ def include_data(value = true)
:nil
end

def load_data(&blk)
@_load_data = blk
:nil
end

# @param serializer [ActiveModel::Serializer]
# @yield [ActiveModel::Serializer]
# @return [:nil, associated resource or resource collection]
Expand All @@ -69,19 +75,22 @@ def include_data(value = true)
# Blog.find(object.blog_id)
# end
# end
def value(serializer)
def value(serializer, current_include_tree)
@object = serializer.object
@scope = serializer.scope

if block
block_value = instance_exec(serializer, &block)
if block_value == :nil
serializer.read_attribute_for_serialization(name)
else
block_value
instance_exec(serializer, &block)

if include_data?(serializer, current_include_tree)
if @_load_data
@_load_data.call(current_include_tree)
else
include_data_for(serializer, current_include_tree)
end
end
else
serializer.read_attribute_for_serialization(name)
include_data_for(serializer, current_include_tree)
end
end

Expand All @@ -106,11 +115,11 @@ def value(serializer)
#
# @api private
#
def build_association(subject, parent_serializer_options)
association_value = value(subject)
def build_association(subject, parent_serializer_options, current_include_tree = {})
association_value = value(subject, current_include_tree)
reflection_options = options.dup
serializer_class = subject.class.serializer_for(association_value, reflection_options)
reflection_options[:include_data] = @_include_data
reflection_options[:include_data] = include_data?(subject, current_include_tree)

if serializer_class
begin
Expand All @@ -134,6 +143,26 @@ def build_association(subject, parent_serializer_options)

private

def include_data_for(serializer, current_include_tree)
return unless include_data?(serializer, current_include_tree)

if serializer.class._associations_via_include_param
if current_include_tree.key?(name)
serializer.read_attribute_for_serialization(name)
end
else
serializer.read_attribute_for_serialization(name)
end
end

def include_data?(serializer, current_include_tree)
if serializer.class._associations_via_include_param
current_include_tree.key?(name)
else
@_include_data
end
end

def serializer_options(subject, parent_serializer_options, reflection_options)
serializer = reflection_options.fetch(:serializer, nil)

Expand Down
18 changes: 9 additions & 9 deletions lib/active_model_serializers/adapter/json_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -231,17 +231,17 @@ def resource_objects_for(serializers)
@primary = []
@included = []
@resource_identifiers = Set.new
serializers.each { |serializer| process_resource(serializer, true) }
serializers.each { |serializer| process_resource(serializer, true, @include_tree) }
serializers.each { |serializer| process_relationships(serializer, @include_tree) }

[@primary, @included]
end

def process_resource(serializer, primary)
def process_resource(serializer, primary, include_tree = {})
resource_identifier = ResourceIdentifier.new(serializer, instance_options).as_json
return false unless @resource_identifiers.add?(resource_identifier)

resource_object = resource_object_for(serializer)
resource_object = resource_object_for(serializer, include_tree)
if primary
@primary << resource_object
else
Expand All @@ -263,7 +263,7 @@ def process_relationship(serializer, include_tree)
return
end
return unless serializer && serializer.object
return unless process_resource(serializer, false)
return unless process_resource(serializer, false, include_tree)

process_relationships(serializer, include_tree)
end
Expand All @@ -289,7 +289,7 @@ def attributes_for(serializer, fields)
end

# {http://jsonapi.org/format/#document-resource-objects Document Resource Objects}
def resource_object_for(serializer)
def resource_object_for(serializer, include_tree = {})
resource_object = cache_check(serializer) do
resource_object = ResourceIdentifier.new(serializer, instance_options).as_json

Expand All @@ -300,7 +300,7 @@ def resource_object_for(serializer)
end

requested_associations = fieldset.fields_for(resource_object[:type]) || '*'
relationships = relationships_for(serializer, requested_associations)
relationships = relationships_for(serializer, requested_associations, include_tree)
resource_object[:relationships] = relationships if relationships.any?

links = links_for(serializer)
Expand Down Expand Up @@ -428,9 +428,9 @@ def resource_object_for(serializer)
# id: 'required-id',
# meta: meta
# }.reject! {|_,v| v.nil? }
def relationships_for(serializer, requested_associations)
include_tree = ActiveModel::Serializer::IncludeTree.from_include_args(requested_associations)
serializer.associations(include_tree).each_with_object({}) do |association, hash|
def relationships_for(serializer, requested_associations, current_include_tree)
full_include_tree = ActiveModel::Serializer::IncludeTree.from_include_args(requested_associations)
serializer.associations(full_include_tree, current_include_tree).each_with_object({}) do |association, hash|
hash[association.key] = Relationship.new(
serializer,
association.serializer,
Expand Down
154 changes: 154 additions & 0 deletions test/adapter/json_api/include_param_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
require 'test_helper'

module ActiveModel
class Serializer
module Adapter
class JsonApi
class IncludeParamTest < ActiveSupport::TestCase
IncludeParamAuthor = Class.new(::Model)

class CustomCommentLoader
def all
[{ foo: 'bar' }]
end
end

class TagSerializer < ActiveModel::Serializer
attributes :id, :name
end

class IncludeParamAuthorSerializer < ActiveModel::Serializer
class_attribute :comment_loader

associations_via_include_param(true)

has_many :tags, serializer: TagSerializer do
link :self, '//example.com/link_author/relationships/tags'
end

has_many :unlinked_tags, serializer: TagSerializer

has_many :posts, serializer: PostWithTagsSerializer
has_many :locations
has_many :comments do
load_data { IncludeParamAuthorSerializer.comment_loader.all }
end
end

def setup
IncludeParamAuthorSerializer.comment_loader = Class.new(CustomCommentLoader).new
@tag = Tag.new(id: 1337, name: 'mytag')
@author = IncludeParamAuthor.new(
id: 1337,
tags: [@tag]
)
end

def test_relationship_not_loaded_when_not_included
expected = {
links: {
self: '//example.com/link_author/relationships/tags'
}
}

@author.define_singleton_method(:read_attribute_for_serialization) do |attr|
fail 'should not be called' if attr == :tags
super(attr)
end

assert_relationship(:tags, expected)
end

def test_relationship_included
expected = {
data: [
{
id: '1337',
type: 'tags'
}
],
links: {
self: '//example.com/link_author/relationships/tags'
}
}

assert_relationship(:tags, expected, include: :tags)
end

def test_nested_relationship
expected = {
data: [
{
id: '1337',
type: 'tags'
}
],
links: {
self: '//example.com/link_author/relationships/tags'
}
}

expected_no_data = {
links: {
self: '//example.com/link_author/relationships/tags'
}
}

assert_relationship(:tags, expected, include: [:tags, { posts: :tags }])

@author.define_singleton_method(:read_attribute_for_serialization) do |attr|
fail 'should not be called' if attr == :tags
super(attr)
end

assert_relationship(:tags, expected_no_data, include: { posts: :tags })
end

def test_include_params_with_no_block
@author.define_singleton_method(:read_attribute_for_serialization) do |attr|
fail 'should not be called' if attr == :locations
super(attr)
end

expected = {}

assert_relationship(:locations, expected)
end

def test_block_relationship
expected = {
data: [
{ 'foo' => 'bar' }
]
}

assert_relationship(:comments, expected, include: [:comments])
end

def test_block_relationship_not_included
expected = {}

IncludeParamAuthorSerializer.comment_loader.define_singleton_method(:all) do
fail 'should not be called'
end

assert_relationship(:comments, expected)
end

def test_node_not_included_when_no_link
expected = nil
assert_relationship(:unlinked_tags, expected)
end

private

def assert_relationship(relationship_name, expected, opts = {})
opts = { adapter: :json_api }.merge(opts)
hash = serializable(@author, opts).serializable_hash
assert_equal(expected, hash[:data][:relationships][relationship_name])
end
end
end
end
end
end
2 changes: 1 addition & 1 deletion test/adapter/json_api/relationships_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class RelationshipAuthorSerializer < ActiveModel::Serializer

has_many :roles do |serializer|
meta count: object.posts.count
serializer.cached_roles
load_data { serializer.cached_roles }
end

has_one :blog do
Expand Down
2 changes: 1 addition & 1 deletion test/serializers/associations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def test_associations_custom_keys
class InlineAssociationTestPostSerializer < ActiveModel::Serializer
has_many :comments
has_many :comments, key: :last_comments do
object.comments.last(1)
load_data { object.comments.last(1) }
end
end

Expand Down

0 comments on commit 1664fbc

Please sign in to comment.