diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c73a811 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc088e2..fbe277e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,23 +4,26 @@ on: [push, pull_request] jobs: ruby_rails_test_matrix: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} strategy: matrix: - ruby: [2.4, 2.6] - rails: [4, 5, 6] - exclude: - - ruby: 2.4 - rails: 6 + ruby: ['3.3', '3.3', '3.4', '4'] + rails: ['7.2', '8.0', '8'] + experimental: [false] + include: + - rails: '7.1' + ruby: '3.3' + experimental: true steps: - - uses: actions/checkout@master + - uses: actions/checkout@v6 - - name: Sets up the environment - uses: actions/setup-ruby@v1 + - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + bundler-cache: true - name: Runs code QA and tests env: @@ -29,8 +32,5 @@ jobs: rm -rf Gemfile.lock sudo apt-get update sudo apt-get install libsqlite3-dev - gem uninstall bundler -a --force - gem install bundler -v '~> 1' - echo $RAILS_VERSION | grep -q '4' && export SQLITE3_VERSION='~> 1.3.6' bundle - rake + bundle exec rake diff --git a/.rubocop.yml b/.rubocop.yml index b287531..6b68123 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,12 +4,21 @@ inherit_gem: require: rubocop-performance +AllCops: + NewCops: enable + Performance: Enabled: true Rails: Enabled: true +Rails/Pluck: + Enabled: false + +Rails/NegateInclude: + Enabled: false + Style/StringLiterals: Enabled: true EnforcedStyle: single_quotes diff --git a/Gemfile b/Gemfile index ce256d1..b77b5cb 100644 --- a/Gemfile +++ b/Gemfile @@ -2,5 +2,3 @@ source 'https://rubygems.org' # Specify your gem's dependencies in jsonapi.gemspec gemspec - -gem 'jsonapi-rspec', git: 'https://github.com/jsonapi-rb/jsonapi-rspec.git' diff --git a/README.md b/README.md index 85e7983..093335d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Main goals: The available features include: - * object serialization (powered by Fast JSON API) + * object serialization (powered by JSON:API Serializer, was `fast_jsonapi`) * [error handling](https://jsonapi.org/format/#errors) (parameters, validation, generic errors) * fetching of the data (support for @@ -39,16 +39,25 @@ The available features include: [sparse fields](https://jsonapi.org/format/#fetching-sparse-fieldsets)) * [filtering](https://jsonapi.org/format/#fetching-filtering) and [sorting](https://jsonapi.org/format/#fetching-sorting) of the data - (powered by Ransack) + (powered by Ransack, soft-dependency) * [pagination](https://jsonapi.org/format/#fetching-pagination) support ## But how? -Mainly by leveraging [Fast JSON API](https://github.com/Netflix/fast_jsonapi) +Mainly by leveraging [JSON:API Serializer](https://github.com/jsonapi-serializer/jsonapi-serializer) and [Ransack](https://github.com/activerecord-hackery/ransack). Thanks to everyone who worked on these amazing projects! +## Sponsors + +I'm grateful for the following companies for supporting this project! + +
+ + ## Installation Add this line to your application's Gemfile: @@ -100,7 +109,7 @@ The naming scheme follows the `ModuleName::ClassNameSerializer` for an instance of the `ModuleName::ClassName`. Please follow the -[Fast JSON API guide](https://github.com/Netflix/fast_jsonapi#serializer-definition) +[JSON:API Serializer guide](https://github.com/jsonapi-serializer/jsonapi-serializer#serializer-definition) on how to define a serializer. To provide a different naming scheme implement the `jsonapi_serializer_class` @@ -223,6 +232,12 @@ class MyController < ActionController::Base end ``` +This allows you to run queries like: + +```bash +$ curl -X GET /api/resources?fields[model]=model_attr,relationship +``` + ### Filtering and sorting `JSONAPI::Filtering` uses the power of @@ -231,6 +246,8 @@ to filter and sort over a collection of records. The support is pretty extended and covers also relationships and composite matchers. +Please add `ransack` to your `Gemfile` in order to benefit from this functionality! + Here's an example: ```ruby @@ -264,7 +281,7 @@ grouping. To enable expressions along with filters, use the option flags: ```ruby options = { sort_with_expressions: true } jsonapi_filter(User.all, allowed_fields, options) do |filtered| - render jsonapi: result.group('id').to_a + render jsonapi: filtered.result.group('id').to_a end ``` @@ -290,6 +307,7 @@ class MyController < ActionController::Base render jsonapi: paginated end end + end ``` @@ -306,6 +324,17 @@ use the `jsonapi_pagination_meta` method: end ``` + +If you want to change the default number of items per page or define a custom logic to handle page size, use the +`jsonapi_page_size` method: + +```ruby + def jsonapi_page_size(pagination_params) + per_page = pagination_params[:size].to_f.to_i + per_page = 30 if per_page > 30 || per_page < 1 + per_page + end +``` ### Deserialization `JSONAPI::Deserialization` provides a helper to transform a `JSONAPI` document diff --git a/Rakefile b/Rakefile index 6572d7d..55a130d 100644 --- a/Rakefile +++ b/Rakefile @@ -4,6 +4,7 @@ require 'rubocop/rake_task' require 'yaml' require 'yardstick' +# rubocop:disable Rails/RakeEnvironment desc('Documentation stats and measurements') task('qa:docs') do yaml = YAML.load_file(File.expand_path('../.yardstick.yml', __FILE__)) @@ -13,6 +14,7 @@ task('qa:docs') do coverage = Yardstick.round_percentage(measure.coverage * 100) exit(1) if coverage < config.threshold end +# rubocop:enable Rails/RakeEnvironment desc('Codestyle check and linter') RuboCop::RakeTask.new('qa:code') do |task| @@ -24,7 +26,11 @@ RuboCop::RakeTask.new('qa:code') do |task| end desc('Run CI QA tasks') -task(qa: ['qa:docs', 'qa:code']) +if ENV['RAILS_VERSION'].to_s.include?('4') + task(qa: ['qa:docs']) +else + task(qa: ['qa:docs', 'qa:code']) +end -RSpec::Core::RakeTask.new(spec: :qa) -task(default: :spec) +RSpec::Core::RakeTask.new(:spec) +task(default: %w[qa spec]) diff --git a/jsonapi.rb.gemspec b/jsonapi.rb.gemspec index dcc0295..2d82832 100644 --- a/jsonapi.rb.gemspec +++ b/jsonapi.rb.gemspec @@ -16,25 +16,29 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/stas/jsonapi.rb' spec.license = 'MIT' - spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) } - end + spec.files = Dir.glob('{lib,spec}/**/*', File::FNM_DOTMATCH) + spec.files += %w(LICENSE.txt README.md) spec.require_paths = ['lib'] - spec.add_dependency 'fast_jsonapi', '~> 1.5' - spec.add_dependency 'ransack' + spec.post_install_message = ( + 'Install manually `ransack` gem before using `JSONAPI::Filtering`!' + ) + + spec.add_dependency 'jsonapi-serializer' spec.add_dependency 'rack' spec.add_development_dependency 'bundler' - spec.add_development_dependency 'rails', ENV['RAILS_VERSION'] - spec.add_development_dependency 'sqlite3', ENV['SQLITE3_VERSION'] + spec.add_development_dependency 'ransack' + spec.add_development_dependency 'railties', ENV['RAILS_VERSION'] + spec.add_development_dependency 'activerecord', ENV['RAILS_VERSION'] + spec.add_development_dependency 'sqlite3', '~> 2.1' spec.add_development_dependency 'ffaker' spec.add_development_dependency 'rspec', '~> 3.0' spec.add_development_dependency 'rspec-rails' spec.add_development_dependency 'jsonapi-rspec' spec.add_development_dependency 'yardstick' spec.add_development_dependency 'rubocop-rails_config' - spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'rubocop', ENV['RUBOCOP_VERSION'] spec.add_development_dependency 'simplecov' spec.add_development_dependency 'rubocop-performance' end diff --git a/lib/jsonapi/active_model_error_serializer.rb b/lib/jsonapi/active_model_error_serializer.rb index 8d9fead..e46521b 100644 --- a/lib/jsonapi/active_model_error_serializer.rb +++ b/lib/jsonapi/active_model_error_serializer.rb @@ -3,9 +3,6 @@ module JSONAPI # [ActiveModel::Errors] serializer class ActiveModelErrorSerializer < ErrorSerializer - set_id :object_id - set_type :error - attribute :status do '422' end @@ -15,35 +12,15 @@ class ActiveModelErrorSerializer < ErrorSerializer end attribute :code do |object| - _, error_hash = object - code = error_hash[:error] unless error_hash[:error].is_a?(Hash) - code ||= error_hash[:message] || :invalid - # `parameterize` separator arguments are different on Rails 4 vs 5... - code.to_s.delete("''").parameterize.tr('-', '_') + object.type.to_s.delete("''").parameterize.tr('-', '_') end - attribute :detail do |object, params| - error_key, error_hash = object - errors_object = params[:model].errors - - # Rails 4 provides just the message. - if error_hash[:error].present? && error_hash[:error].is_a?(Hash) - message = errors_object.generate_message( - error_key, nil, error_hash[:error] - ) - elsif error_hash[:error].present? - message = errors_object.generate_message( - error_key, error_hash[:error], error_hash - ) - else - message = error_hash[:message] - end - - errors_object.full_message(error_key, message) + attribute :detail do |object, _params| + object.full_message end attribute :source do |object, params| - error_key, _ = object + error_key = object.attribute model_serializer = params[:model_serializer] attrs = (model_serializer.attributes_to_serialize || {}).keys rels = (model_serializer.relationships_to_serialize || {}).keys diff --git a/lib/jsonapi/deserialization.rb b/lib/jsonapi/deserialization.rb index 0a9d5de..dae089a 100644 --- a/lib/jsonapi/deserialization.rb +++ b/lib/jsonapi/deserialization.rb @@ -65,7 +65,7 @@ def jsonapi_deserialize(document, options = {}) rel_name = jsonapi_inflector.singularize(assoc_name) if assoc_data.is_a?(Array) - parsed["#{rel_name}_ids"] = assoc_data.map { |ri| ri['id'] }.compact + parsed["#{rel_name}_ids"] = assoc_data.filter_map { |ri| ri['id'] } next end diff --git a/lib/jsonapi/error_serializer.rb b/lib/jsonapi/error_serializer.rb index 6b5fb6c..3b6e10f 100644 --- a/lib/jsonapi/error_serializer.rb +++ b/lib/jsonapi/error_serializer.rb @@ -1,20 +1,25 @@ -require 'fast_jsonapi' +require 'jsonapi/serializer' module JSONAPI # A simple error serializer class ErrorSerializer - include FastJsonapi::ObjectSerializer + include JSONAPI::Serializer - set_id :object_id set_type :error # Object/Hash attribute helpers. - [:status, :source, :title, :detail].each do |attr_name| + [:status, :source, :title, :detail, :code, :meta].each do |attr_name| attribute attr_name do |object| object.try(attr_name) || object.try(:fetch, attr_name, nil) end end + # Overwrite the ID extraction method, to skip validations + # + # @return [NilClass] + def self.id_from_record(_record, _params) + end + # Remap the root key to `errors` # # @return [Hash] diff --git a/lib/jsonapi/errors.rb b/lib/jsonapi/errors.rb index 1ecca5e..5b25425 100644 --- a/lib/jsonapi/errors.rb +++ b/lib/jsonapi/errors.rb @@ -47,7 +47,7 @@ def render_jsonapi_not_found(exception) render jsonapi_errors: [error], status: :not_found end - # Unprocessable entity (422) error handler callback + # Unprocessable Content (422) error handler callback # # @param exception [Exception] instance to handle # @return [String] JSONAPI error response diff --git a/lib/jsonapi/fetching.rb b/lib/jsonapi/fetching.rb index c394e8a..d5e91ec 100644 --- a/lib/jsonapi/fetching.rb +++ b/lib/jsonapi/fetching.rb @@ -17,7 +17,7 @@ def jsonapi_fields end params[:fields].each do |k, v| - extracted[k] = v.to_s.split(',').map(&:strip).compact + extracted[k] = v.to_s.split(',').filter_map(&:strip) end extracted @@ -29,7 +29,7 @@ def jsonapi_fields # # @return [Array] def jsonapi_include - params['include'].to_s.split(',').map(&:strip).compact + params['include'].to_s.split(',').filter_map(&:strip) end end end diff --git a/lib/jsonapi/filtering.rb b/lib/jsonapi/filtering.rb index 70d47db..7a6ab73 100644 --- a/lib/jsonapi/filtering.rb +++ b/lib/jsonapi/filtering.rb @@ -1,5 +1,8 @@ -require 'ransack/predicate' -require_relative 'patches' +begin + require 'ransack/predicate' + require_relative 'patches' +rescue LoadError +end # Filtering and sorting support module JSONAPI diff --git a/lib/jsonapi/pagination.rb b/lib/jsonapi/pagination.rb index 4d267cf..9c6ef65 100644 --- a/lib/jsonapi/pagination.rb +++ b/lib/jsonapi/pagination.rb @@ -13,14 +13,13 @@ module Pagination def jsonapi_paginate(resources) offset, limit, _ = jsonapi_pagination_params + # Cache the original resources size to be used for pagination meta + @_jsonapi_original_size = resources.size + if resources.respond_to?(:offset) resources = resources.offset(offset).limit(limit) else - original_size = resources.size resources = resources[(offset)..(offset + limit - 1)] || [] - - # Cache the original resources size to be used for pagination meta - resources.instance_variable_set(:@original_size, original_size) end block_given? ? yield(resources) : resources @@ -30,7 +29,7 @@ def jsonapi_paginate(resources) # # @return [Array] def jsonapi_pagination(resources) - links = { self: request.base_url + request.fullpath } + links = { self: request.base_url + CGI.unescape(request.fullpath) } pagination = jsonapi_pagination_meta(resources) return links if pagination.blank? @@ -43,6 +42,8 @@ def jsonapi_pagination(resources) original_url = request.base_url + request.path + '?' pagination.each do |page_name, number| + next if page_name == :records + original_params[:page][:number] = number links[page_name] = original_url + CGI.unescape( original_params.to_query @@ -62,13 +63,7 @@ def jsonapi_pagination_meta(resources) numbers = { current: page } - if resources.respond_to?(:unscope) - total = resources.unscope(:limit, :offset, :order).count() - else - # Try to fetch the cached size first - total = resources.instance_variable_get(:@original_size) - total ||= resources.size - end + total = @_jsonapi_original_size last_page = [1, (total.to_f / limit).ceil].max @@ -82,6 +77,10 @@ def jsonapi_pagination_meta(resources) numbers[:last] = last_page end + if total.present? + numbers[:records] = total + end + numbers end @@ -89,16 +88,30 @@ def jsonapi_pagination_meta(resources) # # @return [Array] with the offset, limit and the current page number def jsonapi_pagination_params - def_per_page = self.class.const_get(:JSONAPI_PAGE_SIZE).to_i - pagination = params[:page].try(:slice, :number, :size) || {} - per_page = pagination[:size].to_f.to_i - per_page = def_per_page if per_page > def_per_page || per_page < 1 + per_page = jsonapi_page_size(pagination) num = [1, pagination[:number].to_f.to_i].max [(num - 1) * per_page, per_page, num] end + # Retrieves the default page size + # + # @param per_page_param [Hash] opts the paginations params + # @option opts [String] :number the page number requested + # @option opts [String] :size the page size requested + # + # @return [Integer] + def jsonapi_page_size(pagination_params) + per_page = pagination_params[:size].to_f.to_i + + return self.class + .const_get(:JSONAPI_PAGE_SIZE) + .to_i if per_page < 1 + + per_page + end + # Fallback to Rack's parsed query string when Rails is not available # # @return [Hash] diff --git a/lib/jsonapi/rails.rb b/lib/jsonapi/rails.rb index 9aae0dc..0983628 100644 --- a/lib/jsonapi/rails.rb +++ b/lib/jsonapi/rails.rb @@ -37,16 +37,16 @@ def self.install! # @return [NilClass] def self.add_errors_renderer! ActionController::Renderers.add(:jsonapi_errors) do |resource, options| - self.content_type ||= Mime[:jsonapi] + self.content_type = Mime[:jsonapi] if self.media_type.nil? many = JSONAPI::Rails.is_collection?(resource, options[:is_collection]) resource = [resource] unless many - return JSONAPI::ErrorSerializer.new(resource, options) - .serialized_json unless resource.is_a?(ActiveModel::Errors) + return JSONAPI::Rails.serializer_to_json( + JSONAPI::ErrorSerializer.new(resource, options) + ) unless resource.is_a?(ActiveModel::Errors) - errors = [] - model = resource.instance_variable_get('@base') + model = resource.instance_variable_get(:@base) if respond_to?(:jsonapi_serializer_class, true) model_serializer = jsonapi_serializer_class(model, false) @@ -54,21 +54,14 @@ def self.add_errors_renderer! model_serializer = JSONAPI::Rails.serializer_class(model, false) end - details = resource.messages - details = resource.details if resource.respond_to?(:details) - - details.each do |error_key, error_hashes| - error_hashes.each do |error_hash| - # Rails 4 provides just the message. - error_hash = { message: error_hash } unless error_hash.is_a?(Hash) - - errors << [ error_key, error_hash ] - end - end - - JSONAPI::ActiveModelErrorSerializer.new( - errors, params: { model: model, model_serializer: model_serializer } - ).serialized_json + JSONAPI::Rails.serializer_to_json( + JSONAPI::ActiveModelErrorSerializer.new( + resource.errors, params: { + model: model, + model_serializer: model_serializer + } + ) + ) end end @@ -77,7 +70,7 @@ def self.add_errors_renderer! # @return [NilClass] def self.add_renderer! ActionController::Renderers.add(:jsonapi) do |resource, options| - self.content_type ||= Mime[:jsonapi] + self.content_type = Mime[:jsonapi] if self.media_type.nil? JSONAPI_METHODS_MAPPING.to_a[0..1].each do |opt, method_name| next unless respond_to?(method_name, true) @@ -87,7 +80,7 @@ def self.add_renderer! # If it's an empty collection, return it directly. many = JSONAPI::Rails.is_collection?(resource, options[:is_collection]) if many && !resource.any? - return options.slice(:meta, :links).merge(data: []).to_json + return options.slice(:meta, :links).compact.merge(data: []).to_json end JSONAPI_METHODS_MAPPING.to_a[2..-1].each do |opt, method_name| @@ -100,21 +93,21 @@ def self.add_renderer! serializer_class = JSONAPI::Rails.serializer_class(resource, many) end - serializer_class.new(resource, options).serialized_json + JSONAPI::Rails.serializer_to_json( + serializer_class.new(resource, options) + ) end end # Checks if an object is a collection # - # Stolen from [FastJsonapi::ObjectSerializer], instance method. + # Basically forwards it to a [JSONAPI::Serializer] as there's no public API # # @param resource [Object] to check # @param force_is_collection [NilClass] flag to overwrite # @return [TrueClass] upon success def self.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) + JSONAPI::ErrorSerializer.is_collection?(resource, force_is_collection) end # Resolves resource serializer class @@ -126,5 +119,17 @@ def self.serializer_class(resource, is_collection) "#{klass.name}Serializer".constantize end + + # Lazily returns the serializer JSON + # + # @param serializer [Object] to evaluate + # @return [String] + def self.serializer_to_json(serializer) + if serializer.respond_to?(:serialized_json) + serializer.serialized_json + else + serializer.serializable_hash.to_json + end + end end end diff --git a/lib/jsonapi/version.rb b/lib/jsonapi/version.rb index 9eee5f9..4546016 100644 --- a/lib/jsonapi/version.rb +++ b/lib/jsonapi/version.rb @@ -1,3 +1,3 @@ module JSONAPI - VERSION = '1.5.7' + VERSION = '2.1.1' end diff --git a/spec/dummy.rb b/spec/dummy.rb index 9239455..dd2c0ec 100644 --- a/spec/dummy.rb +++ b/spec/dummy.rb @@ -1,8 +1,7 @@ require 'securerandom' -require 'active_record' -require 'action_controller/railtie' -require 'jsonapi' +require 'rails/all' require 'ransack' +require 'jsonapi' Rails.logger = Logger.new(STDOUT) Rails.logger.level = ENV['LOG_LEVEL'] || Logger::WARN @@ -31,16 +30,38 @@ class User < ActiveRecord::Base has_many :notes + + def self.ransackable_attributes(auth_object = nil) + %w(created_at first_name id last_name updated_at) + end + + def self.ransackable_associations(auth_object = nil) + %w(notes) + end end class Note < ActiveRecord::Base + validate :title_cannot_contain_slurs validates_format_of :title, without: /BAD_TITLE/ validates_numericality_of :quantity, less_than: 100, if: :quantity? belongs_to :user, required: true + + def self.ransackable_associations(auth_object = nil) + %w(user) + end + + def self.ransackable_attributes(auth_object = nil) + %w(created_at id quantity title updated_at user_id) + end + + private + def title_cannot_contain_slurs + errors.add(:base, 'Title has slurs') if title.to_s.include?('SLURS') + end end class CustomNoteSerializer - include FastJsonapi::ObjectSerializer + include JSONAPI::Serializer set_type :note belongs_to :user @@ -48,7 +69,7 @@ class CustomNoteSerializer end class UserSerializer - include FastJsonapi::ObjectSerializer + include JSONAPI::Serializer has_many :notes, serializer: CustomNoteSerializer attributes(:last_name, :created_at, :updated_at) @@ -63,7 +84,6 @@ class UserSerializer end class Dummy < Rails::Application - secrets.secret_key_base = '_' config.hosts << 'www.example.com' if config.respond_to?(:hosts) routes.draw do @@ -98,6 +118,7 @@ def index result = result.to_a if params[:as_list] jsonapi_paginate(result) do |paginated| + paginated = paginated.to_a if params[:decorate_after_pagination] render jsonapi: paginated end end diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index a67e0ac..1320dbc 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -32,11 +32,16 @@ it do expect(response).to have_http_status(:unprocessable_entity) expect(response_json['errors'].size).to eq(1) - expect(response_json['errors'][0]['status']).to eq('422') - expect(response_json['errors'][0]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[422]) - expect(response_json['errors'][0]['source']).to eq('pointer' => '') - expect(response_json['errors'][0]['detail']).to be_nil + expect(response_json['errors']).to contain_exactly( + { + 'status' => '422', + 'source' => { 'pointer' => '' }, + 'title' => 'Unprocessable Content', + 'detail' => nil, + 'code' => nil, + 'meta' => nil + } + ) end end @@ -50,14 +55,21 @@ it do expect(response).to have_http_status(:unprocessable_entity) expect(response_json['errors'].size).to eq(1) - expect(response_json['errors'][0]['status']).to eq('422') - expect(response_json['errors'][0]['code']).to include('blank') - expect(response_json['errors'][0]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[422]) - expect(response_json['errors'][0]['source']) - .to eq('pointer' => '/data/relationships/user') - expect(response_json['errors'][0]['detail']) - .to eq('User can\'t be blank') + expected_detail = if Rails.gem_version >= Gem::Version.new('6.1') + 'User must exist' + else + 'User can\'t be blank' + end + expect(response_json['errors']).to contain_exactly( + { + 'status' => '422', + 'source' => { 'pointer' => '/data/relationships/user' }, + 'title' => 'Unprocessable Content', + 'detail' => expected_detail, + 'code' => 'blank', + 'meta' => nil + } + ) end context 'required by validations' do @@ -71,45 +83,55 @@ it do expect(response).to have_http_status(:unprocessable_entity) expect(response_json['errors'].size).to eq(3) - expect(response_json['errors'][0]['status']).to eq('422') - expect(response_json['errors'][0]['code']).to include('invalid') - expect(response_json['errors'][0]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[422]) - expect(response_json['errors'][0]['source']) - .to eq('pointer' => '/data/attributes/title') - expect(response_json['errors'][0]['detail']) - .to eq('Title is invalid') - - expect(response_json['errors'][1]['status']).to eq('422') - - if Rails::VERSION::MAJOR >= 5 - expect(response_json['errors'][1]['code']).to eq('invalid') - else - expect(response_json['errors'][1]['code']).to eq('has_typos') - end - - expect(response_json['errors'][1]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[422]) - expect(response_json['errors'][1]['source']) - .to eq('pointer' => '/data/attributes/title') - expect(response_json['errors'][1]['detail']) - .to eq('Title has typos') - - expect(response_json['errors'][2]['status']).to eq('422') - - if Rails::VERSION::MAJOR >= 5 - expect(response_json['errors'][2]['code']).to eq('less_than') - else - expect(response_json['errors'][2]['code']) - .to eq('must_be_less_than_100') - end - - expect(response_json['errors'][2]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[422]) - expect(response_json['errors'][2]['source']) - .to eq('pointer' => '/data/attributes/quantity') - expect(response_json['errors'][2]['detail']) - .to eq('Quantity must be less than 100') + expect(response_json['errors']).to contain_exactly( + { + 'status' => '422', + 'source' => { 'pointer' => '/data/attributes/title' }, + 'title' => 'Unprocessable Content', + 'detail' => 'Title is invalid', + 'code' => 'invalid', + 'meta' => nil + }, + { + 'status' => '422', + 'source' => { 'pointer' => '/data/attributes/title' }, + 'title' => 'Unprocessable Content', + 'detail' => 'Title has typos', + 'code' => 'invalid', + 'meta' => nil + }, + { + 'status' => '422', + 'source' => { 'pointer' => '/data/attributes/quantity' }, + 'title' => 'Unprocessable Content', + 'detail' => 'Quantity must be less than 100', + 'code' => 'less_than', + 'meta' => nil + } + ) + end + end + + context 'validations with non-interpolated messages' do + let(:params) do + payload = note_params.dup + payload[:data][:attributes][:title] = 'SLURS ARE GREAT' + payload + end + + it do + expect(response).to have_http_status(:unprocessable_entity) + expect(response_json['errors'].size).to eq(1) + expect(response_json['errors']).to contain_exactly( + { + 'status' => '422', + 'source' => { 'pointer' => '' }, + 'title' => 'Unprocessable Content', + 'detail' => 'Title has slurs', + 'code' => 'title_has_slurs', + 'meta' => nil + } + ) end end @@ -124,8 +146,16 @@ it do expect(response).to have_http_status(:unprocessable_entity) - expect(response_json['errors'][0]['source']) - .to eq('pointer' => '/data/attributes/title') + expect(response_json['errors']).to contain_exactly( + { + 'status' => '422', + 'source' => { 'pointer' => '/data/attributes/title' }, + 'title' => 'Unprocessable Content', + 'detail' => nil, + 'code' => nil, + 'meta' => nil + } + ) end end end @@ -137,11 +167,16 @@ it do expect(response).to have_http_status(:not_found) expect(response_json['errors'].size).to eq(1) - expect(response_json['errors'][0]['status']).to eq('404') - expect(response_json['errors'][0]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[404]) - expect(response_json['errors'][0]['source']).to be_nil - expect(response_json['errors'][0]['detail']).to be_nil + expect(response_json['errors']).to contain_exactly( + { + 'status' => '404', + 'source' => nil, + 'title' => 'Not Found', + 'detail' => nil, + 'code' => nil, + 'meta' => nil + } + ) end end @@ -152,11 +187,16 @@ it do expect(response).to have_http_status(:internal_server_error) expect(response_json['errors'].size).to eq(1) - expect(response_json['errors'][0]['status']).to eq('500') - expect(response_json['errors'][0]['title']) - .to eq(Rack::Utils::HTTP_STATUS_CODES[500]) - expect(response_json['errors'][0]['source']).to be_nil - expect(response_json['errors'][0]['detail']).to be_nil + expect(response_json['errors']).to contain_exactly( + { + 'status' => '500', + 'source' => nil, + 'title' => 'Internal Server Error', + 'detail' => nil, + 'code' => nil, + 'meta' => nil + } + ) end end end diff --git a/spec/pagination_spec.rb b/spec/pagination_spec.rb index 879c883..d417274 100644 --- a/spec/pagination_spec.rb +++ b/spec/pagination_spec.rb @@ -17,7 +17,13 @@ it do expect(response_json['data'].size).to eq(0) expect(response_json['meta']) - .to eq('many' => true, 'pagination' => { 'current' => 1 }) + .to eq( + 'many' => true, + 'pagination' => { + 'current' => 1, + 'records' => 0 + } + ) end context 'with users' do @@ -47,12 +53,14 @@ context 'on page 2 out of 3' do let(:as_list) { } + let(:decorate_after_pagination) { } let(:params) do { page: { number: 2, size: 1 }, sort: '-created_at', - as_list: as_list - }.reject { |_k, _v| _v.blank? } + as_list: as_list, + decorate_after_pagination: decorate_after_pagination + }.compact_blank end context 'on an array of resources' do @@ -68,7 +76,27 @@ 'first' => 1, 'prev' => 1, 'next' => 3, - 'last' => 3 + 'last' => 3, + 'records' => 3 + ) + end + end + + context 'when decorating objects after pagination' do + let(:decorate_after_pagination) { true } + + it do + expect(response).to have_http_status(:ok) + expect(response_json['data'].size).to eq(1) + expect(response_json['data'][0]).to have_id(second_user.id.to_s) + + expect(response_json['meta']['pagination']).to eq( + 'current' => 2, + 'first' => 1, + 'prev' => 1, + 'next' => 3, + 'last' => 3, + 'records' => 3 ) end end @@ -83,7 +111,8 @@ 'first' => 1, 'prev' => 1, 'next' => 3, - 'last' => 3 + 'last' => 3, + 'records' => 3 ) expect(response_json).to have_link(:self) @@ -122,7 +151,8 @@ expect(response_json['meta']['pagination']).to eq( 'current' => 3, 'first' => 1, - 'prev' => 2 + 'prev' => 2, + 'records' => 3 ) expect(response_json).to have_link(:self) @@ -148,7 +178,7 @@ { page: { number: 5, size: 1 }, as_list: as_list - }.reject { |_k, _v| _v.blank? } + }.compact_blank end context 'on an array of resources' do @@ -161,7 +191,8 @@ expect(response_json['meta']['pagination']).to eq( 'current' => 5, 'first' => 1, - 'prev' => 4 + 'prev' => 4, + 'records' => 3 ) end end @@ -173,7 +204,8 @@ expect(response_json['meta']['pagination']).to eq( 'current' => 5, 'first' => 1, - 'prev' => 4 + 'prev' => 4, + 'records' => 3 ) expect(response_json).to have_link(:self) @@ -209,7 +241,8 @@ expect(response_json['meta']['pagination']).to eq( 'current' => 1, 'next' => 2, - 'last' => 3 + 'last' => 3, + 'records' => 3 ) expect(response_json).not_to have_link(:prev)