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 #1707
Fixes #1555
  • Loading branch information
Lee Richmond committed May 27, 2016
1 parent f2cb497 commit 48e5064
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 26 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Misc:

Breaking changes:
- [#1662](https://github.com/rails-api/active_model_serializers/pull/1662) Drop support for Rails 4.0 and Ruby 2.0.0. (@remear)
- [#1720](https://github.com/rails-api/active_model_serializers/pull/1720) Block relationships must explicitly use `load_data` instead of the return value to customize relationship contents.

Features:
- [#1677](https://github.com/rails-api/active_model_serializers/pull/1677) Add `assert_schema`, `assert_request_schema`, `assert_request_response_schema`. (@bf4)
Expand All @@ -28,6 +29,7 @@ Features:
- [#1687](https://github.com/rails-api/active_model_serializers/pull/1687) Only calculate `_cache_digest` (in `cache_key`) when `skip_digest` is false. (@bf4)
- [#1647](https://github.com/rails-api/active_model_serializers/pull/1647) Restrict usage of `serializable_hash` options
to the ActiveModel::Serialization and ActiveModel::Serializers::JSON interface. (@bf4)
- [#1720](https://github.com/rails-api/active_model_serializers/pull/1720) Support JSONAPI 'include` parameter with links/loading.

Fixes:
- [#1700](https://github.com/rails-api/active_model_serializers/pull/1700) Support pagination link for Kaminari when no data is returned. (@iamnader)
Expand Down
6 changes: 3 additions & 3 deletions docs/general/serializers.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ has_many :comments, key: :reviews
has_many :comments, serializer: CommentPreviewSerializer
has_many :reviews, virtual_value: [{ id: 1 }, { id: 2 }]
has_many :comments, key: :last_comments do
last(1)
load_data { last(1) }
end
```

Expand Down Expand Up @@ -252,7 +252,7 @@ class PostSerializer < ActiveModel::Serializer

# scope comments to those created_by the current user
has_many :comments do
object.comments.where(created_by: current_user)
load_data { object.comments.where(created_by: current_user) }
end
end
```
Expand Down Expand Up @@ -365,7 +365,7 @@ To override an association, call `has_many`, `has_one` or `belongs_to` with a bl
```ruby
class PostSerializer < ActiveModel::Serializer
has_many :comments do
object.comments.active
load_data { object.comments.active }
end
end
```
Expand Down
11 changes: 9 additions & 2 deletions lib/active_model/serializer/associations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 @@ -65,6 +66,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 @@ -82,15 +87,17 @@ def associate(reflection)
# default_includes config value when not provided)
# @return [Enumerator<Association>]
#
def associations(include_tree = ActiveModelSerializers.default_include_tree)
def associations(include_tree = ActiveModelSerializers.default_include_tree, current_include_tree = nil)
current_include_tree ||= 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
block_value
elsif @_include_data
serializer.read_attribute_for_serialization(name)
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
Loading

0 comments on commit 48e5064

Please sign in to comment.