diff --git a/README.md b/README.md index 0038b67c..53c8bfa7 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ 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. +Links are defined in FastJsonapi using the `link` method. By default, links 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. You can configure the method to use on the object for example a link with key `self` will get set to the value returned by a method called `url` on the movie object. @@ -245,15 +245,38 @@ 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. +#### Links on a Relationship +You can specify [relationship links](http://jsonapi.org/format/#document-resource-object-relationships) by using the `links:` option on the serializer. Relationship links in JSON API are useful if you want to load a parent document and then load associated documents later due to size constraints (see [related resource links](http://jsonapi.org/format/#document-resource-object-related-resource-links)) ```ruby class MovieSerializer include FastJsonapi::ObjectSerializer + has_many :actors, links: { + self: :url, + related: -> (object) { + "https://movies.com/#{object.id}/actors" + } + } +end +``` + +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 + has_many :actors, lazy_load_data: true, links: { + self: :url, + related: -> (object) { + "https://movies.com/#{object.id}/actors" + } + } +``` + +### 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 meta do |movie| { years_since_release: Date.current.year - movie.year @@ -427,9 +450,9 @@ 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 ``` +set_id | ID of Object | ```set_id :owner_id ``` or ```set_id { |record| "#{record.name.downcase}-#{record.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 ``` +id_method_name | Set custom method name to get ID of an object (If block is provided for the relationship, `id_method_name` is invoked on the return value of the block instead of the resource object) | ```has_many :locations, id_method_name: :place_ids ``` object_method_name | Set custom method name to get related objects | ```has_many :locations, object_method_name: :places ``` record_type | Set custom Object Type for a relationship | ```belongs_to :owner, record_type: :user``` serializer | Set custom Serializer for a relationship | ```has_many :actors, serializer: :custom_actor``` or ```has_many :actors, serializer: MyApp::Api::V1::ActorSerializer``` diff --git a/lib/fast_jsonapi/object_serializer.rb b/lib/fast_jsonapi/object_serializer.rb index a5436f48..b8a24183 100644 --- a/lib/fast_jsonapi/object_serializer.rb +++ b/lib/fast_jsonapi/object_serializer.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true +require 'active_support/time' require 'active_support/json' require 'active_support/concern' require 'active_support/inflector' +require 'active_support/core_ext/numeric/time' require 'fast_jsonapi/attribute' require 'fast_jsonapi/relationship' require 'fast_jsonapi/link' @@ -72,6 +74,7 @@ def serialized_json def process_options(options) @fieldsets = deep_symbolize(options[:fields].presence || {}) + @params = {} return if options.blank? @@ -117,7 +120,7 @@ def inherited(subclass) subclass.transform_method = transform_method subclass.cache_length = cache_length subclass.race_condition_ttl = race_condition_ttl - subclass.data_links = data_links + subclass.data_links = data_links.dup if data_links.present? subclass.cached = cached subclass.set_type(subclass.reflected_record_type) if subclass.reflected_record_type subclass.meta_to_serialize = meta_to_serialize @@ -143,7 +146,11 @@ def set_key_transform(transform_name) 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 + if record_type + set_type(record_type) + elsif reflected_record_type + set_type(reflected_record_type) + end end def run_key_transform(input) @@ -163,8 +170,8 @@ def set_type(type_name) self.record_type = run_key_transform(type_name) end - def set_id(id_name) - self.record_id = id_name + def set_id(id_name = nil, &block) + self.record_id = block || id_name end def cache_options(cache_options) @@ -250,7 +257,9 @@ def create_relationship(base_key, relationship_type, options, block) cached: options[:cached], polymorphic: fetch_polymorphic_option(options), conditional_proc: options[:if], - transform_method: @transform_method + transform_method: @transform_method, + links: options[:links], + lazy_load_data: options[:lazy_load_data] ) end diff --git a/lib/fast_jsonapi/relationship.rb b/lib/fast_jsonapi/relationship.rb index e06b07f3..7a038de7 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, :transform_method + attr_reader :key, :name, :id_method_name, :record_type, :object_method_name, :object_block, :serializer, :relationship_type, :cached, :polymorphic, :conditional_proc, :transform_method, :links, :lazy_load_data def initialize( key:, @@ -14,7 +14,9 @@ def initialize( cached: false, polymorphic:, conditional_proc:, - transform_method: + transform_method:, + links:, + lazy_load_data: false ) @key = key @name = name @@ -28,14 +30,19 @@ def initialize( @polymorphic = polymorphic @conditional_proc = conditional_proc @transform_method = transform_method + @links = links || {} + @lazy_load_data = lazy_load_data 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 - } + + output_hash[key] = {} + unless lazy_load_data + 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? end end @@ -96,6 +103,12 @@ def fetch_id(record, params) record.public_send(id_method_name) 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)\ + end + end + def run_key_transform(input) if self.transform_method.present? input.to_s.send(*self.transform_method).to_sym diff --git a/lib/fast_jsonapi/serialization_core.rb b/lib/fast_jsonapi/serialization_core.rb index a456b21e..200af9b3 100644 --- a/lib/fast_jsonapi/serialization_core.rb +++ b/lib/fast_jsonapi/serialization_core.rb @@ -86,9 +86,10 @@ def record_hash(record, fieldset, params = {}) end def id_from_record(record) - return record.send(record_id) if record_id - raise MandatoryField, 'id is a mandatory field in the jsonapi spec' unless record.respond_to?(:id) - record.id + return record_id.call(record) if record_id.is_a?(Proc) + return record.send(record_id) if record_id + raise MandatoryField, 'id is a mandatory field in the jsonapi spec' unless record.respond_to?(:id) + record.id end # Override #to_json for alternative implementation diff --git a/lib/fast_jsonapi/version.rb b/lib/fast_jsonapi/version.rb index c9c4c7ab..12442c14 100644 --- a/lib/fast_jsonapi/version.rb +++ b/lib/fast_jsonapi/version.rb @@ -1,3 +1,3 @@ module FastJsonapi - VERSION = "1.4" + VERSION = "1.5" end diff --git a/spec/lib/extensions/active_record_spec.rb b/spec/lib/extensions/active_record_spec.rb index e31ffe78..cbe7c1a5 100644 --- a/spec/lib/extensions/active_record_spec.rb +++ b/spec/lib/extensions/active_record_spec.rb @@ -71,3 +71,85 @@ class Account < ActiveRecord::Base File.delete(@db_file) if File.exist?(@db_file) end end + +describe 'active record has_one through' do + # Setup DB + before(:all) do + @db_file = "test_two.db" + + # Open a database + db = SQLite3::Database.new @db_file + + # Create tables + db.execute_batch <<-SQL + create table forests ( + id int primary key, + name varchar(30) + ); + + create table trees ( + id int primary key, + forest_id int, + name varchar(30), + + FOREIGN KEY (forest_id) REFERENCES forests(id) + ); + + create table fruits ( + id int primary key, + tree_id int, + name varchar(30), + + FOREIGN KEY (tree_id) REFERENCES trees(id) + ); + SQL + + # Insert records + db.execute_batch <<-SQL + insert into forests values (1, 'sherwood'); + insert into trees values (2, 1,'pine'); + insert into fruits values (3, 2, 'pine nut'); + + insert into fruits(id,name) values (4,'apple'); + SQL + end + + # Setup Active Record + before(:all) do + class Forest < ActiveRecord::Base + has_many :trees + end + + class Tree < ActiveRecord::Base + belongs_to :forest + end + + class Fruit < ActiveRecord::Base + belongs_to :tree + has_one :forest, through: :tree + end + + ActiveRecord::Base.establish_connection( + :adapter => 'sqlite3', + :database => @db_file + ) + end + + context 'revenue' do + it 'has an forest_id' do + expect(Fruit.find(3).respond_to?(:forest_id)).to be true + expect(Fruit.find(3).forest_id).to eq 1 + expect(Fruit.find(3).forest.name).to eq "sherwood" + end + + it 'has nil if tree id not available' do + expect(Fruit.find(4).respond_to?(:tree_id)).to be true + expect(Fruit.find(4).forest_id).to eq nil + end + end + + # Clean up DB + after(:all) do + File.delete(@db_file) if File.exist?(@db_file) + end +end diff --git a/spec/lib/object_serializer_attribute_param_spec.rb b/spec/lib/object_serializer_attribute_param_spec.rb index f92b9c76..3765a8cf 100644 --- a/spec/lib/object_serializer_attribute_param_spec.rb +++ b/spec/lib/object_serializer_attribute_param_spec.rb @@ -15,7 +15,7 @@ def viewed?(user) class MovieSerializer attribute :viewed do |movie, params| - params ? movie.viewed?(params[:user]) : false + params[:user] ? movie.viewed?(params[:user]) : false end attribute :no_param_attribute do |movie| diff --git a/spec/lib/object_serializer_class_methods_spec.rb b/spec/lib/object_serializer_class_methods_spec.rb index 397ace5a..205f6452 100644 --- a/spec/lib/object_serializer_class_methods_spec.rb +++ b/spec/lib/object_serializer_class_methods_spec.rb @@ -195,28 +195,57 @@ describe '#set_id' do subject(:serializable_hash) { MovieSerializer.new(resource).serializable_hash } - before do - MovieSerializer.set_id :owner_id - end + context 'method name' do + before do + MovieSerializer.set_id :owner_id + end - after do - MovieSerializer.set_id nil - end + after do + MovieSerializer.set_id nil + end - context 'when one record is given' do - let(:resource) { movie } + context 'when one record is given' do + let(:resource) { movie } - it 'returns correct hash which id equals owner_id' do - expect(serializable_hash[:data][:id].to_i).to eq movie.owner_id + it 'returns correct hash which id equals owner_id' do + expect(serializable_hash[:data][:id].to_i).to eq movie.owner_id + end + end + + context 'when an array of records is given' do + let(:resource) { [movie, movie] } + + it 'returns correct hash which id equals owner_id' do + expect(serializable_hash[:data][0][:id].to_i).to eq movie.owner_id + expect(serializable_hash[:data][1][:id].to_i).to eq movie.owner_id + end end end - context 'when an array of records is given' do - let(:resource) { [movie, movie] } + context 'with block' do + before do + MovieSerializer.set_id { |record| "movie-#{record.owner_id}" } + end - it 'returns correct hash which id equals owner_id' do - expect(serializable_hash[:data][0][:id].to_i).to eq movie.owner_id - expect(serializable_hash[:data][1][:id].to_i).to eq movie.owner_id + after do + MovieSerializer.set_id nil + end + + context 'when one record is given' do + let(:resource) { movie } + + it 'returns correct hash which id equals movie-id' do + expect(serializable_hash[:data][:id]).to eq "movie-#{movie.owner_id}" + end + end + + context 'when an array of records is given' do + let(:resource) { [movie, movie] } + + it 'returns correct hash which id equals movie-id' do + expect(serializable_hash[:data][0][:id]).to eq "movie-#{movie.owner_id}" + expect(serializable_hash[:data][1][:id]).to eq "movie-#{movie.owner_id}" + end end end end @@ -355,6 +384,22 @@ def year_since_release_calculator(release_year) expect(serializable_hash[:data][:links][:url]).to eq movie.url end end + + context 'when inheriting from a parent serializer' do + before do + MovieSerializer.link(:url) do |movie_object| + "http://movies.com/#{movie_object.id}" + end + end + subject(:action_serializable_hash) { ActionMovieSerializer.new(movie).serializable_hash } + subject(:horror_serializable_hash) { HorrorMovieSerializer.new(movie).serializable_hash } + + let(:url) { "http://movies.com/#{movie.id}" } + + it 'returns the link for the correct sub-class' do + expect(action_serializable_hash[:data][:links][:url]).to eq "/action-movie/#{movie.id}" + end + end end describe '#key_transform' do @@ -411,4 +456,34 @@ def year_since_release_calculator(release_year) it_behaves_like 'returning key transformed hash', :movie_type, :underscore_movie_type, :release_year end end + + describe '#set_key_transform after #set_type' do + subject(:serializable_hash) { MovieSerializer.new(movie).serializable_hash } + + before do + MovieSerializer.set_type type_name + MovieSerializer.set_key_transform :camel + end + + after do + MovieSerializer.transform_method = nil + MovieSerializer.set_type :movie + end + + context 'when sets singular type name' do + let(:type_name) { :film } + + it 'returns correct hash which type equals transformed set_type value' do + expect(serializable_hash[:data][:type]).to eq :Film + end + end + + context 'when sets plural type name' do + let(:type_name) { :films } + + it 'returns correct hash which type equals transformed set_type value' do + expect(serializable_hash[:data][:type]).to eq :Films + end + end + end end diff --git a/spec/lib/object_serializer_relationship_links_spec.rb b/spec/lib/object_serializer_relationship_links_spec.rb new file mode 100644 index 00000000..8c2f2725 --- /dev/null +++ b/spec/lib/object_serializer_relationship_links_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe FastJsonapi::ObjectSerializer do + include_context 'movie class' + + context "params option" do + let(:hash) { serializer.serializable_hash } + + context "generating links for a serializer relationship" do + let(:params) { { } } + let(:options_with_params) { { params: params } } + let(:relationship_url) { "http://movies.com/#{movie.id}/relationships/actors" } + let(:related_url) { "http://movies.com/movies/#{movie.name.parameterize}/actors/" } + + before(:context) do + class MovieSerializer + has_many :actors, lazy_load_data: false, links: { + self: :actors_relationship_url, + related: -> (object, params = {}) { + "#{params.has_key?(:secure) ? "https" : "http"}://movies.com/movies/#{object.name.parameterize}/actors/" + } + } + end + end + + context "with a single record" do + let(:serializer) { MovieSerializer.new(movie, options_with_params) } + let(:links) { hash[:data][:relationships][:actors][:links] } + + it "handles relationship links that call a method" do + expect(links).to be_present + expect(links[:self]).to eq(relationship_url) + end + + it "handles relationship links that call a proc" do + expect(links).to be_present + expect(links[:related]).to eq(related_url) + end + + context "with serializer params" do + let(:params) { { secure: true } } + let(:secure_related_url) { related_url.gsub("http", "https") } + + it "passes the params to the link serializer correctly" do + expect(links).to be_present + expect(links[:related]).to eq(secure_related_url) + end + end + end + + end + + context "lazy loading relationship data" 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) } + let(:actor_hash) { hash[:data][:relationships][:actors] } + + it "does not include the :data key" do + expect(actor_hash).to be_present + expect(actor_hash).not_to have_key(:data) + end + end + end +end diff --git a/spec/lib/object_serializer_spec.rb b/spec/lib/object_serializer_spec.rb index 85f4a79d..ef755ccc 100644 --- a/spec/lib/object_serializer_spec.rb +++ b/spec/lib/object_serializer_spec.rb @@ -504,4 +504,10 @@ class BlahSerializer end end end + + context 'when attribute contents are determined by params data' do + it 'does not throw an error with no params are passed' do + expect { MovieOptionalAttributeContentsWithParamsSerializer.new(movie).serialized_json }.not_to raise_error + end + end end diff --git a/spec/shared/contexts/movie_context.rb b/spec/shared/contexts/movie_context.rb index f2c7697a..df0c3950 100644 --- a/spec/shared/contexts/movie_context.rb +++ b/spec/shared/contexts/movie_context.rb @@ -61,6 +61,10 @@ def local_name(locale = :english) def url "http://movies.com/#{id}" end + + def actors_relationship_url + "#{url}/relationships/actors" + end end class Actor @@ -181,6 +185,18 @@ class MovieSerializer has_one :advertising_campaign end + class GenreMovieSerializer < MovieSerializer + link(:something) { '/something/' } + end + + class ActionMovieSerializer < GenreMovieSerializer + link(:url) { |object| "/action-movie/#{object.id}" } + end + + class HorrorMovieSerializer < GenreMovieSerializer + link(:url) { |object| "/horror-movie/#{object.id}" } + end + class MovieWithoutIdStructSerializer include FastJsonapi::ObjectSerializer attributes :name, :release_year @@ -228,8 +244,8 @@ class AwardSerializer include FastJsonapi::ObjectSerializer attributes :id, :title attribute :year, if: Proc.new { |record, params| - params[:include_award_year].present? ? - params[:include_award_year] : + params[:include_award_year].present? ? + params[:include_award_year] : false } belongs_to :actor @@ -301,7 +317,7 @@ class MovieOptionalParamsDataSerializer include FastJsonapi::ObjectSerializer set_type :movie attributes :name - attribute :director, if: Proc.new { |record, params| params && params[:admin] == true } + attribute :director, if: Proc.new { |record, params| params[:admin] == true } end class MovieOptionalRelationshipSerializer @@ -315,7 +331,19 @@ 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 } + belongs_to :owner, record_type: :user, if: Proc.new { |record, params| params[:admin] == true } + end + + class MovieOptionalAttributeContentsWithParamsSerializer + include FastJsonapi::ObjectSerializer + set_type :movie + attributes :name + attribute :director do |record, params| + data = {} + data[:first_name] = 'steven' + data[:last_name] = 'spielberg' if params[:admin] + data + end end end @@ -354,6 +382,9 @@ class MovieSerializer after(:context) do classes_to_remove = %i[ + ActionMovieSerializer + GenreMovieSerializer + HorrorMovieSerializer Movie MovieSerializer Actor