diff --git a/.rubocop.yml b/.rubocop.yml index 65e27c6..50263f3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,6 @@ require: - rubocop-performance - # - rubocop-rspec + - rubocop-rspec Style/FrozenStringLiteralComment: Enabled: false @@ -11,42 +11,68 @@ Style/SymbolArray: Style/WordArray: Enabled: false -# RSpec/DescribedClass: -# Enabled: false +Style/SymbolProc: + Exclude: + - 'spec/fixtures/*.rb' + +Lint/DuplicateMethods: + Exclude: + - 'spec/fixtures/*.rb' + +RSpec/FilePath: + Enabled: false + +RSpec/DescribedClass: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +Performance/TimesMap: + Exclude: + - 'spec/**/**.rb' # TODO: Fix these... Style/Documentation: Enabled: false Style/GuardClause: - Enabled: false + Exclude: + - 'lib/**/**.rb' Style/ConditionalAssignment: - Enabled: false + Exclude: + - 'lib/**/**.rb' -Style/ClassAndModuleChildren: - Enabled: false +Style/IfUnlessModifier: + Exclude: + - 'lib/**/**.rb' Lint/AssignmentInCondition: - Enabled: false + Exclude: + - 'lib/**/**.rb' -Metrics/ModuleLength: +Metrics: + Exclude: + - 'lib/**/**.rb' + +Metrics/BlockLength: Enabled: false Layout/LineLength: - Enabled: false - -Metrics: - Enabled: false + Exclude: + - 'lib/**/**.rb' Naming/PredicateName: - Enabled: false + Exclude: + - 'lib/**/**.rb' Naming/AccessorMethodName: - Enabled: false - -Performance/TimesMap: - Enabled: false - -# RSpec/BeforeAfterAll: -# Enabled: false + Exclude: + - 'lib/**/**.rb' diff --git a/Gemfile b/Gemfile index bcf9e56..01d5a23 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,6 @@ source 'https://rubygems.org' # Specify your gem's dependencies in fast_jsonapi.gemspec gemspec + +# TODO: Remove once the gem is released... +gem 'jsonapi-rspec', github: 'jsonapi-rb/jsonapi-rspec' diff --git a/Rakefile b/Rakefile index 7881315..88a6ad4 100644 --- a/Rakefile +++ b/Rakefile @@ -11,5 +11,5 @@ RuboCop::RakeTask.new('rubocop') do |task| ] end -RSpec::Core::RakeTask.new(spec: ['rubocop']) -task(default: :spec) +RSpec::Core::RakeTask.new(:spec) +task(default: [:rubocop, :spec]) diff --git a/lib/fast_jsonapi/object_serializer.rb b/lib/fast_jsonapi/object_serializer.rb index 18bbd72..3de6f6d 100644 --- a/lib/fast_jsonapi/object_serializer.rb +++ b/lib/fast_jsonapi/object_serializer.rb @@ -150,6 +150,7 @@ module FastJsonapi # ensure that the record type is correctly transformed if record_type set_type(record_type) + # TODO: Remove dead code elsif reflected_record_type set_type(reflected_record_type) end @@ -231,6 +232,8 @@ module FastJsonapi self.cachable_relationships_to_serialize = {} if cachable_relationships_to_serialize.nil? self.uncachable_relationships_to_serialize = {} if uncachable_relationships_to_serialize.nil? + # TODO: Remove this undocumented option. + # Delegate the caching to the serializer exclusively. if !relationship.cached uncachable_relationships_to_serialize[relationship.name] = relationship else diff --git a/lib/fast_jsonapi/relationship.rb b/lib/fast_jsonapi/relationship.rb index af9fe23..f80462b 100644 --- a/lib/fast_jsonapi/relationship.rb +++ b/lib/fast_jsonapi/relationship.rb @@ -63,6 +63,7 @@ module FastJsonapi end def serializer_for(record, serialization_params) + # TODO: Remove this, dead code... if @static_serializer @static_serializer @@ -78,6 +79,7 @@ module FastJsonapi serializer_for_name(record.class.name) else + # TODO: Remove this, dead code... raise "Unknown serializer for object #{record.inspect}" end end diff --git a/spec/fixtures/_user.rb b/spec/fixtures/_user.rb new file mode 100644 index 0000000..c993d50 --- /dev/null +++ b/spec/fixtures/_user.rb @@ -0,0 +1,28 @@ +class User + attr_accessor :uid, :first_name, :last_name, :email + + def self.fake(id = nil) + faked = new + faked.uid = id || SecureRandom.uuid + faked.first_name = FFaker::Name.first_name + faked.last_name = FFaker::Name.last_name + faked.email = FFaker::Internet.email + faked + end +end + +class NoSerializerUser < User +end + +class UserSerializer + include FastJsonapi::ObjectSerializer + + set_id :uid + attributes :first_name, :last_name, :email + + meta do |obj| + { + email_length: obj.email.size + } + end +end diff --git a/spec/fixtures/actor.rb b/spec/fixtures/actor.rb new file mode 100644 index 0000000..ba81363 --- /dev/null +++ b/spec/fixtures/actor.rb @@ -0,0 +1,72 @@ +require 'active_support/cache' + +class Actor < User + attr_accessor :movies, :movie_ids + + def self.fake(id = nil) + faked = super(id) + faked.movies = [] + faked.movie_ids = [] + faked + end + + def movie_urls + { + movie_url: movies[0]&.url + } + end +end + +class ActorSerializer < UserSerializer + set_type :actor + + attribute :email, if: ->(_object, params) { params[:conditionals_off].nil? } + + has_many( + :played_movies, + serializer: :movie, + links: :movie_urls, + if: ->(_object, params) { params[:conditionals_off].nil? } + ) do |object| + object.movies + end +end + +class CamelCaseActorSerializer + include FastJsonapi::ObjectSerializer + + set_key_transform :camel + + set_id :uid + set_type :user_actor + attributes :first_name + + link :movie_url do |obj| + obj.movie_urls.values[0] + end + + has_many( + :played_movies, + serializer: :movie + ) do |object| + object.movies + end +end + +class BadMovieSerializerActorSerializer < ActorSerializer + has_many :played_movies, serializer: :bad, object_method_name: :movies +end + +module Cached + class ActorSerializer < ::ActorSerializer + # TODO: Fix this, the serializer gets cached on inherited classes... + has_many :played_movies, serializer: :movie do |object| + object.movies + end + + cache_options( + store: ActiveSupport::Cache::MemoryStore.new, + namespace: 'test' + ) + end +end diff --git a/spec/fixtures/movie.rb b/spec/fixtures/movie.rb new file mode 100644 index 0000000..2ba1666 --- /dev/null +++ b/spec/fixtures/movie.rb @@ -0,0 +1,116 @@ +class Movie + attr_accessor( + :id, + :name, + :year, + :actors, + :actor_ids, + :polymorphics, + :owner, + :owner_id + ) + + def self.fake(id = nil) + faked = new + faked.id = id || SecureRandom.uuid + faked.name = FFaker::Movie.title + faked.year = FFaker::Vehicle.year + faked.actors = [] + faked.actor_ids = [] + faked.polymorphics = [] + faked + end + + def url(obj = nil) + @url ||= FFaker::Internet.http_url + return @url if obj.nil? + + @url + '?' + obj.hash.to_s + end + + def owner=(ownr) + @owner = ownr + @owner_id = ownr.uid + end + + def actors=(acts) + @actors = acts + @actor_ids = actors.map do |actor| + actor.movies << self + actor.uid + end + end +end + +class MovieSerializer + include FastJsonapi::ObjectSerializer + + set_type :movie + + attributes :name + attribute :release_year do |object| + object.year + end + + link :self, :url + + belongs_to :owner, serializer: UserSerializer + has_many( + :actors, + links: { + actors_self: :url, + related: ->(obj) { obj.url(obj) } + } + ) + has_one( + :creator, + object_method_name: :owner, + id_method_name: :uid, + serializer: ->(object, _params) { UserSerializer if object.is_a?(User) } + ) + has_many( + :actors_and_users, + id_method_name: :uid, + polymorphic: { + Actor => :actor, + User => :user + } + ) do |obj| + obj.polymorphics + end + + has_many( + :dynamic_actors_and_users, + id_method_name: :uid, + polymorphic: true + ) do |obj| + obj.polymorphics + end + + has_many( + :auto_detected_actors_and_users, + id_method_name: :uid + ) do |obj| + obj.polymorphics + end +end + +module Cached + class MovieSerializer < ::MovieSerializer + cache_options( + store: ActorSerializer.cache_store_instance, + namespace: 'test' + ) + + has_one( + :creator, + id_method_name: :uid, + serializer: :actor, + # TODO: Remove this undocumented option. + # Delegate the caching to the serializer exclusively. + cached: false + ) do |obj| + obj.owner + end + end +end diff --git a/spec/integration/attributes_fields_spec.rb b/spec/integration/attributes_fields_spec.rb new file mode 100644 index 0000000..2bcdd5e --- /dev/null +++ b/spec/integration/attributes_fields_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:actor) do + act = Actor.fake + act.movies = [Movie.fake] + act + end + let(:params) { {} } + let(:serialized) do + ActorSerializer.new(actor, params).serializable_hash.as_json + end + + describe 'attributes' do + it do + expect(serialized['data']).to have_id(actor.uid) + expect(serialized['data']).to have_type('actor') + + expect(serialized['data']) + .to have_jsonapi_attributes('first_name', 'last_name', 'email').exactly + expect(serialized['data']).to have_attribute('first_name') + .with_value(actor.first_name) + expect(serialized['data']).to have_attribute('last_name') + .with_value(actor.last_name) + expect(serialized['data']).to have_attribute('email') + .with_value(actor.email) + end + + context 'with nil identifier' do + before { actor.uid = nil } + + it { expect(serialized['data']).to have_id(nil) } + end + + context 'with `if` conditions' do + let(:params) { { params: { conditionals_off: 'yes' } } } + + it do + expect(serialized['data']).not_to have_attribute('email') + end + end + + context 'with include and fields' do + let(:params) do + { + include: [:played_movies], + fields: { movie: [:release_year], actor: [:first_name] } + } + end + + it do + expect(serialized['data']) + .to have_jsonapi_attributes(:first_name).exactly + + expect(serialized['included']).to include( + have_type('movie') + .and(have_id(actor.movies[0].id)) + .and(have_jsonapi_attributes('release_year').exactly) + ) + end + end + end +end diff --git a/spec/integration/caching_spec.rb b/spec/integration/caching_spec.rb new file mode 100644 index 0000000..eda3872 --- /dev/null +++ b/spec/integration/caching_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:actor) do + faked = Actor.fake + movie = Movie.fake + movie.owner = User.fake + movie.actors = [faked] + faked.movies = [movie] + faked + end + let(:cache_store) { Cached::ActorSerializer.cache_store_instance } + + describe 'with caching' do + it do + expect(cache_store.delete(actor, namespace: 'test')).to be(false) + + Cached::ActorSerializer.new( + [actor, actor], include: ['played_movies', 'played_movies.owner'] + ).serializable_hash + + expect(cache_store.delete(actor, namespace: 'test')).to be(true) + expect(cache_store.delete(actor.movies[0], namespace: 'test')).to be(true) + expect( + cache_store.delete(actor.movies[0].owner, namespace: 'test') + ).to be(false) + end + end +end diff --git a/spec/integration/errors_spec.rb b/spec/integration/errors_spec.rb new file mode 100644 index 0000000..2d94fde --- /dev/null +++ b/spec/integration/errors_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:actor) { Actor.fake } + let(:params) { {} } + + describe 'with errors' do + it do + expect do + BadMovieSerializerActorSerializer.new( + actor, include: ['played_movies'] + ) + end.to raise_error( + NameError, /cannot resolve a serializer class for 'bad'/ + ) + end + + it do + expect { ActorSerializer.new(actor, include: ['bad_include']) } + .to raise_error( + ArgumentError, /bad_include is not specified as a relationship/ + ) + end + end +end diff --git a/spec/integration/key_transform_spec.rb b/spec/integration/key_transform_spec.rb new file mode 100644 index 0000000..5959c5f --- /dev/null +++ b/spec/integration/key_transform_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:actor) { Actor.fake } + let(:params) { {} } + let(:serialized) do + CamelCaseActorSerializer.new(actor, params).serializable_hash.as_json + end + + describe 'camel case key tranformation' do + it do + expect(serialized['data']).to have_id(actor.uid) + expect(serialized['data']).to have_type('UserActor') + expect(serialized['data']).to have_attribute('FirstName') + expect(serialized['data']).to have_relationship('PlayedMovies') + expect(serialized['data']).to have_link('MovieUrl').with_value(nil) + end + end +end diff --git a/spec/integration/links_spec.rb b/spec/integration/links_spec.rb new file mode 100644 index 0000000..0fb9d57 --- /dev/null +++ b/spec/integration/links_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:movie) do + faked = Movie.fake + faked.actors = [Actor.fake] + faked + end + let(:params) { {} } + let(:serialized) do + MovieSerializer.new(movie, params).serializable_hash.as_json + end + + describe 'links' do + it do + expect(serialized['data']).to have_link('self').with_value(movie.url) + expect(serialized['data']['relationships']['actors']) + .to have_link('actors_self').with_value(movie.url) + expect(serialized['data']['relationships']['actors']) + .to have_link('related').with_value(movie.url(movie)) + end + + context 'with included records' do + let(:serialized) do + ActorSerializer.new(movie.actors[0]).serializable_hash.as_json + end + + it do + expect(serialized['data']['relationships']['played_movies']) + .to have_link('movie_url').with_value(movie.url) + end + end + + context 'with root link' do + let(:params) do + { + links: { 'root_link' => FFaker::Internet.http_url } + } + end + + it do + expect(serialized) + .to have_link('root_link').with_value(params[:links]['root_link']) + end + end + end +end diff --git a/spec/integration/meta_spec.rb b/spec/integration/meta_spec.rb new file mode 100644 index 0000000..7e65b5a --- /dev/null +++ b/spec/integration/meta_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:user) { User.fake } + let(:params) { {} } + let(:serialized) do + UserSerializer.new(user, params).serializable_hash.as_json + end + + it do + expect(serialized['data']).to have_meta('email_length' => user.email.size) + end + + context 'with root meta' do + let(:params) do + { + meta: { 'code' => FFaker::Internet.password } + } + end + + it do + expect(serialized).to have_meta(params[:meta]) + end + end +end diff --git a/spec/integration/relationships_spec.rb b/spec/integration/relationships_spec.rb new file mode 100644 index 0000000..e48ccb0 --- /dev/null +++ b/spec/integration/relationships_spec.rb @@ -0,0 +1,126 @@ +require 'spec_helper' + +RSpec.describe FastJsonapi::ObjectSerializer do + let(:movie) do + mov = Movie.fake + mov.actors = rand(2..5).times.map { Actor.fake } + mov.owner = User.fake + poly_act = Actor.fake + poly_act.movies = [Movie.fake] + mov.polymorphics = [User.fake, poly_act] + mov + end + let(:params) { {} } + let(:serialized) do + MovieSerializer.new(movie, params).serializable_hash.as_json + end + + describe 'relationships' do + it do + actors_rel = movie.actors.map { |a| { 'id' => a.uid, 'type' => 'actor' } } + + expect(serialized['data']) + .to have_relationship('actors').with_data(actors_rel) + + expect(serialized['data']) + .to have_relationship('owner') + .with_data('id' => movie.owner.uid, 'type' => 'user') + + expect(serialized['data']) + .to have_relationship('creator') + .with_data('id' => movie.owner.uid, 'type' => 'user') + + expect(serialized['data']) + .to have_relationship('actors_and_users') + .with_data( + [ + { 'id' => movie.polymorphics[0].uid, 'type' => 'user' }, + { 'id' => movie.polymorphics[1].uid, 'type' => 'actor' } + ] + ) + + expect(serialized['data']) + .to have_relationship('dynamic_actors_and_users') + .with_data( + [ + { 'id' => movie.polymorphics[0].uid, 'type' => 'user' }, + { 'id' => movie.polymorphics[1].uid, 'type' => 'actor' } + ] + ) + + expect(serialized['data']) + .to have_relationship('auto_detected_actors_and_users') + .with_data( + [ + { 'id' => movie.polymorphics[0].uid, 'type' => 'user' }, + { 'id' => movie.polymorphics[1].uid, 'type' => 'actor' } + ] + ) + end + + context 'with include' do + let(:params) do + { include: [:actors] } + end + + it do + movie.actors.each do |actor| + expect(serialized['included']).to include( + have_type('actor') + .and(have_id(actor.uid)) + .and(have_relationship('played_movies') + .with_data([{ 'id' => actor.movies[0].id, 'type' => 'movie' }])) + ) + end + end + + context 'with `if` conditions' do + let(:params) do + { + include: ['actors'], + params: { conditionals_off: 'yes' } + } + end + + it do + movie.actors.each do |actor| + expect(serialized['included']).not_to include( + have_type('actor') + .and(have_id(actor.uid)) + .and(have_relationship('played_movies')) + ) + end + end + end + + context 'with polymorphic' do + let(:params) do + { include: ['actors_and_users.played_movies'] } + end + + it do + expect(serialized['included']).to include( + have_type('user').and(have_id(movie.polymorphics[0].uid)) + ) + + expect(serialized['included']).to include( + have_type('movie').and(have_id(movie.polymorphics[1].movies[0].id)) + ) + + expect(serialized['included']).to include( + have_type('actor') + .and(have_id(movie.polymorphics[1].uid)) + .and( + have_relationship('played_movies').with_data( + [{ + 'id' => movie.polymorphics[1].movies[0].id, + 'type' => 'movie' + }] + ) + ) + ) + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 496ab23..9622107 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,14 +6,19 @@ SimpleCov.start do end SimpleCov.minimum_coverage 90 -require 'active_record' +require 'active_support/core_ext/object/json' require 'fast_jsonapi' +require 'ffaker' +require 'rspec' +require 'jsonapi/rspec' require 'byebug' +require 'securerandom' -Dir[File.dirname(__FILE__) + '/shared/contexts/*.rb'].each {|file| require file } -Dir[File.dirname(__FILE__) + '/shared/examples/*.rb'].each {|file| require file } +Dir[File.expand_path('spec/fixtures/*.rb')].sort.each { |f| require f } RSpec.configure do |config| + config.include JSONAPI::RSpec + config.mock_with :rspec config.filter_run_when_matching :focus config.disable_monkey_patching!