From 796a1058fe6db9dc8d3d8acd5909a12acf462d47 Mon Sep 17 00:00:00 2001
From: Lucas Hosseini <lucas.hosseini@gmail.com>
Date: Thu, 21 Jan 2016 01:10:02 +0100
Subject: [PATCH] Add support for relationship-level links and meta.

---
 .../serializer/adapter/json_api.rb            | 56 +++++--------------
 .../adapter/json_api/association.rb           | 48 ++++++++++++++++
 .../adapter/json_api/resource_identifier.rb   | 41 ++++++++++++++
 lib/active_model/serializer/association.rb    |  2 +-
 lib/active_model/serializer/reflection.rb     | 39 ++++++++++++-
 test/adapter/json_api/links_test.rb           | 33 ++++++++++-
 test/serializers/associations_test.rb         |  8 +--
 7 files changed, 177 insertions(+), 50 deletions(-)
 create mode 100644 lib/active_model/serializer/adapter/json_api/association.rb
 create mode 100644 lib/active_model/serializer/adapter/json_api/resource_identifier.rb

diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb
index 1343e1239..e00a46eac 100644
--- a/lib/active_model/serializer/adapter/json_api.rb
+++ b/lib/active_model/serializer/adapter/json_api.rb
@@ -6,6 +6,8 @@ class JsonApi < Base
         autoload :PaginationLinks
         autoload :FragmentCache
         autoload :Link
+        autoload :Association
+        autoload :ResourceIdentifier
         autoload :Deserialization
 
         # TODO: if we like this abstraction and other API objects to it,
@@ -97,7 +99,7 @@ def resource_objects_for(serializers)
         end
 
         def process_resource(serializer, primary)
-          resource_identifier = resource_identifier_for(serializer)
+          resource_identifier = JsonApi::ResourceIdentifier.new(serializer).as_json
           return false unless @resource_identifiers.add?(resource_identifier)
 
           resource_object = resource_object_for(serializer)
@@ -127,37 +129,13 @@ def process_relationship(serializer, include_tree)
           process_relationships(serializer, include_tree)
         end
 
-        def resource_identifier_type_for(serializer)
-          return serializer._type if serializer._type
-          if ActiveModelSerializers.config.jsonapi_resource_type == :singular
-            serializer.object.class.model_name.singular
-          else
-            serializer.object.class.model_name.plural
-          end
-        end
-
-        def resource_identifier_id_for(serializer)
-          if serializer.respond_to?(:id)
-            serializer.id
-          else
-            serializer.object.id
-          end
-        end
-
-        def resource_identifier_for(serializer)
-          type = resource_identifier_type_for(serializer)
-          id   = resource_identifier_id_for(serializer)
-
-          { id: id.to_s, type: type }
-        end
-
         def attributes_for(serializer, fields)
           serializer.attributes(fields).except(:id)
         end
 
         def resource_object_for(serializer)
           resource_object = cache_check(serializer) do
-            resource_object = resource_identifier_for(serializer)
+            resource_object = JsonApi::ResourceIdentifier.new(serializer).as_json
 
             requested_fields = fieldset && fieldset.fields_for(resource_object[:type])
             attributes = attributes_for(serializer, requested_fields)
@@ -165,7 +143,8 @@ def resource_object_for(serializer)
             resource_object
           end
 
-          relationships = relationships_for(serializer)
+          requested_associations = fieldset.fields_for(resource_object[:type]) || '*'
+          relationships = relationships_for(serializer, requested_associations)
           resource_object[:relationships] = relationships if relationships.any?
 
           links = links_for(serializer)
@@ -174,24 +153,15 @@ def resource_object_for(serializer)
           resource_object
         end
 
-        def relationship_value_for(serializer, options = {})
-          if serializer.respond_to?(:each)
-            serializer.map { |s| resource_identifier_for(s) }
-          else
-            if options[:virtual_value]
-              options[:virtual_value]
-            elsif serializer && serializer.object
-              resource_identifier_for(serializer)
-            end
-          end
-        end
-
-        def relationships_for(serializer)
-          resource_type = resource_identifier_type_for(serializer)
-          requested_associations = fieldset.fields_for(resource_type) || '*'
+        def relationships_for(serializer, requested_associations)
           include_tree = IncludeTree.from_include_args(requested_associations)
           serializer.associations(include_tree).each_with_object({}) do |association, hash|
-            hash[association.key] = { data: relationship_value_for(association.serializer, association.options) }
+            hash[association.key] = JsonApi::Association.new(serializer,
+                                                             association.serializer,
+                                                             association.options,
+                                                             association.links,
+                                                             association.meta)
+                                    .as_json
           end
         end
 
diff --git a/lib/active_model/serializer/adapter/json_api/association.rb b/lib/active_model/serializer/adapter/json_api/association.rb
new file mode 100644
index 000000000..b6cfc70dd
--- /dev/null
+++ b/lib/active_model/serializer/adapter/json_api/association.rb
@@ -0,0 +1,48 @@
+module ActiveModel
+  class Serializer
+    module Adapter
+      class JsonApi
+        class Association
+          def initialize(parent_serializer, serializer, options, links, meta)
+            @object = parent_serializer.object
+            @scope = parent_serializer.scope
+
+            @options = options
+            @data = data_for(serializer, options)
+            @links = links
+                     .map { |key, value| { key => Link.new(parent_serializer, value).as_json } }
+                     .reduce({}, :merge)
+            @meta = meta.respond_to?(:call) ? parent_serializer.instance_eval(&meta) : meta
+          end
+
+          def as_json
+            hash = {}
+            hash[:data] = @data if @options[:include_data]
+            hash[:links] = @links if @links.any?
+            hash[:meta] = @meta if @meta
+
+            hash
+          end
+
+          protected
+
+          attr_reader :object, :scope
+
+          private
+
+          def data_for(serializer, options)
+            if serializer.respond_to?(:each)
+              serializer.map { |s| ResourceIdentifier.new(s).as_json }
+            else
+              if options[:virtual_value]
+                options[:virtual_value]
+              elsif serializer && serializer.object
+                ResourceIdentifier.new(serializer).as_json
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/active_model/serializer/adapter/json_api/resource_identifier.rb b/lib/active_model/serializer/adapter/json_api/resource_identifier.rb
new file mode 100644
index 000000000..99bff2981
--- /dev/null
+++ b/lib/active_model/serializer/adapter/json_api/resource_identifier.rb
@@ -0,0 +1,41 @@
+module ActiveModel
+  class Serializer
+    module Adapter
+      class JsonApi
+        class ResourceIdentifier
+          def initialize(serializer)
+            @id = id_for(serializer)
+            @type = type_for(serializer)
+          end
+
+          def as_json
+            { id: @id.to_s, type: @type }
+          end
+
+          protected
+
+          attr_reader :object, :scope
+
+          private
+
+          def type_for(serializer)
+            return serializer._type if serializer._type
+            if ActiveModelSerializers.config.jsonapi_resource_type == :singular
+              serializer.object.class.model_name.singular
+            else
+              serializer.object.class.model_name.plural
+            end
+          end
+
+          def id_for(serializer)
+            if serializer.respond_to?(:id)
+              serializer.id
+            else
+              serializer.object.id
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/active_model/serializer/association.rb b/lib/active_model/serializer/association.rb
index 1003f0a6f..cbe167527 100644
--- a/lib/active_model/serializer/association.rb
+++ b/lib/active_model/serializer/association.rb
@@ -9,7 +9,7 @@ class Serializer
     # @example
     #  Association.new(:comments, CommentSummarySerializer)
     #
-    Association = Struct.new(:name, :serializer, :options) do
+    Association = Struct.new(:name, :serializer, :options, :links, :meta) do
       # @return [Symbol]
       #
       def key
diff --git a/lib/active_model/serializer/reflection.rb b/lib/active_model/serializer/reflection.rb
index c0287b646..9e520c07e 100644
--- a/lib/active_model/serializer/reflection.rb
+++ b/lib/active_model/serializer/reflection.rb
@@ -34,6 +34,38 @@ class Serializer
     # So you can inspect reflections in your Adapters.
     #
     class Reflection < Field
+      def initialize(*)
+        super
+        @_links = {}
+        @_include_data = true
+      end
+
+      def link(name, value = nil, &block)
+        @_links[name] = block || value
+        nil
+      end
+
+      def meta(value = nil, &block)
+        @_meta = block || value
+        nil
+      end
+
+      def include_data(value = true)
+        @_include_data = value
+        nil
+      end
+
+      def value(serializer)
+        @object = serializer.object
+        @scope = serializer.scope
+
+        if block
+          instance_eval(&block)
+        else
+          serializer.read_attribute_for_serialization(name)
+        end
+      end
+
       # Build association. This method is used internally to
       # build serializer's association by its reflection.
       #
@@ -59,6 +91,7 @@ def build_association(subject, parent_serializer_options)
         association_value = value(subject)
         reflection_options = options.dup
         serializer_class = subject.class.serializer_for(association_value, reflection_options)
+        reflection_options[:include_data] = _include_data
 
         if serializer_class
           begin
@@ -73,9 +106,13 @@ def build_association(subject, parent_serializer_options)
           reflection_options[:virtual_value] = association_value
         end
 
-        Association.new(name, serializer, reflection_options)
+        Association.new(name, serializer, reflection_options, _links, _meta)
       end
 
+      protected
+
+      attr_accessor :object, :scope, :_links, :_meta, :_include_data
+
       private
 
       def serializer_options(subject, parent_serializer_options, reflection_options)
diff --git a/test/adapter/json_api/links_test.rb b/test/adapter/json_api/links_test.rb
index dbda88ea0..11873ac44 100644
--- a/test/adapter/json_api/links_test.rb
+++ b/test/adapter/json_api/links_test.rb
@@ -7,6 +7,8 @@ class JsonApi
         class LinksTest < ActiveSupport::TestCase
           LinkAuthor = Class.new(::Model)
           class LinkAuthorSerializer < ActiveModel::Serializer
+            type 'author'
+
             link :self do
               href "//example.com/link_author/#{object.id}"
               meta stuff: 'value'
@@ -17,11 +19,23 @@ class LinkAuthorSerializer < ActiveModel::Serializer
             link :yet_another do
               "//example.com/resource/#{object.id}"
             end
+
+            has_many :posts do
+              link :self do
+                href '//example.com/link_author/relationships/posts'
+                meta stuff: 'value'
+              end
+              link :related do
+                href '//example.com/link_author/posts'
+                meta count: object.posts.count
+              end
+              include_data false
+            end
           end
 
           def setup
             @post = Post.new(id: 1337, comments: [], author: nil)
-            @author = LinkAuthor.new(id: 1337)
+            @author = LinkAuthor.new(id: 1337, posts: [@post])
           end
 
           def test_toplevel_links
@@ -61,6 +75,23 @@ def test_resource_links
             }
             assert_equal(expected, hash[:data][:links])
           end
+
+          def test_relationship_links
+            hash = serializable(@author, adapter: :json_api).serializable_hash
+            expected = {
+              links: {
+                self: {
+                  href: '//example.com/link_author/relationships/posts',
+                  meta: { stuff: 'value' }
+                },
+                related: {
+                  href: '//example.com/link_author/posts',
+                  meta: { count: 1 }
+                }
+              }
+            }
+            assert_equal(expected, hash[:data][:relationships][:posts])
+          end
         end
       end
     end
diff --git a/test/serializers/associations_test.rb b/test/serializers/associations_test.rb
index aa0cae085..f62da8b81 100644
--- a/test/serializers/associations_test.rb
+++ b/test/serializers/associations_test.rb
@@ -32,13 +32,13 @@ def test_has_many_and_has_one
 
           case key
           when :posts
-            assert_equal({}, options)
+            assert_equal({ include_data: true }, options)
             assert_kind_of(ActiveModelSerializers.config.collection_serializer, serializer)
           when :bio
-            assert_equal({}, options)
+            assert_equal({ include_data: true }, options)
             assert_nil serializer
           when :roles
-            assert_equal({}, options)
+            assert_equal({ include_data: true }, options)
             assert_kind_of(ActiveModelSerializers.config.collection_serializer, serializer)
           else
             flunk "Unknown association: #{key}"
@@ -80,7 +80,7 @@ def test_belongs_to
             flunk "Unknown association: #{key}"
           end
 
-          assert_equal({}, association.options)
+          assert_equal({ include_data: true }, association.options)
         end
       end