diff --git a/README.md b/README.md index 2cbaa51b..a03e1a8c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Fast JSON API serialized 250 records in 3.01 ms * [Collection Serialization](#collection-serialization) * [Caching](#caching) * [Params](#params) + * [Conditional Attributes](#conditional-attributes) + * [Conditional Relationships](#conditional-relationships) + * [Sparse Fieldsets](#sparse-fieldsets) * [Contributing](#contributing) @@ -205,6 +208,18 @@ class MovieSerializer end ``` +Attributes can also use a different name by passing the original method or accessor with a proc shortcut: + +```ruby +class MovieSerializer + include FastJsonapi::ObjectSerializer + + attributes :name + + attribute :released_in_year, &:year +end +``` + ### Links Per Object Links are defined in FastJsonapi using the `link` method. By default, link are read directly from the model property of the same name.In this example, `public_url` is expected to be a property of the object being serialized. @@ -259,6 +274,26 @@ hash = MovieSerializer.new([movie, movie], options).serializable_hash json_string = MovieSerializer.new([movie, movie], options).serialized_json ``` +#### Control Over Collection Serialization + +You can use `is_collection` option to have better control over collection serialization. + +If this option is not provided or `nil` autedetect logic is used to try understand +if provided resource is a single object or collection. + +Autodetect logic is compatible with most DB toolkits (ActiveRecord, Sequel, etc.) but +**cannot** guarantee that single vs collection will be always detected properly. + +```ruby +options[:is_collection] +``` + +was introduced to be able to have precise control this behavior + +- `nil` or not provided: will try to autodetect single vs collection (please, see notes above) +- `true` will always treat input resource as *collection* +- `false` will always treat input resource as *single object* + ### Caching Requires a `cache_key` method be defined on model: @@ -284,7 +319,6 @@ block you opt-in to using params by adding it as a block parameter. ```ruby class MovieSerializer - class MovieSerializer include FastJsonapi::ObjectSerializer attributes :name, :year @@ -308,6 +342,68 @@ serializer.serializable_hash Custom attributes and relationships that only receive the resource are still possible by defining the block to only receive one argument. +### Conditional Attributes + +Conditional attributes can be defined by passing a Proc to the `if` key on the `attribute` method. Return `true` if the attribute should be serialized, and `false` if not. The record and any params passed to the serializer are available inside the Proc as the first and second parameters, respectively. + +```ruby +class MovieSerializer + include FastJsonapi::ObjectSerializer + + attributes :name, :year + attribute :release_year, if: Proc.new do |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| + # The director will be serialized only if the :admin key of params is true + params && params[:admin] == true + end +end + +# ... +current_user = User.find(cookies[:current_user_id]) +serializer = MovieSerializer.new(movie, { params: { admin: current_user.admin? }}) +serializer.serializable_hash +``` + +### Conditional Relationships + +Conditional relationships can be defined by passing a Proc to the `if` key. Return `true` if the relationship should be serialized, and `false` if not. The record and any params passed to the serializer are available inside the Proc as the first and second parameters, respectively. + +```ruby +class MovieSerializer + include FastJsonapi::ObjectSerializer + + # Actors will only be serialized if the record has any associated actors + has_many :actors, if: Proc.new { |record| record.actors.any? } + + # Owner will only be serialized if the :admin key of params is true + belongs_to :owner, if: Proc.new { |record, params| params && params[:admin] == true } +end + +# ... +current_user = User.find(cookies[:current_user_id]) +serializer = MovieSerializer.new(movie, { params: { admin: current_user.admin? }}) +serializer.serializable_hash +``` + +### Sparse Fieldsets + +Attributes and relationships can be selectively returned per record type by using the `fields` option. + +```ruby +class MovieSerializer + include FastJsonapi::ObjectSerializer + + attributes :name, :year +end + +serializer = MovieSerializer.new(movie, { fields: { movie: [:name] } }) +serializer.serializable_hash +``` + ### Customizable Options Option | Purpose | Example diff --git a/lib/extensions/has_one.rb b/lib/extensions/has_one.rb index 930ca574..1588359b 100644 --- a/lib/extensions/has_one.rb +++ b/lib/extensions/has_one.rb @@ -1,20 +1,18 @@ # frozen_string_literal: true -if defined?(::ActiveRecord) - ::ActiveRecord::Associations::Builder::HasOne.class_eval do - # Based on - # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/collection_association.rb#L50 - # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/singular_association.rb#L11 - def self.define_accessors(mixin, reflection) - super - name = reflection.name - mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 - def #{name}_id - # if an attribute is already defined with this methods name we should just use it - return read_attribute(__method__) if has_attribute?(__method__) - association(:#{name}).reader.try(:id) - end - CODE - end +::ActiveRecord::Associations::Builder::HasOne.class_eval do + # Based on + # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/collection_association.rb#L50 + # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/singular_association.rb#L11 + def self.define_accessors(mixin, reflection) + super + name = reflection.name + mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name}_id + # if an attribute is already defined with this methods name we should just use it + return read_attribute(__method__) if has_attribute?(__method__) + association(:#{name}).reader.try(:id) + end + CODE end end diff --git a/lib/fast_jsonapi.rb b/lib/fast_jsonapi.rb index d257b6eb..cb4915bb 100644 --- a/lib/fast_jsonapi.rb +++ b/lib/fast_jsonapi.rb @@ -2,5 +2,9 @@ module FastJsonapi require 'fast_jsonapi/object_serializer' - require 'extensions/has_one' + if defined?(::Rails) + require 'fast_jsonapi/railtie' + elsif defined?(::ActiveRecord) + require 'extensions/has_one' + end end diff --git a/lib/fast_jsonapi/attribute.rb b/lib/fast_jsonapi/attribute.rb new file mode 100644 index 00000000..9acc3e09 --- /dev/null +++ b/lib/fast_jsonapi/attribute.rb @@ -0,0 +1,29 @@ +module FastJsonapi + class Attribute + attr_reader :key, :method, :conditional_proc + + def initialize(key:, method:, options: {}) + @key = key + @method = method + @conditional_proc = options[:if] + end + + def serialize(record, serialization_params, output_hash) + if include_attribute?(record, serialization_params) + output_hash[key] = if method.is_a?(Proc) + method.arity.abs == 1 ? method.call(record) : method.call(record, serialization_params) + else + record.public_send(method) + end + end + end + + def include_attribute?(record, serialization_params) + if conditional_proc.present? + conditional_proc.call(record, serialization_params) + else + true + end + end + end +end diff --git a/lib/fast_jsonapi/link.rb b/lib/fast_jsonapi/link.rb new file mode 100644 index 00000000..41f84c2f --- /dev/null +++ b/lib/fast_jsonapi/link.rb @@ -0,0 +1,18 @@ +module FastJsonapi + class Link + attr_reader :key, :method + + def initialize(key:, method:) + @key = key + @method = method + end + + def serialize(record, serialization_params, output_hash) + output_hash[key] = if method.is_a?(Proc) + method.arity == 1 ? method.call(record) : method.call(record, serialization_params) + else + record.public_send(method) + end + end + end +end diff --git a/lib/fast_jsonapi/object_serializer.rb b/lib/fast_jsonapi/object_serializer.rb index 4dea074f..7f740c8e 100644 --- a/lib/fast_jsonapi/object_serializer.rb +++ b/lib/fast_jsonapi/object_serializer.rb @@ -3,6 +3,9 @@ require 'active_support/core_ext/object' require 'active_support/concern' require 'active_support/inflector' +require 'fast_jsonapi/attribute' +require 'fast_jsonapi/relationship' +require 'fast_jsonapi/link' require 'fast_jsonapi/serialization_core' module FastJsonapi @@ -25,7 +28,7 @@ def initialize(resource, options = {}) end def serializable_hash - return hash_for_collection if is_collection?(@resource) + return hash_for_collection if is_collection?(@resource, @is_collection) hash_for_one_record end @@ -38,8 +41,8 @@ def hash_for_one_record return serializable_hash unless @resource - serializable_hash[:data] = self.class.record_hash(@resource, @params) - serializable_hash[:included] = self.class.get_included_records(@resource, @includes, @known_included_objects, @params) if @includes.present? + serializable_hash[:data] = self.class.record_hash(@resource, @fieldsets[self.class.record_type.to_sym], @params) + serializable_hash[:included] = self.class.get_included_records(@resource, @includes, @known_included_objects, @fieldsets, @params) if @includes.present? serializable_hash end @@ -48,9 +51,10 @@ def hash_for_collection data = [] included = [] + fieldset = @fieldsets[self.class.record_type.to_sym] @resource.each do |record| - data << self.class.record_hash(record, @params) - included.concat self.class.get_included_records(record, @includes, @known_included_objects, @params) if @includes.present? + data << self.class.record_hash(record, fieldset, @params) + included.concat self.class.get_included_records(record, @includes, @known_included_objects, @fieldsets, @params) if @includes.present? end serializable_hash[:data] = data @@ -67,11 +71,14 @@ def serialized_json private def process_options(options) + @fieldsets = deep_symbolize(options[:fields].presence || {}) + return if options.blank? @known_included_objects = {} @meta = options[:meta] @links = options[:links] + @is_collection = options[:is_collection] @params = options[:params] || {} raise ArgumentError.new("`params` option passed to serializer must be a hash") unless @params.is_a?(Hash) @@ -81,8 +88,22 @@ def process_options(options) end end - def is_collection?(resource) - resource.respond_to?(:each) && !resource.respond_to?(:each_pair) + def deep_symbolize(collection) + if collection.is_a? Hash + Hash[collection.map do |k, v| + [k.to_sym, deep_symbolize(v)] + end] + elsif collection.is_a? Array + collection.map { |i| deep_symbolize(i) } + else + collection.to_sym + end + end + + def is_collection?(resource, force_is_collection = nil) + return force_is_collection unless force_is_collection.nil? + + resource.respond_to?(:size) && !resource.respond_to?(:each_pair) end class_methods do @@ -98,6 +119,7 @@ def inherited(subclass) subclass.race_condition_ttl = race_condition_ttl subclass.data_links = data_links subclass.cached = cached + subclass.set_type(subclass.reflected_record_type) if subclass.reflected_record_type end def reflected_record_type @@ -118,6 +140,9 @@ def set_key_transform(transform_name) underscore: :underscore } self.transform_method = mapping[transform_name.to_sym] + + # ensure that the record type is correctly transformed + set_type(reflected_record_type) if reflected_record_type end def run_key_transform(input) @@ -149,48 +174,51 @@ def cache_options(cache_options) def attributes(*attributes_list, &block) attributes_list = attributes_list.first if attributes_list.first.class.is_a?(Array) + options = attributes_list.last.is_a?(Hash) ? attributes_list.pop : {} self.attributes_to_serialize = {} if self.attributes_to_serialize.nil? + attributes_list.each do |attr_name| method_name = attr_name key = run_key_transform(method_name) - attributes_to_serialize[key] = block || method_name + attributes_to_serialize[key] = Attribute.new( + key: key, + method: block || method_name, + options: options + ) end end alias_method :attribute, :attributes - def add_relationship(name, relationship) + 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[name] = relationship + + if !relationship.cached + self.uncachable_relationships_to_serialize[relationship.name] = relationship else - self.cachable_relationships_to_serialize[name] = relationship + self.cachable_relationships_to_serialize[relationship.name] = relationship end - self.relationships_to_serialize[name] = relationship - end + self.relationships_to_serialize[relationship.name] = relationship + end def has_many(relationship_name, options = {}, &block) - name = relationship_name.to_sym - hash = create_relationship_hash(relationship_name, :has_many, options, block) - add_relationship(name, hash) + relationship = create_relationship(relationship_name, :has_many, options, block) + add_relationship(relationship) end def has_one(relationship_name, options = {}, &block) - name = relationship_name.to_sym - hash = create_relationship_hash(relationship_name, :has_one, options, block) - add_relationship(name, hash) + relationship = create_relationship(relationship_name, :has_one, options, block) + add_relationship(relationship) end def belongs_to(relationship_name, options = {}, &block) - name = relationship_name.to_sym - hash = create_relationship_hash(relationship_name, :belongs_to, options, block) - add_relationship(name, hash) + relationship = create_relationship(relationship_name, :belongs_to, options, block) + add_relationship(relationship) end - def create_relationship_hash(base_key, relationship_type, options, block) + def create_relationship(base_key, relationship_type, options, block) name = base_key.to_sym if relationship_type == :has_many base_serialization_key = base_key.to_s.singularize @@ -201,7 +229,7 @@ def create_relationship_hash(base_key, relationship_type, options, block) base_key_sym = name id_postfix = '_id' end - { + 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, @@ -210,9 +238,10 @@ def create_relationship_hash(base_key, relationship_type, options, block) object_block: block, serializer: compute_serializer_name(options[:serializer] || base_key_sym), relationship_type: relationship_type, - cached: options[:cached] || false, - polymorphic: fetch_polymorphic_option(options) - } + cached: options[:cached], + polymorphic: fetch_polymorphic_option(options), + conditional_proc: options[:if] + ) end def compute_serializer_name(serializer_key) @@ -233,7 +262,11 @@ def link(link_name, link_method_name = nil, &block) self.data_links = {} if self.data_links.nil? link_method_name = link_name if link_method_name.nil? key = run_key_transform(link_name) - self.data_links[key] = block || link_method_name + + self.data_links[key] = Link.new( + key: key, + method: block || link_method_name + ) end def validate_includes!(includes) @@ -244,8 +277,8 @@ def validate_includes!(includes) parse_include_item(include_item).each do |parsed_include| relationship_to_include = klass.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 + raise NotImplementedError if relationship_to_include.polymorphic.is_a?(Hash) + klass = relationship_to_include.serializer.to_s.constantize end end end diff --git a/lib/fast_jsonapi/railtie.rb b/lib/fast_jsonapi/railtie.rb new file mode 100644 index 00000000..e6a27176 --- /dev/null +++ b/lib/fast_jsonapi/railtie.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails/railtie' + +class Railtie < Rails::Railtie + initializer 'fast_jsonapi.active_record' do + ActiveSupport.on_load :active_record do + require 'extensions/has_one' + end + end +end diff --git a/lib/fast_jsonapi/relationship.rb b/lib/fast_jsonapi/relationship.rb new file mode 100644 index 00000000..0b3a1019 --- /dev/null +++ b/lib/fast_jsonapi/relationship.rb @@ -0,0 +1,99 @@ +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 + + def initialize( + key:, + name:, + id_method_name:, + record_type:, + object_method_name:, + object_block:, + serializer:, + relationship_type:, + cached: false, + polymorphic:, + conditional_proc: + ) + @key = key + @name = name + @id_method_name = id_method_name + @record_type = record_type + @object_method_name = object_method_name + @object_block = object_block + @serializer = serializer + @relationship_type = relationship_type + @cached = cached + @polymorphic = polymorphic + @conditional_proc = conditional_proc + end + + def serialize(record, serialization_params, output_hash) + if include_relationship?(record, serialization_params) + empty_case = relationship_type == :has_many ? [] : nil + output_hash[key] = { + data: ids_hash_from_record_and_relationship(record, serialization_params) || empty_case + } + end + end + + def fetch_associated_object(record, params) + return object_block.call(record, params) unless object_block.nil? + record.send(object_method_name) + end + + def include_relationship?(record, serialization_params) + if conditional_proc.present? + conditional_proc.call(record, serialization_params) + else + true + end + end + + private + + def ids_hash_from_record_and_relationship(record, params = {}) + return ids_hash( + fetch_id(record, params) + ) unless polymorphic + + return unless associated_object = fetch_associated_object(record, params) + + return associated_object.map do |object| + id_hash_from_record object, polymorphic + end if associated_object.respond_to? :map + + id_hash_from_record associated_object, polymorphic + end + + 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 + id_hash(record.id, associated_record_type) + end + + def ids_hash(ids) + return ids.map { |id| id_hash(id, record_type) } if ids.respond_to? :map + id_hash(ids, record_type) # ids variable is just a single id here + end + + def id_hash(id, record_type, default_return=false) + if id.present? + { id: id.to_s, type: record_type } + else + default_return ? { id: nil, type: record_type } : nil + end + end + + def fetch_id(record, params) + unless object_block.nil? + object = object_block.call(record, params) + + return object.map(&:id) if object.respond_to? :map + return object.try(:id) + end + + record.public_send(id_method_name) + end + end +end \ No newline at end of file diff --git a/lib/fast_jsonapi/serialization_core.rb b/lib/fast_jsonapi/serialization_core.rb index de138cd7..6ec069a9 100644 --- a/lib/fast_jsonapi/serialization_core.rb +++ b/lib/fast_jsonapi/serialization_core.rb @@ -34,73 +34,36 @@ def id_hash(id, record_type, default_return=false) end end - def ids_hash(ids, record_type) - return ids.map { |id| id_hash(id, record_type) } if ids.respond_to? :map - id_hash(ids, record_type) # ids variable is just a single id here - end - - def id_hash_from_record(record, record_types) - # memoize the record type within the record_types dictionary, then assigning to record_type: - record_type = record_types[record.class] ||= record.class.name.underscore.to_sym - id_hash(record.id, record_type) - end - - def ids_hash_from_record_and_relationship(record, relationship, params = {}) - polymorphic = relationship[:polymorphic] - - return ids_hash( - fetch_id(record, relationship, params), - relationship[:record_type] - ) unless polymorphic - - return unless associated_object = fetch_associated_object(record, relationship, params) - - return associated_object.map do |object| - id_hash_from_record object, polymorphic - end if associated_object.respond_to? :map - - id_hash_from_record associated_object, polymorphic - end - def links_hash(record, params = {}) - data_links.each_with_object({}) do |(key, method), link_hash| - link_hash[key] = if method.is_a?(Proc) - method.arity == 1 ? method.call(record) : method.call(record, params) - else - record.public_send(method) - end + data_links.each_with_object({}) do |(_k, link), hash| + link.serialize(record, params, hash) end end - def attributes_hash(record, params = {}) - attributes_to_serialize.each_with_object({}) do |(key, method), attr_hash| - attr_hash[key] = if method.is_a?(Proc) - method.arity == 1 ? method.call(record) : method.call(record, params) - else - record.public_send(method) - end + def attributes_hash(record, fieldset = nil, params = {}) + attributes = attributes_to_serialize + attributes = attributes.slice(*fieldset) if fieldset.present? + attributes.each_with_object({}) do |(_k, attribute), hash| + attribute.serialize(record, params, hash) end end - def relationships_hash(record, relationships = nil, params = {}) + def relationships_hash(record, relationships = nil, fieldset = nil, params = {}) relationships = relationships_to_serialize if relationships.nil? + relationships = relationships.slice(*fieldset) if fieldset.present? relationships.each_with_object({}) do |(_k, relationship), hash| - name = relationship[:key] - empty_case = relationship[:relationship_type] == :has_many ? [] : nil - hash[name] = { - data: ids_hash_from_record_and_relationship(record, relationship, params) || empty_case - } + relationship.serialize(record, params, hash) end end - def record_hash(record, params = {}) + 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 temp_hash = id_hash(id_from_record(record), record_type, true) - temp_hash[:attributes] = attributes_hash(record, params) if attributes_to_serialize.present? + temp_hash[:attributes] = attributes_hash(record, fieldset, params) if attributes_to_serialize.present? temp_hash[:relationships] = {} - temp_hash[:relationships] = relationships_hash(record, cachable_relationships_to_serialize, params) if cachable_relationships_to_serialize.present? + temp_hash[:relationships] = relationships_hash(record, cachable_relationships_to_serialize, fieldset, params) if cachable_relationships_to_serialize.present? temp_hash[:links] = links_hash(record, params) if data_links.present? temp_hash end @@ -108,8 +71,8 @@ def record_hash(record, params = {}) record_hash else record_hash = id_hash(id_from_record(record), record_type, true) - record_hash[:attributes] = attributes_hash(record, params) if attributes_to_serialize.present? - record_hash[:relationships] = relationships_hash(record, nil, params) if relationships_to_serialize.present? + 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 end @@ -140,25 +103,27 @@ def remaining_items(items) end # includes handler - def get_included_records(record, includes_list, known_included_objects, params = {}) + def get_included_records(record, includes_list, known_included_objects, fieldsets, params = {}) return unless includes_list.present? includes_list.sort.each_with_object([]) do |include_item, included_records| items = parse_include_item(include_item) items.each do |item| next unless relationships_to_serialize && relationships_to_serialize[item] - raise NotImplementedError if @relationships_to_serialize[item][:polymorphic].is_a?(Hash) - record_type = @relationships_to_serialize[item][:record_type] - serializer = @relationships_to_serialize[item][:serializer].to_s.constantize - relationship_type = @relationships_to_serialize[item][:relationship_type] - - included_objects = fetch_associated_object(record, @relationships_to_serialize[item], params) + 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 + relationship_type = relationship_item.relationship_type + + included_objects = relationship_item.fetch_associated_object(record, params) next if included_objects.blank? included_objects = [included_objects] unless relationship_type == :has_many included_objects.each do |inc_obj| if remaining_items(items) - serializer_records = serializer.get_included_records(inc_obj, remaining_items(items), known_included_objects) + serializer_records = serializer.get_included_records(inc_obj, remaining_items(items), known_included_objects, fieldsets) included_records.concat(serializer_records) unless serializer_records.empty? end @@ -166,27 +131,12 @@ def get_included_records(record, includes_list, known_included_objects, params = next if known_included_objects.key?(code) known_included_objects[code] = inc_obj - included_records << serializer.record_hash(inc_obj, params) + + included_records << serializer.record_hash(inc_obj, fieldsets[serializer.record_type], params) end end end end - - def fetch_associated_object(record, relationship, params) - return relationship[:object_block].call(record, params) unless relationship[:object_block].nil? - record.send(relationship[:object_method_name]) - end - - def fetch_id(record, relationship, params) - unless relationship[:object_block].nil? - object = relationship[:object_block].call(record, params) - - return object.map(&:id) if object.respond_to? :map - return object.id - end - - record.public_send(relationship[:id_method_name]) - end end end end diff --git a/lib/fast_jsonapi/version.rb b/lib/fast_jsonapi/version.rb index 3c96404b..f17716eb 100644 --- a/lib/fast_jsonapi/version.rb +++ b/lib/fast_jsonapi/version.rb @@ -1,3 +1,3 @@ module FastJsonapi - VERSION = "1.2" + VERSION = "1.3" end diff --git a/spec/lib/object_serializer_class_methods_spec.rb b/spec/lib/object_serializer_class_methods_spec.rb index 55e351af..3c477c34 100644 --- a/spec/lib/object_serializer_class_methods_spec.rb +++ b/spec/lib/object_serializer_class_methods_spec.rb @@ -230,6 +230,23 @@ expect(serializable_hash[:data][:attributes][:title_with_year]).to eq "#{movie.name} (#{movie.release_year})" end end + + context 'with &:proc' do + before do + movie.release_year = 2008 + MovieSerializer.attribute :released_in_year, &:release_year + MovieSerializer.attribute :name, &:local_name + end + + after do + MovieSerializer.attributes_to_serialize.delete(:released_in_year) + end + + it 'returns correct hash when serializable_hash is called' do + expect(serializable_hash[:data][:attributes][:name]).to eq "english #{movie.name}" + expect(serializable_hash[:data][:attributes][:released_in_year]).to eq movie.release_year + end + end end describe '#link' do @@ -312,7 +329,6 @@ movie_type_serializer_class.instance_eval do include FastJsonapi::ObjectSerializer set_key_transform key_transform - set_type :movie_type attributes :name end end @@ -321,25 +337,25 @@ context 'when key_transform is dash' do let(:key_transform) { :dash } - it_behaves_like 'returning key transformed hash', :'movie-type', :'release-year' + it_behaves_like 'returning key transformed hash', :'movie-type', :'dash-movie-type', :'release-year' end context 'when key_transform is camel' do let(:key_transform) { :camel } - it_behaves_like 'returning key transformed hash', :MovieType, :ReleaseYear + it_behaves_like 'returning key transformed hash', :MovieType, :CamelMovieType, :ReleaseYear end context 'when key_transform is camel_lower' do let(:key_transform) { :camel_lower } - it_behaves_like 'returning key transformed hash', :movieType, :releaseYear + it_behaves_like 'returning key transformed hash', :movieType, :camelLowerMovieType, :releaseYear end context 'when key_transform is underscore' do let(:key_transform) { :underscore } - it_behaves_like 'returning key transformed hash', :movie_type, :release_year + it_behaves_like 'returning key transformed hash', :movie_type, :underscore_movie_type, :release_year end end end diff --git a/spec/lib/object_serializer_fields_spec.rb b/spec/lib/object_serializer_fields_spec.rb new file mode 100644 index 00000000..913ba83b --- /dev/null +++ b/spec/lib/object_serializer_fields_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe FastJsonapi::ObjectSerializer do + include_context 'movie class' + + let(:fields) do + { + movie: %i[name actors advertising_campaign], + actor: %i[name agency] + } + end + + it 'only returns specified fields' do + hash = MovieSerializer.new(movie, fields: fields).serializable_hash + + expect(hash[:data][:attributes].keys.sort).to eq %i[name] + end + + it 'only returns specified relationships' do + hash = MovieSerializer.new(movie, fields: fields).serializable_hash + + expect(hash[:data][:relationships].keys.sort).to eq %i[actors advertising_campaign] + end + + it 'only returns specified fields for included relationships' do + hash = MovieSerializer.new(movie, fields: fields, include: %i[actors]).serializable_hash + + expect(hash[:included].first[:attributes].keys.sort).to eq %i[name] + end + + it 'only returns specified relationships for included relationships' do + hash = MovieSerializer.new(movie, fields: fields, include: %i[actors advertising_campaign]).serializable_hash + + expect(hash[:included].first[:relationships].keys.sort).to eq %i[agency] + end + + it 'returns all fields for included relationships when no explicit fields have been specified' do + hash = MovieSerializer.new(movie, fields: fields, include: %i[actors advertising_campaign]).serializable_hash + + expect(hash[:included][3][:attributes].keys.sort).to eq %i[id name] + end + + it 'returns all fields for included relationships when no explicit fields have been specified' do + hash = MovieSerializer.new(movie, fields: fields, include: %i[actors advertising_campaign]).serializable_hash + + expect(hash[:included][3][:relationships].keys.sort).to eq %i[movie] + end +end diff --git a/spec/lib/object_serializer_inheritance_spec.rb b/spec/lib/object_serializer_inheritance_spec.rb index 06dba256..beb74872 100644 --- a/spec/lib/object_serializer_inheritance_spec.rb +++ b/spec/lib/object_serializer_inheritance_spec.rb @@ -95,6 +95,11 @@ class EmployeeSerializer < UserSerializer has_one :account end + it 'sets the correct record type' do + expect(EmployeeSerializer.reflected_record_type).to eq :employee + expect(EmployeeSerializer.record_type).to eq :employee + end + context 'when testing inheritance of attributes' do it 'includes parent attributes' do @@ -113,7 +118,7 @@ class EmployeeSerializer < UserSerializer end it 'includes child attributes' do - expect(EmployeeSerializer.attributes_to_serialize[:location]).to eq(:location) + expect(EmployeeSerializer.attributes_to_serialize[:location].method).to eq(:location) end it 'doesnt change parent class attributes' do diff --git a/spec/lib/object_serializer_spec.rb b/spec/lib/object_serializer_spec.rb index 2d8c99ea..07cbbef4 100644 --- a/spec/lib/object_serializer_spec.rb +++ b/spec/lib/object_serializer_spec.rb @@ -97,6 +97,13 @@ expect(serializable_hash['data']['relationships']['owner']['data']).to be nil end + it 'returns correct json when belongs_to returns nil and there is a block for the relationship' do + movie.owner_id = nil + json = MovieSerializer.new(movie, {include: [:owner]}).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['relationships']['owner']['data']).to be nil + end + it 'returns correct json when has_one returns nil' do supplier.account_id = nil json = SupplierSerializer.new(supplier).serialized_json @@ -302,4 +309,142 @@ class BlahSerializer expect(serializable_hash[:included][0][:links][:self]).to eq url end end + + context 'when is_collection option present' do + subject { MovieSerializer.new(resource, is_collection_options).serializable_hash } + + context 'autodetect' do + let(:is_collection_options) { {} } + + context 'collection if no option present' do + let(:resource) { [movie] } + it { expect(subject[:data]).to be_a(Array) } + end + + context 'single if no option present' do + let(:resource) { movie } + it { expect(subject[:data]).to be_a(Hash) } + end + end + + context 'force is_collection to true' do + let(:is_collection_options) { { is_collection: true } } + + context 'collection will pass' do + let(:resource) { [movie] } + it { expect(subject[:data]).to be_a(Array) } + end + + context 'single will raise error' do + let(:resource) { movie } + it { expect { subject }.to raise_error(NoMethodError, /method(.*)each/) } + end + end + + context 'force is_collection to false' do + let(:is_collection_options) { { is_collection: false } } + + context 'collection will fail without id' do + let(:resource) { [movie] } + it { expect { subject }.to raise_error(FastJsonapi::MandatoryField, /id is a mandatory field/) } + end + + context 'single will pass' do + let(:resource) { movie } + it { expect(subject[:data]).to be_a(Hash) } + end + end + end + + context 'when optional attributes are determined by record data' do + it 'returns optional attribute when attribute is included' do + movie.release_year = 2001 + json = MovieOptionalRecordDataSerializer.new(movie).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['attributes']['release_year']).to eq movie.release_year + end + + it "doesn't return optional attribute when attribute is not included" do + movie.release_year = 1970 + json = MovieOptionalRecordDataSerializer.new(movie).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['attributes'].has_key?('release_year')).to be_falsey + end + end + + context 'when optional attributes are determined by params data' do + it 'returns optional attribute when attribute is included' do + movie.director = 'steven spielberg' + json = MovieOptionalParamsDataSerializer.new(movie, { params: { admin: true }}).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['attributes']['director']).to eq 'steven spielberg' + end + + it "doesn't return optional attribute when attribute is not included" do + movie.director = 'steven spielberg' + json = MovieOptionalParamsDataSerializer.new(movie, { params: { admin: false }}).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['attributes'].has_key?('director')).to be_falsey + end + end + + context 'when optional relationships are determined by record data' do + it 'returns optional relationship when relationship is included' do + json = MovieOptionalRelationshipSerializer.new(movie).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['relationships'].has_key?('actors')).to be_truthy + end + + context "when relationship is not included" do + let(:json) { + MovieOptionalRelationshipSerializer.new(movie, options).serialized_json + } + let(:options) { + {} + } + let(:serializable_hash) { + JSON.parse(json) + } + + it "doesn't return optional relationship" do + movie.actor_ids = [] + expect(serializable_hash['data']['relationships'].has_key?('actors')).to be_falsey + end + + it "doesn't include optional relationship" do + movie.actor_ids = [] + options[:include] = [:actors] + expect(serializable_hash['included']).to be_blank + end + end + end + + context 'when optional relationships are determined by params data' do + it 'returns optional relationship when relationship is included' do + json = MovieOptionalRelationshipWithParamsSerializer.new(movie, { params: { admin: true }}).serialized_json + serializable_hash = JSON.parse(json) + expect(serializable_hash['data']['relationships'].has_key?('owner')).to be_truthy + end + + context "when relationship is not included" do + let(:json) { + MovieOptionalRelationshipWithParamsSerializer.new(movie, options).serialized_json + } + let(:options) { + { params: { admin: false }} + } + let(:serializable_hash) { + JSON.parse(json) + } + + it "doesn't return optional relationship" do + expect(serializable_hash['data']['relationships'].has_key?('owner')).to be_falsey + end + + it "doesn't include optional relationship" do + options[:include] = [:owner] + expect(serializable_hash['included']).to be_blank + end + end + end end diff --git a/spec/lib/serialization_core_spec.rb b/spec/lib/serialization_core_spec.rb index 64f25bbb..adf33dfc 100644 --- a/spec/lib/serialization_core_spec.rb +++ b/spec/lib/serialization_core_spec.rb @@ -17,31 +17,13 @@ expect(result_hash).to be nil end - it 'returns the correct hash when ids_hash_from_record_and_relationship is called for a polymorphic association' do - relationship = { name: :groupees, relationship_type: :has_many, object_method_name: :groupees, polymorphic: {} } - results = GroupSerializer.send :ids_hash_from_record_and_relationship, group, relationship - expect(results).to include({ id: "1", type: :person }, { id: "2", type: :group }) - end - - it 'returns correct hash when ids_hash is called' do - inputs = [{ids: %w(1 2 3), record_type: :movie}, {ids: %w(x y z), record_type: 'person'}] - inputs.each do |hash| - results = MovieSerializer.send(:ids_hash, hash[:ids], hash[:record_type]) - expect(results.map{|h| h[:id]}).to eq hash[:ids] - expect(results[0][:type]).to eq hash[:record_type] - end - - result = MovieSerializer.send(:ids_hash, [], 'movie') - expect(result).to be_empty - end - it 'returns correct hash when attributes_hash is called' do attributes_hash = MovieSerializer.send(:attributes_hash, movie) attribute_names = attributes_hash.keys.sort expect(attribute_names).to eq MovieSerializer.attributes_to_serialize.keys.sort - MovieSerializer.attributes_to_serialize.each do |key, method_name| + MovieSerializer.attributes_to_serialize.each do |key, attribute| value = attributes_hash[key] - expect(value).to eq movie.send(method_name) + expect(value).to eq movie.send(attribute.method) end end @@ -57,7 +39,7 @@ relationships_hash = MovieSerializer.send(:relationships_hash, movie) relationship_names = relationships_hash.keys.sort relationships_hashes = MovieSerializer.relationships_to_serialize.values - expected_names = relationships_hashes.map{|relationship| relationship[:key]}.sort + expected_names = relationships_hashes.map{|relationship| relationship.key}.sort expect(relationship_names).to eq expected_names end @@ -82,7 +64,7 @@ known_included_objects = {} included_records = [] [movie, movie].each do |record| - included_records.concat MovieSerializer.send(:get_included_records, record, includes_list, known_included_objects, nil) + included_records.concat MovieSerializer.send(:get_included_records, record, includes_list, known_included_objects, {}, nil) end expect(included_records.size).to eq 3 end diff --git a/spec/shared/contexts/movie_context.rb b/spec/shared/contexts/movie_context.rb index 7871f8b2..90612260 100644 --- a/spec/shared/contexts/movie_context.rb +++ b/spec/shared/contexts/movie_context.rb @@ -43,10 +43,21 @@ def advertising_campaign ac end + def owner + return unless owner_id + ow = Owner.new + ow.id = owner_id + ow + end + def cache_key "#{id}" end + def local_name(locale = :english) + "#{locale} #{name}" + end + def url "http://movies.com/#{id}" end @@ -146,6 +157,14 @@ class Account attr_accessor :id end + class Owner + attr_accessor :id + end + + class OwnerSerializer + include FastJsonapi::ObjectSerializer + end + # serializers class MovieSerializer include FastJsonapi::ObjectSerializer @@ -153,7 +172,9 @@ class MovieSerializer # director attr is not mentioned intentionally attributes :name, :release_year has_many :actors - belongs_to :owner, record_type: :user + belongs_to :owner, record_type: :user do |object, params| + object.owner + end belongs_to :movie_type has_one :advertising_campaign end @@ -261,6 +282,34 @@ class AccountSerializer set_type :account belongs_to :supplier end + + class MovieOptionalRecordDataSerializer + include FastJsonapi::ObjectSerializer + set_type :movie + attributes :name + attribute :release_year, if: Proc.new { |record| record.release_year >= 2000 } + end + + class MovieOptionalParamsDataSerializer + include FastJsonapi::ObjectSerializer + set_type :movie + attributes :name + attribute :director, if: Proc.new { |record, params| params && params[:admin] == true } + end + + class MovieOptionalRelationshipSerializer + include FastJsonapi::ObjectSerializer + set_type :movie + attributes :name + has_many :actors, if: Proc.new { |record| record.actors.any? } + end + + class MovieOptionalRelationshipWithParamsSerializer + include FastJsonapi::ObjectSerializer + set_type :movie + attributes :name + belongs_to :owner, record_type: :user, if: Proc.new { |record, params| params && params[:admin] == true } + end end diff --git a/spec/shared/examples/object_serializer_class_methods_examples.rb b/spec/shared/examples/object_serializer_class_methods_examples.rb index c529dcb1..cffce414 100644 --- a/spec/shared/examples/object_serializer_class_methods_examples.rb +++ b/spec/shared/examples/object_serializer_class_methods_examples.rb @@ -1,18 +1,18 @@ RSpec.shared_examples 'returning correct relationship hash' do |serializer, id_method_name, record_type| it 'returns correct relationship hash' do - expect(relationship).to be_instance_of(Hash) - expect(relationship.keys).to all(be_instance_of(Symbol)) - expect(relationship[:serializer]).to be serializer - expect(relationship[:id_method_name]).to be id_method_name - expect(relationship[:record_type]).to be record_type + expect(relationship).to be_instance_of(FastJsonapi::Relationship) + # expect(relationship.keys).to all(be_instance_of(Symbol)) + expect(relationship.serializer).to be serializer + expect(relationship.id_method_name).to be id_method_name + expect(relationship.record_type).to be record_type end end -RSpec.shared_examples 'returning key transformed hash' do |movie_type, release_year| +RSpec.shared_examples 'returning key transformed hash' do |movie_type, serializer_type, release_year| it 'returns correctly transformed hash' do expect(hash[:data][0][:attributes]).to have_key(release_year) expect(hash[:data][0][:relationships]).to have_key(movie_type) expect(hash[:data][0][:relationships][movie_type][:data][:type]).to eq(movie_type) - expect(hash[:included][0][:type]).to eq(movie_type) + expect(hash[:included][0][:type]).to eq(serializer_type) end end