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 Jun 9, 2016
1 parent 3594634 commit c4110bc
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 25 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 @@ -31,6 +32,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
37 changes: 37 additions & 0 deletions docs/general/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,43 @@ The user could pass in `include=**`.

We recommend filtering any user-supplied includes appropriately.

#### Include Parameters

You may wish to include related resources based on the `include` request
parameter (See [fetching_includes](http://jsonapi.org/format/#fetching-includes). You can opt-in to this pattern:

```ruby
class PostSerializer < ActiveModel::Serializer
associations_via_include_param(true)
end
```

The serializer will now behave as follows:

* If the relationship is in the `include` parameter, both
`relationships` and `included` will be populated.
* If the relationship is not in the `include` parameter, but a `link`
has been specified, `relationships` will contain the link but not
`id/type`s. This avoids an unnecessary database hit.
* If the relationship is not in the `include` parameter, and there is no
`link`, the relationship is rendered without `data`. This specifies
that the relationship exists, but we aren't saying anything about the
contents of that relationship (nil, empty, or otherwise).

To sync your URL `include` parameter with the `include` option passed to
`render`, you'll probably also want to add something like this:

```ruby
class PeopleController < ApplicationController
def index
render json: Person.all, include: params[:include]
end
end
```

Remember, you probably want to [whitelist your includes for security
purposes](https://github.com/rails-api/active_model_serializers/blob/master/docs/general/adapters.md#security-considerations).

## Advanced adapter configuration

### Registering an adapter
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_include_directive+ config value when not provided)
# @return [Enumerator<Association>]
#
def associations(include_directive = ActiveModelSerializers.default_include_directive)
def associations(include_directive = ActiveModelSerializers.default_include_directive, current_include_directive = nil)
current_include_directive ||= include_directive
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_directive.key?(key)
y.yield reflection.build_association(self, instance_options)

y.yield reflection.build_association(self, instance_options, current_include_directive)
end
end
end
Expand Down
48 changes: 39 additions & 9 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,23 @@ def include_data(value = true)
# Blog.find(object.blog_id)
# end
# end
def value(serializer)
def value(serializer, current_include_directive)
@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)
load_data { block_value } if block_value != :nil

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

Expand All @@ -106,11 +116,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_directive = {})
association_value = value(subject, current_include_directive)
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_directive)

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

private

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

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

def include_data?(serializer, current_include_directive)
if serializer.class._associations_via_include_param
current_include_directive.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 @@ -235,17 +235,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_directive) }
serializers.each { |serializer| process_relationships(serializer, @include_directive) }

[@primary, @included]
end

def process_resource(serializer, primary)
def process_resource(serializer, primary, include_directive = {})
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_directive)
if primary
@primary << resource_object
else
Expand All @@ -267,7 +267,7 @@ def process_relationship(serializer, include_directive)
return
end
return unless serializer && serializer.object
return unless process_resource(serializer, false)
return unless process_resource(serializer, false, include_directive)

process_relationships(serializer, include_directive)
end
Expand All @@ -293,7 +293,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_directive = {})
resource_object = serializer.fetch(self) do
resource_object = ResourceIdentifier.new(serializer, instance_options).as_json

Expand All @@ -304,7 +304,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_directive)
resource_object[:relationships] = relationships if relationships.any?

links = links_for(serializer)
Expand Down Expand Up @@ -432,12 +432,12 @@ def resource_object_for(serializer)
# id: 'required-id',
# meta: meta
# }.reject! {|_,v| v.nil? }
def relationships_for(serializer, requested_associations)
include_directive = JSONAPI::IncludeDirective.new(
def relationships_for(serializer, requested_associations, current_include_directive)
full_include_directive = JSONAPI::IncludeDirective.new(
requested_associations,
allow_wildcard: true
)
serializer.associations(include_directive).each_with_object({}) do |association, hash|
serializer.associations(full_include_directive, current_include_directive).each_with_object({}) do |association, hash|
hash[association.key] = Relationship.new(
serializer,
association.serializer,
Expand Down
Loading

0 comments on commit c4110bc

Please sign in to comment.