diff --git a/.travis.yml b/.travis.yml index 00b98f79..7025fd1f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,5 +4,12 @@ rvm: - 2.3.6 - 2.4.3 - 2.5.0 + - 2.6 + +before_install: + - "travis_retry gem update --system 2.7.9" + - "travis_retry gem install bundler -v '1.17.3'" +install: BUNDLER_VERSION=1.17.3 bundle install --path=vendor/bundle --retry=3 --jobs=3 + script: - bundle exec rspec diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..97809b45 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- Allow relationship links to be delcared as a method ([#2](https://github.com/fast-jsonapi/fast_jsonapi/pull/2)) +- Test against Ruby 2.6 ([#1](https://github.com/fast-jsonapi/fast_jsonapi/pull/1)) +### Changed +- Optimize SerializationCore.get_included_records calculates remaining_items only once ([#4](https://github.com/fast-jsonapi/fast_jsonapi/pull/4)) +- Optimize SerializtionCore.parse_include_item by mapping in place ([#5](https://github.com/fast-jsonapi/fast_jsonapi/pull/5)) +- Define ObjectSerializer.set_key_transform mapping as a constant ([#7](https://github.com/fast-jsonapi/fast_jsonapi/pull/7)) +- Optimize SerializtionCore.remaining_items by taking from original array ([#9](https://github.com/fast-jsonapi/fast_jsonapi/pull/9)) +- Optimize ObjectSerializer.deep_symbolize by using each_with_object instead of Hash[map] ([#6](https://github.com/fast-jsonapi/fast_jsonapi/pull/6)) + +[Unreleased]: https://github.com/fast-jsonapi/fast_jsonapi/compare/dev...HEAD diff --git a/README.md b/README.md index f36a22a3..b18d0558 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Fast JSON API serialized 250 records in 3.01 ms * [Conditional Attributes](#conditional-attributes) * [Conditional Relationships](#conditional-relationships) * [Sparse Fieldsets](#sparse-fieldsets) + * [Using helper methods](#using-helper-methods) * [Contributing](#contributing) @@ -277,6 +278,12 @@ class MovieSerializer end ``` +Relationship links can also be configured to be defined as a method on the object. + +```ruby + has_many :actors, links: :actor_relationship_links +``` + This will create a `self` reference for the relationship, and a `related` link for loading the actors relationship later. NB: This will not automatically disable loading the data in the relationship, you'll need to do that using the `lazy_load_data` option: ```ruby @@ -291,7 +298,12 @@ This will create a `self` reference for the relationship, and a `related` link f ### 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 @@ -459,6 +471,68 @@ serializer = MovieSerializer.new(movie, { fields: { movie: [:name] } }) serializer.serializable_hash ``` +### Using helper methods + +You can mix-in code from another ruby module into your serializer class to reuse functions across your app. + +Since a serializer is evaluated in a the context of a `class` rather than an `instance` of a class, you need to make sure that your methods act as `class` methods when mixed in. + + +##### Using ActiveSupport::Concern + +``` ruby + +module AvatarHelper + extend ActiveSupport::Concern + + class_methods do + def avatar_url(user) + user.image.url + end + end +end + +class UserSerializer + include FastJsonapi::ObjectSerializer + + include AvatarHelper # mixes in your helper method as class method + + set_type :user + + attributes :name, :email + + attribute :avatar do |user| + avatar_url(user) + end +end + +``` + +##### Using Plain Old Ruby + +``` ruby +module AvatarHelper + def avatar_url(user) + user.image.url + end +end + +class UserSerializer + include FastJsonapi::ObjectSerializer + + extend AvatarHelper # mixes in your helper method as class method + + set_type :user + + attributes :name, :email + + attribute :avatar do |user| + avatar_url(user) + end +end + +``` + ### Customizable Options Option | Purpose | Example @@ -526,9 +600,3 @@ To run tests only performance tests: ```bash rspec spec --tag performance:true ``` - -### We're Hiring! - -Join the Netflix Studio Engineering team and help us build gems like this! - -* [Senior Ruby Engineer](https://jobs.netflix.com/jobs/864893) diff --git a/lib/fast_jsonapi/object_serializer.rb b/lib/fast_jsonapi/object_serializer.rb index c161fb6b..06fcbf64 100644 --- a/lib/fast_jsonapi/object_serializer.rb +++ b/lib/fast_jsonapi/object_serializer.rb @@ -17,6 +17,12 @@ module ObjectSerializer SERIALIZABLE_HASH_NOTIFICATION = 'render.fast_jsonapi.serializable_hash' SERIALIZED_JSON_NOTIFICATION = 'render.fast_jsonapi.serialized_json' + TRANSFORMS_MAPPING = { + camel: :camelize, + camel_lower: [:camelize, :lower], + dash: :dasherize, + underscore: :underscore + }.freeze included do # Set record_type based on the name of the serializer class @@ -43,7 +49,7 @@ def hash_for_one_record return serializable_hash unless @resource - serializable_hash[:data] = self.class.record_hash(@resource, @fieldsets[self.class.record_type.to_sym], @params) + serializable_hash[:data] = self.class.record_hash(@resource, @fieldsets[self.class.record_type.to_sym], @includes, @params) serializable_hash[:included] = self.class.get_included_records(@resource, @includes, @known_included_objects, @fieldsets, @params) if @includes.present? serializable_hash end @@ -55,7 +61,7 @@ def hash_for_collection included = [] fieldset = @fieldsets[self.class.record_type.to_sym] @resource.each do |record| - data << self.class.record_hash(record, fieldset, @params) + data << self.class.record_hash(record, fieldset, @includes, @params) included.concat self.class.get_included_records(record, @includes, @known_included_objects, @fieldsets, @params) if @includes.present? end @@ -93,9 +99,9 @@ def process_options(options) def deep_symbolize(collection) if collection.is_a? Hash - Hash[collection.map do |k, v| - [k.to_sym, deep_symbolize(v)] - end] + collection.each_with_object({}) do |(k, v), hsh| + hsh[k.to_sym] = deep_symbolize(v) + end elsif collection.is_a? Array collection.map { |i| deep_symbolize(i) } else @@ -137,13 +143,7 @@ def reflected_record_type end def set_key_transform(transform_name) - mapping = { - camel: :camelize, - camel_lower: [:camelize, :lower], - dash: :dasherize, - underscore: :underscore - } - self.transform_method = mapping[transform_name.to_sym] + self.transform_method = TRANSFORMS_MAPPING[transform_name.to_sym] # ensure that the record type is correctly transformed if record_type diff --git a/lib/fast_jsonapi/relationship.rb b/lib/fast_jsonapi/relationship.rb index 7a038de7..5f828437 100644 --- a/lib/fast_jsonapi/relationship.rb +++ b/lib/fast_jsonapi/relationship.rb @@ -34,12 +34,12 @@ def initialize( @lazy_load_data = lazy_load_data end - def serialize(record, serialization_params, output_hash) + def serialize(record, included, serialization_params, output_hash) if include_relationship?(record, serialization_params) empty_case = relationship_type == :has_many ? [] : nil output_hash[key] = {} - unless lazy_load_data + unless (lazy_load_data && !included) output_hash[key][:data] = ids_hash_from_record_and_relationship(record, serialization_params) || empty_case end add_links_hash(record, serialization_params, output_hash) if links.present? @@ -104,8 +104,12 @@ def fetch_id(record, params) end def add_links_hash(record, params, output_hash) - output_hash[key][:links] = links.each_with_object({}) do |(key, method), hash| - Link.new(key: key, method: method).serialize(record, params, hash)\ + if links.is_a?(Symbol) + output_hash[key][:links] = record.public_send(links) + else + output_hash[key][:links] = links.each_with_object({}) do |(key, method), hash| + Link.new(key: key, method: method).serialize(record, params, hash)\ + end end end diff --git a/lib/fast_jsonapi/serialization_core.rb b/lib/fast_jsonapi/serialization_core.rb index 845aee75..1c9a3bcb 100644 --- a/lib/fast_jsonapi/serialization_core.rb +++ b/lib/fast_jsonapi/serialization_core.rb @@ -51,13 +51,14 @@ def attributes_hash(record, fieldset = nil, params = {}) end end - def relationships_hash(record, relationships = nil, fieldset = nil, params = {}) + def relationships_hash(record, relationships = nil, fieldset = nil, includes_list = nil, params = {}) relationships = relationships_to_serialize if relationships.nil? relationships = relationships.slice(*fieldset) if fieldset.present? relationships = {} if fieldset == [] - relationships.each_with_object({}) do |(_k, relationship), hash| - relationship.serialize(record, params, hash) + relationships.each_with_object({}) do |(key, relationship), hash| + included = includes_list.present? && includes_list.include?(key) + relationship.serialize(record, included, params, hash) end end @@ -65,23 +66,23 @@ def meta_hash(record, params = {}) meta_to_serialize.call(record, params) end - def record_hash(record, fieldset, params = {}) + def record_hash(record, fieldset, includes_list, 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, fieldset, params) if attributes_to_serialize.present? temp_hash[:relationships] = {} - temp_hash[:relationships] = relationships_hash(record, cachable_relationships_to_serialize, fieldset, params) if cachable_relationships_to_serialize.present? + temp_hash[:relationships] = relationships_hash(record, cachable_relationships_to_serialize, fieldset, includes_list, params) if cachable_relationships_to_serialize.present? 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, fieldset, params)) if uncachable_relationships_to_serialize.present? + record_hash[:relationships] = record_hash[:relationships].merge(relationships_hash(record, uncachable_relationships_to_serialize, fieldset, includes_list, 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[:relationships] = relationships_hash(record, nil, fieldset, includes_list, 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 @@ -102,15 +103,14 @@ def to_json(payload) def parse_include_item(include_item) return [include_item.to_sym] unless include_item.to_s.include?('.') - include_item.to_s.split('.').map { |item| item.to_sym } + + include_item.to_s.split('.').map!(&:to_sym) end def remaining_items(items) return unless items.size > 1 - items_copy = items.dup - items_copy.delete_at(0) - [items_copy.join('.').to_sym] + [items[1..-1].join('.').to_sym] end # includes handler @@ -119,6 +119,8 @@ def get_included_records(record, includes_list, known_included_objects, fieldset includes_list.sort.each_with_object([]) do |include_item, included_records| items = parse_include_item(include_item) + remaining_items = remaining_items(items) + items.each do |item| next unless relationships_to_serialize && relationships_to_serialize[item] relationship_item = relationships_to_serialize[item] @@ -139,8 +141,8 @@ def get_included_records(record, includes_list, known_included_objects, fieldset 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, params) + if remaining_items.present? + serializer_records = serializer.get_included_records(inc_obj, remaining_items, known_included_objects, fieldsets, params) included_records.concat(serializer_records) unless serializer_records.empty? end @@ -149,7 +151,7 @@ def get_included_records(record, includes_list, known_included_objects, fieldset known_included_objects[code] = inc_obj - included_records << serializer.record_hash(inc_obj, fieldsets[serializer.record_type], params) + included_records << serializer.record_hash(inc_obj, fieldsets[serializer.record_type], includes_list, params) end end end diff --git a/spec/lib/object_serializer_relationship_links_spec.rb b/spec/lib/object_serializer_relationship_links_spec.rb index 8c2f2725..e45f9d74 100644 --- a/spec/lib/object_serializer_relationship_links_spec.rb +++ b/spec/lib/object_serializer_relationship_links_spec.rb @@ -67,5 +67,46 @@ class LazyLoadingMovieSerializer < MovieSerializer expect(actor_hash).not_to have_key(:data) end end + + context "including lazy loaded relationships" do + before(:context) do + class LazyLoadingMovieSerializer < MovieSerializer + has_many :actors, lazy_load_data: true, links: { + related: :actors_relationship_url + } + end + end + + let(:serializer) { LazyLoadingMovieSerializer.new(movie, include: [:actors]) } + let(:actor_hash) { hash[:data][:relationships][:actors] } + + it "includes the :data key" do + expect(actor_hash).to be_present + expect(actor_hash).to have_key(:data) + end + end + + context "relationship links defined by a method on the object" do + before(:context) do + class Movie + def relationship_links + { self: "http://movies.com/#{id}/relationships/actors" } + end + end + + class LinksPassingMovieSerializer < MovieSerializer + has_many :actors, links: :relationship_links + end + end + + let(:serializer) { LinksPassingMovieSerializer.new(movie) } + let(:links) { hash[:data][:relationships][:actors][:links] } + let(:relationship_url) { "http://movies.com/#{movie.id}/relationships/actors" } + + it "generates relationship links in the object" do + expect(links).to be_present + expect(links[:self]).to eq(relationship_url) + end + end end end diff --git a/spec/lib/serialization_core_spec.rb b/spec/lib/serialization_core_spec.rb index adf33dfc..0d8d0117 100644 --- a/spec/lib/serialization_core_spec.rb +++ b/spec/lib/serialization_core_spec.rb @@ -52,7 +52,7 @@ end it 'returns correct hash when record_hash is called' do - record_hash = MovieSerializer.send(:record_hash, movie, nil) + record_hash = MovieSerializer.send(:record_hash, movie, nil, nil) expect(record_hash[:id]).to eq movie.id.to_s expect(record_hash[:type]).to eq MovieSerializer.record_type expect(record_hash).to have_key(:attributes) if MovieSerializer.attributes_to_serialize.present?