diff --git a/README.md b/README.md index 1ca6d156e..781d5dc3b 100644 --- a/README.md +++ b/README.md @@ -239,31 +239,34 @@ end ##### Options The association methods support the following options: + * `class_name` - a string specifying the underlying class for the related resource - * `foreign_key` - the method on the resource used to fetch the related resource. Defaults to `_id` for - has_one and `_ids` for has_many relationships. + * `foreign_key` - the method on the resource used to fetch the related resource. Defaults to `_id` for has_one and `_ids` for has_many relationships. * `acts_as_set` - allows the entire set of related records to be replaced in one operation. Defaults to false if not set. - * `relation_name` - the name of the relation to use on the model. A lambda may be provided which allows conditional - selection of the relation based on the context. - + * `relation_name` - the name of the relation to use on the model. A lambda may be provided which allows conditional selection of the relation based on the context. + * `polymorphic` - set to true to identify `has_one` associations that are polymorphic. + Examples: ```ruby - class CommentResource < JSONAPI::Resource +class CommentResource < JSONAPI::Resource attributes :body has_one :post has_one :author, class_name: 'Person' has_many :tags, acts_as_set: true - end -``` +end -```ruby class ExpenseEntryResource < JSONAPI::Resource attributes :cost, :transaction_date has_one :currency, class_name: 'Currency', foreign_key: 'currency_code' has_one :employee end + +class TagResource < JSONAPI::Resource + attributes :name + has_one :taggable, polymorphic: true +end ``` ```ruby @@ -283,8 +286,14 @@ class BookResource < JSONAPI::Resource } ... end -``` +The polymorphic association will require the resource and controller to exist, although routing to them will cause an error. + +```ruby +class TaggableResource < JSONAPI::Resource; end +class TaggablesController < JSONAPI::ResourceController; end +``` + #### Filters Filters for locating objects of the resource type are specified in the resource definition. Single filters can be diff --git a/lib/jsonapi/association.rb b/lib/jsonapi/association.rb index dbba16f3f..ae3d97848 100644 --- a/lib/jsonapi/association.rb +++ b/lib/jsonapi/association.rb @@ -1,16 +1,20 @@ module JSONAPI class Association - attr_reader :acts_as_set, :foreign_key, :type, :options, :name, :class_name + attr_reader :acts_as_set, :foreign_key, :type, :options, :name, + :class_name, :polymorphic def initialize(name, options = {}) - @name = name.to_s - @options = options - @acts_as_set = options.fetch(:acts_as_set, false) == true - @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil - @module_path = options[:module_path] || '' - @relation_name = options.fetch(:relation_name, @name) + @name = name.to_s + @options = options + @acts_as_set = options.fetch(:acts_as_set, false) == true + @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil + @module_path = options[:module_path] || '' + @relation_name = options.fetch(:relation_name, @name) + @polymorphic = options.fetch(:polymorphic, false) == true end + alias_method :polymorphic?, :polymorphic + def primary_key @primary_key ||= resource_klass._primary_key end @@ -32,6 +36,15 @@ def relation_name(options = {}) end end + def type_for_source(source) + if polymorphic? + resource = source.public_send(name) + resource.class._type if resource + else + type + end + end + class HasOne < Association def initialize(name, options = {}) super @@ -39,6 +52,10 @@ def initialize(name, options = {}) @type = class_name.underscore.pluralize.to_sym @foreign_key ||= "#{name}_id".to_sym end + + def polymorphic_type + "#{type.to_s.singularize}_type" if polymorphic? + end end class HasMany < Association diff --git a/lib/jsonapi/operation.rb b/lib/jsonapi/operation.rb index 7bfb7bcf9..b98842cd4 100644 --- a/lib/jsonapi/operation.rb +++ b/lib/jsonapi/operation.rb @@ -236,6 +236,25 @@ def apply end end + class ReplacePolymorphicHasOneAssociationOperation < Operation + attr_reader :resource_id, :association_type, :key_value, :key_type + + def initialize(resource_klass, options = {}) + @resource_id = options.fetch(:resource_id) + @key_value = options.fetch(:key_value) + @key_type = options.fetch(:key_type) + @association_type = options.fetch(:association_type).to_sym + super(resource_klass, options) + end + + def apply + resource = @resource_klass.find_by_key(@resource_id, context: @context) + result = resource.replace_polymorphic_has_one_link(@association_type, @key_value, @key_type) + + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + end + end + class CreateHasManyAssociationOperation < Operation attr_reader :resource_id, :association_type, :data diff --git a/lib/jsonapi/operations_processor.rb b/lib/jsonapi/operations_processor.rb index ceddde937..d80210826 100644 --- a/lib/jsonapi/operations_processor.rb +++ b/lib/jsonapi/operations_processor.rb @@ -12,6 +12,7 @@ class OperationsProcessor :remove_resource_operation, :replace_fields_operation, :replace_has_one_association_operation, + :replace_polymorphic_has_one_association_operation, :create_has_many_association_operation, :replace_has_many_association_operation, :remove_has_many_association_operation, diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb index 59317e336..d009d7ffa 100644 --- a/lib/jsonapi/request.rb +++ b/lib/jsonapi/request.rb @@ -185,7 +185,7 @@ def check_include(resource_klass, include_parts) association = resource_klass._association(association_name) if association && format_key(association_name) == include_parts.first unless include_parts.last.empty? - check_include(Resource.resource_for(@resource_klass.module_path + association.class_name.to_s), include_parts.last.partition('.')) + check_include(Resource.resource_for(@resource_klass.module_path + association.class_name.to_s.underscore), include_parts.last.partition('.')) end else @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), @@ -403,11 +403,8 @@ def parse_params(params, allowed_fields) end links_object = parse_has_one_links_object(linkage) - # Since we do not yet support polymorphic associations we will raise an error if the type does not match the - # association's type. - # TODO: Support Polymorphic associations - if links_object[:type] && (links_object[:type].to_s != association.type.to_s) - fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) + if !association.polymorphic? && links_object[:type] && (links_object[:type].to_s != association.type.to_s) + raise JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) end unless links_object[:id].nil? @@ -520,18 +517,31 @@ def parse_add_association_operation(data, association_type, parent_key) def parse_update_association_operation(data, association_type, parent_key) association = resource_klass._association(association_type) - if association.is_a?(JSONAPI::Association::HasOne) - object_params = { relationships: { format_key(association.name) => { data: data } } } - verified_param_set = parse_params(object_params, updatable_fields) - - @operations.push JSONAPI::ReplaceHasOneAssociationOperation.new( - resource_klass, - context: @context, - resource_id: parent_key, - association_type: association_type, - key_value: verified_param_set[:has_one].values[0] - ) + if association.polymorphic? + object_params = {relationships: {format_key(association.name) => {data: data}}} + verified_param_set = parse_params(object_params, updatable_fields) + + @operations.push JSONAPI::ReplacePolymorphicHasOneAssociationOperation.new( + resource_klass, + context: @context, + resource_id: parent_key, + association_type: association_type, + key_value: verified_param_set[:has_one].values[0], + key_type: data['type'] + ) + else + object_params = {relationships: {format_key(association.name) => {data: data}}} + verified_param_set = parse_params(object_params, updatable_fields) + + @operations.push JSONAPI::ReplaceHasOneAssociationOperation.new( + resource_klass, + context: @context, + resource_id: parent_key, + association_type: association_type, + key_value: verified_param_set[:has_one].values[0] + ) + end else unless association.acts_as_set fail JSONAPI::Exceptions::HasManySetReplacementForbidden.new @@ -541,12 +551,12 @@ def parse_update_association_operation(data, association_type, parent_key) verified_param_set = parse_params(object_params, updatable_fields) @operations.push JSONAPI::ReplaceHasManyAssociationOperation.new( - resource_klass, - context: @context, - resource_id: parent_key, - association_type: association_type, - data: verified_param_set[:has_many].values[0] - ) + resource_klass, + context: @context, + resource_id: parent_key, + association_type: association_type, + data: verified_param_set[:has_many].values[0] + ) end end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 53a02770f..2be281bcb 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -17,6 +17,7 @@ class Resource :replace_has_many_links, :create_has_one_link, :replace_has_one_link, + :replace_polymorphic_has_one_link, :remove_has_many_link, :remove_has_one_link, :replace_fields @@ -79,6 +80,12 @@ def replace_has_one_link(association_type, association_key_value) end end + def replace_polymorphic_has_one_link(association_type, association_key_value, association_key_type) + change :replace_polymorphic_has_one_link do + _replace_polymorphic_has_one_link(association_type, association_key_value, association_key_type) + end + end + def remove_has_many_link(association_type, key) change :remove_has_many_link do _remove_has_many_link(association_type, key) @@ -188,6 +195,17 @@ def _replace_has_one_link(association_type, association_key_value) :completed end + def _replace_polymorphic_has_one_link(association_type, key_value, key_type) + association = self.class._associations[association_type] + + send("#{association.foreign_key}=", key_value) + send("#{association.polymorphic_type}=", key_type.singularize.capitalize) + + @save_needed = true + + :completed + end + def _remove_has_many_link(association_type, key) association = self.class._associations[association_type] @@ -632,12 +650,11 @@ def _associate(klass, *attrs) attrs.each do |attr| check_reserved_association_name(attr) - - association = @_associations[attr] = klass.new(attr, options) + @_associations[attr] = association = klass.new(attr, options) associated_records_method_name = case association - when JSONAPI::Association::HasOne then "record_for_#{attr}" - when JSONAPI::Association::HasMany then "records_for_#{attr}" + when JSONAPI::Association::HasOne then "record_for_#{attr}" + when JSONAPI::Association::HasMany then "records_for_#{attr}" end foreign_key = association.foreign_key @@ -657,10 +674,16 @@ def _associate(klass, *attrs) end unless method_defined?(foreign_key) define_method attr do |options = {}| - resource_klass = association.resource_klass - if resource_klass + if association.polymorphic? associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil + resource_klass = Resource.resource_for(self.class.module_path + associated_model.class.to_s.underscore) if associated_model + return resource_klass.new(associated_model, @context) if resource_klass + else + resource_klass = association.resource_klass + if resource_klass + associated_model = public_send(associated_records_method_name) + return associated_model ? resource_klass.new(associated_model, @context) : nil + end end end unless method_defined?(attr) elsif association.is_a?(JSONAPI::Association::HasMany) diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index ad6bebf29..e1be8cae5 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -168,6 +168,7 @@ def relationship_data(source, include_directives) resource = source.send(name) if resource id = resource.id + type = association.type_for_source(source) associations_only = already_serialized?(type, id) if include_linkage && !associations_only add_included_object(type, id, object_hash(resource, ia)) @@ -232,7 +233,7 @@ def has_one_linkage(source, association) linkage = {} linkage_id = foreign_key_value(source, association) if linkage_id - linkage[:type] = format_key(association.type) + linkage[:type] = format_key(association.type_for_source(source)) linkage[:id] = linkage_id else linkage = nil diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index adae5de18..ef42d68a9 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -163,8 +163,13 @@ def jsonapi_related_resource(*association) association = source._associations[association_name] formatted_association_name = format_route(association.name) - related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(association.class_name.underscore.pluralize)) - options[:controller] ||= related_resource._type.to_s + + if association.polymorphic? + options[:controller] ||= association.class_name.underscore.pluralize + else + related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(association.class_name.underscore.pluralize)) + options[:controller] ||= related_resource._type.to_s + end match "#{formatted_association_name}", controller: options[:controller], association: association.name, source: resource_type_with_module_prefix(source._type), diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 5813b056c..69d4ac114 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -170,6 +170,23 @@ t.string :name t.string :status, limit: 10 end + + create_table :pictures, force: true do |t| + t.string :name + t.integer :imageable_id + t.string :imageable_type + t.timestamps null: false + end + + create_table :documents, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :products, force: true do |t| + t.string :name + t.timestamps null: false + end end ### MODELS @@ -347,6 +364,18 @@ class NumeroTelefone < ActiveRecord::Base class Category < ActiveRecord::Base end +class Picture < ActiveRecord::Base + belongs_to :imageable, polymorphic: true +end + +class Document < ActiveRecord::Base + has_many :pictures, as: :imageable +end + +class Product < ActiveRecord::Base + has_one :picture, as: :imageable +end + ### PORO Data - don't do this in a production app $breed_data = BreedData.new $breed_data.add(Breed.new(0, 'persian')) @@ -397,6 +426,18 @@ class FactsController < JSONAPI::ResourceController class CategoriesController < JSONAPI::ResourceController end +class PicturesController < JSONAPI::ResourceController +end + +class DocumentsController < JSONAPI::ResourceController +end + +class ProductsController < JSONAPI::ResourceController +end + +class ImageablesController < JSONAPI::ResourceController +end + ### CONTROLLERS module Api module V1 @@ -781,6 +822,32 @@ class CategoryResource < JSONAPI::Resource filter :status, default: 'active' end +class PictureResource < JSONAPI::Resource + attribute :name + has_one :imageable, polymorphic: true + + def imageable_type=(type) + model.imageable_type = type + end +end + +class DocumentResource < JSONAPI::Resource + attribute :name + has_many :pictures +end + +class ProductResource < JSONAPI::Resource + attribute :name + has_one :picture + + def picture_id + model.picture.id + end +end + +class ImageableResource < JSONAPI::Resource +end + module Api module V1 class WriterResource < JSONAPI::Resource diff --git a/test/fixtures/documents.yml b/test/fixtures/documents.yml new file mode 100644 index 000000000..12312278d --- /dev/null +++ b/test/fixtures/documents.yml @@ -0,0 +1,3 @@ +document_1: + id: 1 + name: Company Brochure diff --git a/test/fixtures/pictures.yml b/test/fixtures/pictures.yml new file mode 100644 index 000000000..62584e945 --- /dev/null +++ b/test/fixtures/pictures.yml @@ -0,0 +1,15 @@ +picture_1: + id: 1 + name: enterprise_gizmo.jpg + imageable_id: 1 + imageable_type: Product + +picture_2: + id: 2 + name: company_brochure.jpg + imageable_id: 1 + imageable_type: Document + +picture_3: + id: 3 + name: group_photo.jpg diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml new file mode 100644 index 000000000..77eab3ece --- /dev/null +++ b/test/fixtures/products.yml @@ -0,0 +1,3 @@ +product_1: + id: 1 + name: Enterprise Gizmo diff --git a/test/integration/routes/routes_test.rb b/test/integration/routes/routes_test.rb index 6d41c3705..b42780691 100644 --- a/test/integration/routes/routes_test.rb +++ b/test/integration/routes/routes_test.rb @@ -52,6 +52,53 @@ def test_routing_posts_links_tags_update_acts_as_set {controller: 'posts', action: 'update_association', post_id: '1', association: 'tags'}) end + # Polymorphic + def test_routing_polymorphic_get_related_resource + assert_routing( + { + path: '/pictures/1/imageable', + method: :get + }, + { + association: 'imageable', + source: 'pictures', + controller: 'imageables', + action: 'get_related_resource', + picture_id: '1' + } + ) + end + + def test_routing_polymorphic_patch_related_resource + assert_routing( + { + path: '/pictures/1/relationships/imageable', + method: :patch + }, + { + association: 'imageable', + controller: 'pictures', + action: 'update_association', + picture_id: '1' + } + ) + end + + def test_routing_polymorphic_delete_related_resource + assert_routing( + { + path: '/pictures/1/relationships/imageable', + method: :delete + }, + { + association: 'imageable', + controller: 'pictures', + action: 'destroy_association', + picture_id: '1' + } + ) + end + # V1 def test_routing_v1_posts_show assert_routing({path: '/api/v1/posts/1', method: :get}, diff --git a/test/test_helper.rb b/test/test_helper.rb index 66218f593..7fce66b54 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -105,6 +105,9 @@ def show_queries jsonapi_resources :preferences jsonapi_resources :facts jsonapi_resources :categories + jsonapi_resources :pictures + jsonapi_resources :documents + jsonapi_resources :products namespace :api do namespace :v1 do diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb new file mode 100644 index 000000000..aa526a64a --- /dev/null +++ b/test/unit/serializer/polymorphic_serializer_test.rb @@ -0,0 +1,249 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' +require 'json' + +class PolymorphismTest < ActionDispatch::IntegrationTest + def setup + @pictures = Picture.all + + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + end + + def after_teardown + JSONAPI.configuration.json_key_format = :underscored_key + end + + def test_polymorphic_association + associations = PictureResource._associations + imageable = associations[:imageable] + + assert_equal associations.size, 1 + assert imageable.polymorphic? + end + + def test_polymorphic_serialization + serialized_data = JSONAPI::ResourceSerializer.new( + PictureResource, + include: %w(imageable) + ).serialize_to_hash(@pictures.map { |p| PictureResource.new p }) + + assert_hash_equals( + { + data: [ + { + id: '1', + type: 'pictures', + links: { + self: '/pictures/1' + }, + attributes: { + name: 'enterprise_gizmo.jpg' + }, + relationships: { + imageable: { + links: { + self: '/pictures/1/relationships/imageable', + related: '/pictures/1/imageable' + }, + data: { + type: 'products', + id: '1' + } + } + } + }, + { + id: '2', + type: 'pictures', + links: { + self: '/pictures/2' + }, + attributes: { + name: 'company_brochure.jpg' + }, + relationships: { + imageable: { + links: { + self: '/pictures/2/relationships/imageable', + related: '/pictures/2/imageable' + }, + data: { + type: 'documents', + id: '1' + } + } + } + }, + { + id: '3', + type: 'pictures', + links: { + self: '/pictures/3' + }, + attributes: { + name: 'group_photo.jpg' + }, + relationships: { + imageable: { + links: { + self: '/pictures/3/relationships/imageable', + related: '/pictures/3/imageable' + }, + data: nil + } + } + } + + ], + :included => [ + { + id: '1', + type: 'products', + links: { + self: '/products/1' + }, + attributes: { + name: 'Enterprise Gizmo' + }, + relationships: { + picture: { + links: { + self: '/products/1/relationships/picture', + related: '/products/1/picture', + }, + data: { + type: 'pictures', + id: '1' + } + } + } + }, + { + id: '1', + type: 'documents', + links: { + self: '/documents/1' + }, + attributes: { + name: 'Company Brochure' + }, + relationships: { + pictures: { + links: { + self: '/documents/1/relationships/pictures', + related: '/documents/1/pictures' + } + } + } + } + ] + }, + serialized_data + ) + end + + def test_polymorphic_get_related_resource + get '/pictures/1/imageable' + serialized_data = JSON.parse(response.body) + assert_hash_equals( + { + data: { + id: '1', + type: 'products', + links: { + self: 'http://www.example.com/products/1' + }, + attributes: { + name: 'Enterprise Gizmo' + }, + relationships: { + picture: { + links: { + self: 'http://www.example.com/products/1/relationships/picture', + related: 'http://www.example.com/products/1/picture' + }, + data: { + type: 'pictures', + id: '1' + } + } + } + } + }, + serialized_data + ) + end + + + def test_polymorphic_create_relationship + picture = Picture.find(3) + original_imageable = picture.imageable + assert_nil original_imageable + + patch "/pictures/#{picture.id}/relationships/imageable", + { + association: 'imageable', + data: { + type: 'documents', + id: '1' + } + }.to_json, + { + 'Content-Type' => JSONAPI::MEDIA_TYPE + } + assert_response :no_content + picture = Picture.find(3) + assert_equal 'Document', picture.imageable.class.to_s + + # restore data + picture.imageable = original_imageable + picture.save + end + + def test_polymorphic_update_relationship + picture = Picture.find(1) + original_imageable = picture.imageable + assert_not_equal 'Document', picture.imageable.class.to_s + + patch "/pictures/#{picture.id}/relationships/imageable", + { + association: 'imageable', + data: { + type: 'documents', + id: '1' + } + }.to_json, + { + 'Content-Type' => JSONAPI::MEDIA_TYPE + } + assert_response :no_content + picture = Picture.find(1) + assert_equal 'Document', picture.imageable.class.to_s + + # restore data + picture.imageable = original_imageable + picture.save + end + + def test_polymorphic_delete_relationship + picture = Picture.find(1) + original_imageable = picture.imageable + assert original_imageable + + delete "/pictures/#{picture.id}/relationships/imageable", + { + association: 'imageable' + }.to_json, + { + 'Content-Type' => JSONAPI::MEDIA_TYPE + } + assert_response :no_content + picture = Picture.find(1) + assert_nil picture.imageable + + # restore data + picture.imageable = original_imageable + picture.save + end +end