diff --git a/README.md b/README.md index 53cec7b8..0038b67c 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,23 @@ class MovieSerializer end ``` +### Meta Per Resource + +For every resource in the collection, you can include a meta object containing non-standard meta-information about a resource that can not be represented as an attribute or relationship. + + +```ruby +class MovieSerializer + include FastJsonapi::ObjectSerializer + + meta do |movie| + { + years_since_release: Date.current.year - movie.year + } + end +end +``` + ### Compound Document Support for top-level and nested included associations through ` options[:include] `. @@ -351,15 +368,15 @@ class MovieSerializer include FastJsonapi::ObjectSerializer attributes :name, :year - attribute :release_year, if: Proc.new do |record| + attribute :release_year, if: Proc.new { |record| # Release year will only be serialized if it's greater than 1990 record.release_year > 1990 - end + } - attribute :director, if: Proc.new do |record, params| + attribute :director, if: Proc.new { |record, params| # The director will be serialized only if the :admin key of params is true params && params[:admin] == true - end + } end # ... @@ -409,6 +426,7 @@ serializer.serializable_hash Option | Purpose | Example ------------ | ------------- | ------------- set_type | Type name of Object | ```set_type :movie ``` +key | Key of Object | ```belongs_to :owner, key: :user ``` set_id | ID of Object | ```set_id :owner_id ``` cache_options | Hash to enable caching and set cache length | ```cache_options enabled: true, cache_length: 12.hours, race_condition_ttl: 10.seconds``` id_method_name | Set custom method name to get ID of an object | ```has_many :locations, id_method_name: :place_ids ``` diff --git a/lib/fast_jsonapi/object_serializer.rb b/lib/fast_jsonapi/object_serializer.rb index 7f740c8e..a5436f48 100644 --- a/lib/fast_jsonapi/object_serializer.rb +++ b/lib/fast_jsonapi/object_serializer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'active_support/core_ext/object' +require 'active_support/json' require 'active_support/concern' require 'active_support/inflector' require 'fast_jsonapi/attribute' @@ -65,7 +65,7 @@ def hash_for_collection end def serialized_json - self.class.to_json(serializable_hash) + ActiveSupport::JSON.encode(serializable_hash) end private @@ -120,6 +120,7 @@ def inherited(subclass) subclass.data_links = data_links subclass.cached = cached subclass.set_type(subclass.reflected_record_type) if subclass.reflected_record_type + subclass.meta_to_serialize = meta_to_serialize end def reflected_record_type @@ -194,7 +195,7 @@ def add_relationship(relationship) self.relationships_to_serialize = {} if relationships_to_serialize.nil? self.cachable_relationships_to_serialize = {} if cachable_relationships_to_serialize.nil? self.uncachable_relationships_to_serialize = {} if uncachable_relationships_to_serialize.nil? - + if !relationship.cached self.uncachable_relationships_to_serialize[relationship.name] = relationship else @@ -218,6 +219,10 @@ def belongs_to(relationship_name, options = {}, &block) add_relationship(relationship) end + def meta(&block) + self.meta_to_serialize = block + end + def create_relationship(base_key, relationship_type, options, block) name = base_key.to_sym if relationship_type == :has_many @@ -232,7 +237,11 @@ def create_relationship(base_key, relationship_type, options, block) Relationship.new( key: options[:key] || run_key_transform(base_key), name: name, - id_method_name: options[:id_method_name] || "#{base_serialization_key}#{id_postfix}".to_sym, + id_method_name: compute_id_method_name( + options[:id_method_name], + "#{base_serialization_key}#{id_postfix}".to_sym, + block + ), record_type: options[:record_type] || run_key_transform(base_key_sym), object_method_name: options[:object_method_name] || name, object_block: block, @@ -240,10 +249,19 @@ def create_relationship(base_key, relationship_type, options, block) relationship_type: relationship_type, cached: options[:cached], polymorphic: fetch_polymorphic_option(options), - conditional_proc: options[:if] + conditional_proc: options[:if], + transform_method: @transform_method ) end + def compute_id_method_name(custom_id_method_name, id_method_name_from_relationship, block) + if block.present? + custom_id_method_name || :id + else + custom_id_method_name || id_method_name_from_relationship + end + end + def compute_serializer_name(serializer_key) return serializer_key unless serializer_key.is_a? Symbol namespace = self.name.gsub(/()?\w+Serializer$/, '') @@ -275,10 +293,10 @@ def validate_includes!(includes) includes.detect do |include_item| klass = self parse_include_item(include_item).each do |parsed_include| - relationship_to_include = klass.relationships_to_serialize[parsed_include] + relationships_to_serialize = klass.relationships_to_serialize || {} + relationship_to_include = relationships_to_serialize[parsed_include] raise ArgumentError, "#{parsed_include} is not specified as a relationship on #{klass.name}" unless relationship_to_include - raise NotImplementedError if relationship_to_include.polymorphic.is_a?(Hash) - klass = relationship_to_include.serializer.to_s.constantize + klass = relationship_to_include.serializer.to_s.constantize unless relationship_to_include.polymorphic.is_a?(Hash) end end end diff --git a/lib/fast_jsonapi/relationship.rb b/lib/fast_jsonapi/relationship.rb index 0b3a1019..e06b07f3 100644 --- a/lib/fast_jsonapi/relationship.rb +++ b/lib/fast_jsonapi/relationship.rb @@ -1,6 +1,6 @@ module FastJsonapi class Relationship - attr_reader :key, :name, :id_method_name, :record_type, :object_method_name, :object_block, :serializer, :relationship_type, :cached, :polymorphic, :conditional_proc + attr_reader :key, :name, :id_method_name, :record_type, :object_method_name, :object_block, :serializer, :relationship_type, :cached, :polymorphic, :conditional_proc, :transform_method def initialize( key:, @@ -13,7 +13,8 @@ def initialize( relationship_type:, cached: false, polymorphic:, - conditional_proc: + conditional_proc:, + transform_method: ) @key = key @name = name @@ -26,6 +27,7 @@ def initialize( @cached = cached @polymorphic = polymorphic @conditional_proc = conditional_proc + @transform_method = transform_method end def serialize(record, serialization_params, output_hash) @@ -68,7 +70,7 @@ def ids_hash_from_record_and_relationship(record, params = {}) def id_hash_from_record(record, record_types) # memoize the record type within the record_types dictionary, then assigning to record_type: - associated_record_type = record_types[record.class] ||= record.class.name.underscore.to_sym + associated_record_type = record_types[record.class] ||= run_key_transform(record.class.name.demodulize.underscore) id_hash(record.id, associated_record_type) end @@ -86,14 +88,20 @@ def id_hash(id, record_type, default_return=false) end def fetch_id(record, params) - unless object_block.nil? + if object_block.present? object = object_block.call(record, params) - - return object.map(&:id) if object.respond_to? :map - return object.try(:id) + return object.map { |item| item.public_send(id_method_name) } if object.respond_to? :map + return object.try(id_method_name) end - record.public_send(id_method_name) end + + def run_key_transform(input) + if self.transform_method.present? + input.to_s.send(*self.transform_method).to_sym + else + input.to_sym + end + end end -end \ No newline at end of file +end diff --git a/lib/fast_jsonapi/serialization_core.rb b/lib/fast_jsonapi/serialization_core.rb index 6ec069a9..a456b21e 100644 --- a/lib/fast_jsonapi/serialization_core.rb +++ b/lib/fast_jsonapi/serialization_core.rb @@ -21,7 +21,8 @@ class << self :cache_length, :race_condition_ttl, :cached, - :data_links + :data_links, + :meta_to_serialize end end @@ -57,6 +58,10 @@ def relationships_hash(record, relationships = nil, fieldset = nil, params = {}) end end + def meta_hash(record, params = {}) + meta_to_serialize.call(record, params) + end + def record_hash(record, fieldset, params = {}) if cached record_hash = Rails.cache.fetch(record.cache_key, expires_in: cache_length, race_condition_ttl: race_condition_ttl) do @@ -67,13 +72,15 @@ def record_hash(record, fieldset, params = {}) temp_hash[:links] = links_hash(record, params) if data_links.present? temp_hash end - record_hash[:relationships] = record_hash[:relationships].merge(relationships_hash(record, uncachable_relationships_to_serialize, params)) if uncachable_relationships_to_serialize.present? + record_hash[:relationships] = record_hash[:relationships].merge(relationships_hash(record, uncachable_relationships_to_serialize, fieldset, params)) if uncachable_relationships_to_serialize.present? + record_hash[:meta] = meta_hash(record, params) if meta_to_serialize.present? record_hash else record_hash = id_hash(id_from_record(record), record_type, true) record_hash[:attributes] = attributes_hash(record, fieldset, params) if attributes_to_serialize.present? record_hash[:relationships] = relationships_hash(record, nil, fieldset, params) if relationships_to_serialize.present? record_hash[:links] = links_hash(record, params) if data_links.present? + record_hash[:meta] = meta_hash(record, params) if meta_to_serialize.present? record_hash end end @@ -112,9 +119,10 @@ def get_included_records(record, includes_list, known_included_objects, fieldset next unless relationships_to_serialize && relationships_to_serialize[item] relationship_item = relationships_to_serialize[item] next unless relationship_item.include_relationship?(record, params) - raise NotImplementedError if relationship_item.polymorphic.is_a?(Hash) - record_type = relationship_item.record_type - serializer = relationship_item.serializer.to_s.constantize + unless relationship_item.polymorphic.is_a?(Hash) + record_type = relationship_item.record_type + serializer = relationship_item.serializer.to_s.constantize + end relationship_type = relationship_item.relationship_type included_objects = relationship_item.fetch_associated_object(record, params) @@ -122,12 +130,17 @@ def get_included_records(record, includes_list, known_included_objects, fieldset included_objects = [included_objects] unless relationship_type == :has_many included_objects.each do |inc_obj| + if relationship_item.polymorphic.is_a?(Hash) + record_type = inc_obj.class.name.demodulize.underscore + serializer = self.compute_serializer_name(inc_obj.class.name.demodulize.to_sym).to_s.constantize + end + if remaining_items(items) - serializer_records = serializer.get_included_records(inc_obj, remaining_items(items), known_included_objects, fieldsets) + serializer_records = serializer.get_included_records(inc_obj, remaining_items(items), known_included_objects, fieldsets, params) included_records.concat(serializer_records) unless serializer_records.empty? end - code = "#{record_type}_#{inc_obj.id}" + code = "#{record_type}_#{serializer.id_from_record(inc_obj)}" next if known_included_objects.key?(code) known_included_objects[code] = inc_obj diff --git a/lib/fast_jsonapi/version.rb b/lib/fast_jsonapi/version.rb index f17716eb..c9c4c7ab 100644 --- a/lib/fast_jsonapi/version.rb +++ b/lib/fast_jsonapi/version.rb @@ -1,3 +1,3 @@ module FastJsonapi - VERSION = "1.3" + VERSION = "1.4" end diff --git a/spec/lib/object_serializer_class_methods_spec.rb b/spec/lib/object_serializer_class_methods_spec.rb index 3c477c34..397ace5a 100644 --- a/spec/lib/object_serializer_class_methods_spec.rb +++ b/spec/lib/object_serializer_class_methods_spec.rb @@ -87,6 +87,31 @@ end end + describe '#has_many with block and id_method_name' do + before do + MovieSerializer.has_many(:awards, id_method_name: :imdb_award_id) do |movie| + movie.actors.map(&:awards).flatten + end + end + + after do + MovieSerializer.relationships_to_serialize.delete(:awards) + end + + context 'awards is not included' do + subject(:hash) { MovieSerializer.new(movie).serializable_hash } + + it 'returns correct hash where id is obtained from the method specified via `id_method_name`' do + expected_award_data = movie.actors.map(&:awards).flatten.map do |actor| + { id: actor.imdb_award_id.to_s, type: actor.class.name.downcase.to_sym } + end + serialized_award_data = hash[:data][:relationships][:awards][:data] + + expect(serialized_award_data).to eq(expected_award_data) + end + end + end + describe '#belongs_to' do subject(:relationship) { MovieSerializer.relationships_to_serialize[:area] } @@ -249,6 +274,34 @@ end end + describe '#meta' do + subject(:serializable_hash) { MovieSerializer.new(movie).serializable_hash } + + before do + movie.release_year = 2008 + MovieSerializer.meta do |movie| + { + years_since_release: year_since_release_calculator(movie.release_year) + } + end + end + + after do + movie.release_year = nil + MovieSerializer.meta_to_serialize = nil + end + + it 'returns correct hash when serializable_hash is called' do + expect(serializable_hash[:data][:meta]).to eq ({ years_since_release: year_since_release_calculator(movie.release_year) }) + end + + private + + def year_since_release_calculator(release_year) + Date.current.year - release_year + end + end + describe '#link' do subject(:serializable_hash) { MovieSerializer.new(movie).serializable_hash } diff --git a/spec/lib/object_serializer_polymorphic_spec.rb b/spec/lib/object_serializer_polymorphic_spec.rb new file mode 100644 index 00000000..f889141f --- /dev/null +++ b/spec/lib/object_serializer_polymorphic_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe FastJsonapi::ObjectSerializer do + class List + attr_accessor :id, :name, :items + end + + class ChecklistItem + attr_accessor :id, :name + end + + class Car + attr_accessor :id, :model, :year + end + + class ListSerializer + include FastJsonapi::ObjectSerializer + set_type :list + attributes :name + set_key_transform :dash + has_many :items, polymorphic: true + end + + let(:car) do + car = Car.new + car.id = 1 + car.model = 'Toyota Corolla' + car.year = 1987 + car + end + + let(:checklist_item) do + checklist_item = ChecklistItem.new + checklist_item.id = 2 + checklist_item.name = 'Do this action!' + checklist_item + end + + context 'when serializing id and type of polymorphic relationships' do + it 'should return correct type when transform_method is specified' do + list = List.new + list.id = 1 + list.items = [checklist_item, car] + list_hash = ListSerializer.new(list).to_hash + record_type = list_hash[:data][:relationships][:items][:data][0][:type] + expect(record_type).to eq 'checklist-item'.to_sym + record_type = list_hash[:data][:relationships][:items][:data][1][:type] + expect(record_type).to eq 'car'.to_sym + end + end +end diff --git a/spec/lib/object_serializer_spec.rb b/spec/lib/object_serializer_spec.rb index 07cbbef4..85f4a79d 100644 --- a/spec/lib/object_serializer_spec.rb +++ b/spec/lib/object_serializer_spec.rb @@ -158,6 +158,32 @@ end end + context 'id attribute is the same for actors and not a primary key' do + before do + ActorSerializer.set_id :email + movie.actor_ids = [0, 0, 0] + class << movie + def actors + super.each_with_index { |actor, i| actor.email = "actor#{i}@email.com" } + end + end + end + + after { ActorSerializer.set_id nil } + + let(:options) { { include: ['actors'] } } + subject { MovieSerializer.new(movie, options).serializable_hash } + + it 'returns all actors in includes' do + + expect( + subject[:included].select { |i| i[:type] == :actor }.map { |i| i[:id] } + ).to eq( + movie.actors.map(&:email) + ) + end + end + context 'nested includes' do it 'has_many to belongs_to: returns correct nested includes when serializable_hash is called' do # 3 actors, 3 agencies @@ -252,10 +278,24 @@ def advertising_campaign end end - it 'polymorphic throws an error that polymorphic is not supported' do + it 'polymorphic has_many: returns correct nested includes when serializable_hash is called' do options = {} options[:include] = [:groupees] - expect(-> { GroupSerializer.new([group], options)}).to raise_error(NotImplementedError) + + serializable_hash = GroupSerializer.new([group], options).serializable_hash + + persons_serialized = serializable_hash[:included].find_all { |included| included[:type] == :person }.map { |included| included[:id].to_i } + groups_serialized = serializable_hash[:included].find_all { |included| included[:type] == :group }.map { |included| included[:id].to_i } + + persons = group.groupees.find_all { |groupee| groupee.is_a?(Person) } + persons.each do |person| + expect(persons_serialized).to include(person.id) + end + + groups = group.groupees.find_all { |groupee| groupee.is_a?(Group) } + groups.each do |group| + expect(groups_serialized).to include(group.id) + end end end @@ -310,6 +350,23 @@ class BlahSerializer end end + context 'when serializing included, params should be available in any serializer' do + subject(:serializable_hash) do + options = {} + options[:include] = [:"actors.awards"] + options[:params] = { include_award_year: true } + MovieSerializer.new(movie, options).serializable_hash + end + let(:actor) { movie.actors.first } + let(:award) { actor.awards.first } + let(:year) { award.year } + + it 'passes params to deeply nested includes' do + expect(year).to_not be_blank + expect(serializable_hash[:included][0][:attributes][:year]).to eq year + end + end + context 'when is_collection option present' do subject { MovieSerializer.new(resource, is_collection_options).serializable_hash } diff --git a/spec/shared/contexts/movie_context.rb b/spec/shared/contexts/movie_context.rb index 90612260..f2c7697a 100644 --- a/spec/shared/contexts/movie_context.rb +++ b/spec/shared/contexts/movie_context.rb @@ -80,6 +80,8 @@ def awards a.id = i a.title = "Test Award #{i}" a.actor_id = id + a.imdb_award_id = i * 10 + a.year = 1990 + i end end end @@ -110,7 +112,7 @@ def state end class Award - attr_accessor :id, :title, :actor_id + attr_accessor :id, :title, :actor_id, :year, :imdb_award_id end class State @@ -225,6 +227,11 @@ class AgencySerializer class AwardSerializer include FastJsonapi::ObjectSerializer attributes :id, :title + attribute :year, if: Proc.new { |record, params| + params[:include_award_year].present? ? + params[:include_award_year] : + false + } belongs_to :actor end