From 845c96b9360f512edcc40db63de4e8ca71f2c423 Mon Sep 17 00:00:00 2001 From: Campbell Allen Date: Thu, 12 Feb 2015 09:52:23 +0000 Subject: [PATCH] support polymorphic belongs_to associations --- lib/restpack_serializer/factory.rb | 30 ++++++---- lib/restpack_serializer/serializable.rb | 26 ++++++++- .../serializable/paging.rb | 6 +- .../serializable/side_load_data_builder.rb | 18 ++++-- .../serializable/side_loading.rb | 55 ++++++++++++++----- spec/factory/factory_spec.rb | 12 ++++ spec/fixtures/db.rb | 28 ++++++++++ spec/fixtures/serializers.rb | 15 ++++- spec/serializable/paging_spec.rb | 22 ++++++++ spec/serializable/serializer_spec.rb | 17 ++++++ .../side_loading/side_loading_spec.rb | 14 +++++ spec/support/factory.rb | 4 ++ 12 files changed, 214 insertions(+), 33 deletions(-) diff --git a/lib/restpack_serializer/factory.rb b/lib/restpack_serializer/factory.rb index dd8d0cf..fc73d8c 100644 --- a/lib/restpack_serializer/factory.rb +++ b/lib/restpack_serializer/factory.rb @@ -1,18 +1,24 @@ -class RestPack::Serializer::Factory - def self.create(*identifiers) - serializers = identifiers.map { |identifier| self.classify(identifier) } - serializers.count == 1 ? serializers.first : serializers - end +module RestPack::Serializer + + class UnknownSerializer < StandardError; end - private + class Factory - def self.classify(identifier) - normalised_identifier = identifier.to_s.underscore - [normalised_identifier, normalised_identifier.singularize].each do |format| - klass = RestPack::Serializer.class_map[format] - return klass.new if klass + def self.create(*identifiers) + serializers = identifiers.map { |identifier| self.classify(identifier) } + serializers.count == 1 ? serializers.first : serializers end - raise "Invalid RestPack::Serializer : #{identifier}" + private + + def self.classify(identifier) + normalised_identifier = identifier.to_s.underscore + [normalised_identifier, normalised_identifier.singularize].each do |format| + klass = RestPack::Serializer.class_map[format] + return klass.new if klass + end + + raise UnknownSerializer.new("Unknown serializer class: #{identifier}") + end end end diff --git a/lib/restpack_serializer/serializable.rb b/lib/restpack_serializer/serializable.rb index 770b66b..fb66775 100644 --- a/lib/restpack_serializer/serializable.rb +++ b/lib/restpack_serializer/serializable.rb @@ -56,6 +56,10 @@ def as_json(model, context = {}) def custom_attributes {} end + + def url + self.class.url + end private @@ -69,7 +73,22 @@ def add_links(model, data) data[:links] ||= {} links_value = case when association.macro == :belongs_to - model.send(association.foreign_key).try(:to_s) + if association.polymorphic? + linked_id = model.send(association.foreign_key) + .try(:to_s) + linked_type = model.send(association.foreign_type) + .try(:to_s) + .demodulize + .underscore + .pluralize + { + href: "/#{linked_type}/#{linked_id}", + id: linked_id, + type: linked_type + } + else + model.send(association.foreign_key).try(:to_s) + end when association.macro.to_s.match(/has_/) if model.send(association.name).loaded? model.send(association.name).collect { |associated| associated.id.to_s } @@ -126,6 +145,11 @@ def singular_key def plural_key self.key end + + def url(path=nil) + return @url || plural_key unless path + @url = path + end end end end diff --git a/lib/restpack_serializer/serializable/paging.rb b/lib/restpack_serializer/serializable/paging.rb index 9ad2f46..031988e 100644 --- a/lib/restpack_serializer/serializable/paging.rb +++ b/lib/restpack_serializer/serializable/paging.rb @@ -16,7 +16,11 @@ def page_with_options(options) if options.include_links result.links = self.links - Array(RestPack::Serializer::Factory.create(*options.include)).each do |serializer| + linkable_types = result.links.values.map { |link| link[:type].to_s } + includes = options.include.select do |include| + linkable_types.include?(include) + end + Array(RestPack::Serializer::Factory.create(*includes)).each do |serializer| result.links.merge! serializer.class.links end end diff --git a/lib/restpack_serializer/serializable/side_load_data_builder.rb b/lib/restpack_serializer/serializable/side_load_data_builder.rb index 495cfad..b24bc98 100644 --- a/lib/restpack_serializer/serializable/side_load_data_builder.rb +++ b/lib/restpack_serializer/serializable/side_load_data_builder.rb @@ -2,16 +2,17 @@ module RestPack module Serializer class SideLoadDataBuilder - def initialize(association, models, serializer) + def initialize(association, models) @association = association @models = models - @serializer = serializer end def side_load_belongs_to foreign_keys = @models.map { |model| model.send(@association.foreign_key) }.uniq.compact side_load = foreign_keys.any? ? @association.klass.find(foreign_keys) : [] - json_model_data = side_load.map { |model| @serializer.as_json(model) } + json_model_data = side_load.map do |model| + model_serializer(model).as_json(model) + end { @association.plural_name.to_sym => json_model_data, meta: { } } end @@ -43,9 +44,18 @@ def model_ids @models.map(&:id) end + def model_serializer(model) + serializer_type = if @association.polymorphic? + model.class.name + else + @association.class_name + end + RestPack::Serializer::Factory.create(serializer_type) + end + def has_association_relation return {} if @models.empty? - serializer_class = @serializer.class + serializer_class = RestPack::Serializer::Factory.create(@association.class_name).class options = RestPack::Serializer::Options.new(serializer_class) yield options options.include_links = false diff --git a/lib/restpack_serializer/serializable/side_loading.rb b/lib/restpack_serializer/serializable/side_loading.rb index e5a1b12..c6b352b 100644 --- a/lib/restpack_serializer/serializable/side_loading.rb +++ b/lib/restpack_serializer/serializable/side_loading.rb @@ -19,24 +19,25 @@ def can_includes end def can_include(*includes) + @can_include_options = {} + @can_include_options = includes.last if includes.last.is_a?(Hash) @can_includes ||= [] - @can_includes += includes + @can_includes += includes.flat_map do + |include| include.try(:keys)|| include + end end def links {}.tap do |links| - associations.each do |association| - if association.macro == :belongs_to - link_key = "#{self.key}.#{association.name}" - href = "/#{association.plural_name}/{#{link_key}}" + non_polymorphic_associations.each do |association| + link_key = if association.macro == :belongs_to + "#{key}.#{association.name}" elsif association.macro.to_s.match(/has_/) - singular_key = self.key.to_s.singularize - link_key = "#{self.key}.#{association.plural_name}" - href = "/#{association.plural_name}?#{singular_key}_id={#{key}.id}" + "#{key}.#{association.plural_name}" end links.merge!(link_key => { - :href => href_prefix + href, + :href => href_prefix + url_for_association(association), :type => association.plural_name.to_sym } ) @@ -53,13 +54,16 @@ def associations private + def non_polymorphic_associations + associations.select do |association| + !association.polymorphic? + end + end + def side_load(include, models, options) association = association_from_include(include) return {} unless supported_association?(association.macro) - serializer = RestPack::Serializer::Factory.create(association.class_name) - builder = RestPack::Serializer::SideLoadDataBuilder.new(association, - models, - serializer) + builder = RestPack::Serializer::SideLoadDataBuilder.new(association,models) builder.send("side_load_#{association.macro}") end @@ -92,5 +96,30 @@ def raise_invalid_include(include) raise RestPack::Serializer::InvalidInclude.new, ":#{include} is not a valid include for #{self.model_class}" end + + def url_from_association(association) + serializer_from_association_class(association).url + end + + def url_for_association(association) + identifier = if association.macro == :belongs_to + "/{#{key}.#{association.name}}" + else association.macro.to_s.match(/has_/) + param = can_include_options(association)[:param] || "#{singular_key}_id" + value = can_include_options(association)[:value] || "id" + + "?#{param}={#{key}.#{value}}" + end + + "/#{url_from_association(association)}#{identifier}" + end + + def can_include_options(association) + @can_include_options.fetch(association.name.to_sym, {}) + end + + def serializer_from_association_class(association) + RestPack::Serializer::Factory.create(association.class_name) + end end end diff --git a/spec/factory/factory_spec.rb b/spec/factory/factory_spec.rb index c481200..0244e72 100644 --- a/spec/factory/factory_spec.rb +++ b/spec/factory/factory_spec.rb @@ -32,6 +32,7 @@ it "creates multi-word string" do factory.create("AlbumReview").should be_an_instance_of(MyApp::AlbumReviewSerializer) end + it "creates multi-word lowercase string" do factory.create("album_review").should be_an_instance_of(MyApp::AlbumReviewSerializer) end @@ -46,4 +47,15 @@ end end + describe "unknown serializer word" do + + it "raises a custom exception" do + unknown_serializer_class = "UnknownModelType" + message = "Unknown serializer class: #{unknown_serializer_class}" + expect do + factory.create(unknown_serializer_class) + end.to raise_error(RestPack::Serializer::UnknownSerializer, message) + end + end + end diff --git a/spec/fixtures/db.rb b/spec/fixtures/db.rb index be89b82..b73bf09 100644 --- a/spec/fixtures/db.rb +++ b/spec/fixtures/db.rb @@ -19,6 +19,7 @@ t.string "title" t.integer "year" t.integer "artist_id" + t.string "producer" t.datetime "created_at" t.datetime "updated_at" end @@ -62,6 +63,17 @@ t.integer :artist_id t.integer :stalker_id end + + create_table "producers", force: true do |t| + t.string :name + t.string :album + end + + create_table "generic_metadata", force: true do |t| + t.integer :linked_id + t.string :linked_type + t.string :some_stuff_about_the_link + end end module MyApp @@ -72,6 +84,8 @@ class Artist < ActiveRecord::Base has_many :songs has_many :payments has_many :fans, :through => :payments + has_many :generic_metadata, as: :linked + has_and_belongs_to_many :stalkers end @@ -82,6 +96,8 @@ class Album < ActiveRecord::Base belongs_to :artist has_many :songs has_many :album_reviews + has_many :producers, foreign_key: :album + has_many :generic_metadata, as: :linked end class AlbumReview < ActiveRecord::Base @@ -94,6 +110,7 @@ class Song < ActiveRecord::Base attr_accessible :title, :artist, :album + has_many :generic_metadata, as: :linked belongs_to :artist belongs_to :album end @@ -108,6 +125,7 @@ class Payment < ActiveRecord::Base class Fan < ActiveRecord::Base attr_accessible :name has_many :payments + has_many :generic_metadata, as: :linked has_many :artists, :through => :albums end @@ -115,4 +133,14 @@ class Stalker < ActiveRecord::Base attr_accessible :name has_and_belongs_to_many :artists end + + class Producer < ActiveRecord::Base + attr_accessible :name + belongs_to :album, foreign_key: :album + end + + class GenericMetadatum < ActiveRecord::Base + attr_accessible :some_stuff_about_the_link + belongs_to :linked, polymorphic: true + end end diff --git a/spec/fixtures/serializers.rb b/spec/fixtures/serializers.rb index ff7e88a..828f0d5 100644 --- a/spec/fixtures/serializers.rb +++ b/spec/fixtures/serializers.rb @@ -2,7 +2,7 @@ module MyApp class SongSerializer include RestPack::Serializer attributes :id, :title, :album_id - can_include :albums, :artists + can_include :artists, :albums can_filter_by :title can_sort_by :id, :title @@ -14,7 +14,7 @@ def title class AlbumSerializer include RestPack::Serializer attributes :id, :title, :year, :artist_id - can_include :artists, :songs + can_include :artists, :songs, producers: { param: "album", value: "title" } can_filter_by :year end @@ -39,4 +39,15 @@ class StalkerSerializer include RestPack::Serializer attributes :id, :name end + + class ProducerSerializer + include RestPack::Serializer + attributes :id, :name + end + + class GenericMetadatumSerializer + include RestPack::Serializer + attributes :id, :some_stuff_about_the_link + can_include :linked + end end diff --git a/spec/serializable/paging_spec.rb b/spec/serializable/paging_spec.rb index 2eba6a2..108b1ec 100644 --- a/spec/serializable/paging_spec.rb +++ b/spec/serializable/paging_spec.rb @@ -175,6 +175,17 @@ page[:links]['artists.albums'].should_not == nil end end + + context "with an unknown include link" do + let(:params) { { include: "unknown_link" } } + + it "raises an exception" do + message = ":unknown_link is not a valid include for MyApp::Song" + expect do + page[:links] + end.to raise_error(RestPack::Serializer::InvalidInclude, message) + end + end end context "when filtering" do @@ -256,6 +267,17 @@ page[:meta][:songs][:page_count].should == 6 end end + + context "with an unknown include link" do + let(:params) { { include: "unknown_link" } } + + it "raises an exception" do + message = ":unknown_link is not a valid include for MyApp::Song" + expect do + page[:links] + end.to raise_error(RestPack::Serializer::InvalidInclude, message) + end + end end context "paging with paged side-load" do diff --git a/spec/serializable/serializer_spec.rb b/spec/serializable/serializer_spec.rb index aaa3e3a..8f956db 100644 --- a/spec/serializable/serializer_spec.rb +++ b/spec/serializable/serializer_spec.rb @@ -152,6 +152,23 @@ def custom_attributes end end + context "'belongs to polymorphic' associations" do + let(:serializer) { MyApp::GenericMetadatumSerializer.new } + + it "includes a hash describe its polymorphic relationship" do + linked = FactoryGirl.create(:album) + datum = FactoryGirl.create(:generic_metadatum, linked: linked) + json = serializer.as_json(datum) + json[:links].should == { + linked: { + href: "/albums/#{linked.id}", + id: linked.id.to_s, + type: "albums" + } + } + end + end + context "with a serializer with has_* associations" do let(:artist_factory) { FactoryGirl.create :artist_with_fans } let(:artist_serializer) { MyApp::ArtistSerializer.new } diff --git a/spec/serializable/side_loading/side_loading_spec.rb b/spec/serializable/side_loading/side_loading_spec.rb index ec890b5..26e8b4c 100644 --- a/spec/serializable/side_loading/side_loading_spec.rb +++ b/spec/serializable/side_loading/side_loading_spec.rb @@ -58,6 +58,10 @@ class CustomSerializer "albums.songs" => { :href => "/songs?album_id={albums.id}", :type => :songs + }, + "albums.producers" => { + :href => "/producers?album={albums.title}", + :type => :producers } } @@ -74,6 +78,16 @@ class CustomSerializer MyApp::AlbumSerializer.links["albums.artist"][:href].should == "/api/v2/artists/{albums.artist}" MyApp::AlbumSerializer.href_prefix = original end + + it "applies a custom url to links" do + MyApp::ProducerSerializer.url("prods") + MyApp::AlbumSerializer.links["albums.producers"][:href].should == "/prods?album={albums.title}" + MyApp::ProducerSerializer.url("producers") + end + + it "should not include polymorphic belongs to" do + MyApp::GenericMetadatumSerializer.links.should == {} + end end describe "#filterable_by" do diff --git a/spec/support/factory.rb b/spec/support/factory.rb index 40d0f5e..90ac118 100644 --- a/spec/support/factory.rb +++ b/spec/support/factory.rb @@ -69,4 +69,8 @@ factory :stalker, :class => MyApp::Stalker do sequence(:name) {|n| "Stalker ##{n}"} end + + factory :generic_metadatum, :class => MyApp::GenericMetadatum do + some_stuff_about_the_link "Somethinge!" + end end