diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 60f056246..5cc998fae 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -13,11 +13,13 @@ jobs: fail-fast: false matrix: ruby: - - '3.3' - '3.2' + - '3.3' + - '3.4' rails: - '7.1' - '7.2' + - '8.0' database_url: - sqlite3:test_db env: @@ -25,7 +27,7 @@ jobs: DATABASE_URL: ${{ matrix.database_url }} name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} DB ${{ matrix.database_url }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.gitignore b/.gitignore index 6cc125d63..ec4e88afd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ coverage test/log test_db test_db-journal +test/test_db-* diff --git a/Gemfile b/Gemfile index 805949eb6..2dc5e6be9 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' gemspec platforms :ruby do - gem 'sqlite3', '>= 1.4' + gem 'sqlite3', '>= 2.1' end platforms :jruby do @@ -17,7 +17,7 @@ when 'master' gem 'railties', { git: 'https://github.com/rails/rails.git' } gem 'arel', { git: 'https://github.com/rails/arel.git' } when 'default' - gem 'railties', '~> 7.1.0' + gem 'railties', '~> 8.0.0' else gem 'railties', "~> #{version}" end diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 9942ddf9e..7b5e13975 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -17,17 +17,20 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 2.1' + spec.required_ruby_version = '>= 3.2' spec.add_development_dependency 'bundler', '>= 1.5', '< 3.0' spec.add_development_dependency 'rake' spec.add_development_dependency 'minitest' + spec.add_development_dependency 'minitest-mock' spec.add_development_dependency 'minitest-spec-rails' + spec.add_development_dependency 'minitest-reporters' spec.add_development_dependency 'simplecov' spec.add_development_dependency 'pry' spec.add_development_dependency 'concurrent-ruby-ext' - spec.add_dependency 'activerecord', '>= 4.1' - spec.add_dependency 'railties', '>= 4.1' + spec.add_dependency 'activerecord', '>= 7.1', '< 8.1' # versions 7.1, 7.2, 8.0 + spec.add_dependency 'railties', '>= 7.1', '< 8.1' # versions 7.1, 7.2, 8.0 + spec.add_dependency 'rack', '~> 3.0' spec.add_dependency 'concurrent-ruby' spec.add_runtime_dependency 'csv' end diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index fc39d93aa..3fafdd2a1 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -15,7 +15,7 @@ def initialize(options = {}) @source = options[:source] @links = options[:links] - @status = Rack::Utils.status_code(options[:status]).to_s + @status = status_code(options[:status]).to_s @meta = options[:meta] end @@ -24,6 +24,21 @@ def to_hash instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } hash end + + private + + # Extracted from Rack 2 + def status_code(status) + if status.nil? + raise ArgumentError, "Status code is required" + end + + if status.is_a?(Symbol) + Rack::Utils::SYMBOL_TO_STATUS_CODE.fetch(status) { raise ArgumentError, "Unrecognized status code #{status.inspect}" } + else + status.to_i + end + end end class Warning diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index cf77dfd12..feabb58ce 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -475,7 +475,7 @@ def errors def json_api_error(attr_key, message) create_error_object(code: JSONAPI::VALIDATION_ERROR, - status: :unprocessable_entity, + status: :unprocessable_content, title: message, detail: "#{format_key(attr_key)} - #{message}", source: { pointer: pointer(attr_key) }, @@ -500,7 +500,7 @@ def pointer(attr_or_relationship_name) class SaveFailed < Error def errors [create_error_object(code: JSONAPI::SAVE_FAILED, - status: :unprocessable_entity, + status: :unprocessable_content, title: I18n.translate('jsonapi-resources.exceptions.save_failed.title', default: 'Save failed or was cancelled'), detail: I18n.translate('jsonapi-resources.exceptions.save_failed.detail', diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index c7e0d6efa..aa34e4f31 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -1,5 +1,6 @@ require 'jsonapi/operation' require 'jsonapi/paginator' +require 'cgi' module JSONAPI class RequestParser @@ -272,7 +273,7 @@ def parse_sort_criteria(sort_criteria) sorts = [] begin - raw = URI.unescape(sort_criteria) + raw = CGI.unescape(sort_criteria) sorts += CSV.parse_line(raw) rescue CSV::MalformedCSVError fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(@resource_klass._type), raw) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index efaae4635..05eff7925 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -511,7 +511,7 @@ def attribute(attr, options = {}) check_reserved_attribute_name(attr) if (attr.to_sym == :id) && (options[:format].nil?) - ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') + ActiveSupport::Deprecation.new(nil, 'JSONAPI').warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') end check_duplicate_attribute_name(attr) if options[:format].nil? diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 07ba6bd5f..d9a2c2bad 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.1.2' + VERSION = '0.1.3' end end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 7bdf19c2d..48cde639a 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -483,7 +483,7 @@ def test_show_does_not_include_records_count_in_meta JSONAPI.configuration.top_level_meta_include_record_count = true assert_cacheable_get :show, params: { id: Post.first.id } assert_response :success - assert_equal json_response['meta'], nil + assert_nil json_response['meta'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end @@ -492,7 +492,7 @@ def test_show_does_not_include_pages_count_in_meta JSONAPI.configuration.top_level_meta_include_page_count = true assert_cacheable_get :show, params: { id: Post.first.id } assert_response :success - assert_equal json_response['meta'], nil + assert_nil json_response['meta'] ensure JSONAPI.configuration.top_level_meta_include_page_count = false end @@ -596,7 +596,7 @@ def test_create_simple_id_not_allowed assert_response :bad_request assert_match /id is not allowed/, response.body - assert_equal nil,response.location + assert_nil response.location end def test_create_link_to_missing_object @@ -615,10 +615,10 @@ def test_create_link_to_missing_object } } - assert_response :unprocessable_entity + assert_response :unprocessable_content # TODO: check if this validation is working assert_match /author - can't be blank/, response.body - assert_equal nil, response.location + assert_nil response.location end def test_create_extra_param @@ -640,7 +640,7 @@ def test_create_extra_param assert_response :bad_request assert_match /asdfg is not allowed/, response.body - assert_equal nil,response.location + assert_nil response.location end def test_create_extra_param_allow_extra_params @@ -698,7 +698,7 @@ def test_create_with_invalid_data } } - assert_response :unprocessable_entity + assert_response :unprocessable_content assert_equal "/data/relationships/author", json_response['errors'][0]['source']['pointer'] assert_equal "can't be blank", json_response['errors'][0]['title'] @@ -707,7 +707,7 @@ def test_create_with_invalid_data assert_equal "/data/attributes/title", json_response['errors'][1]['source']['pointer'] assert_equal "is too long (maximum is 35 characters)", json_response['errors'][1]['title'] assert_equal "title - is too long (maximum is 35 characters)", json_response['errors'][1]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_multiple @@ -760,7 +760,7 @@ def test_create_simple_missing_posts assert_response :bad_request assert_match /The required parameter, data, is missing./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_wrong_type @@ -781,7 +781,7 @@ def test_create_simple_wrong_type assert_response :bad_request assert_match /posts_spelled_wrong is not a valid resource./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_missing_type @@ -801,7 +801,7 @@ def test_create_simple_missing_type assert_response :bad_request assert_match /The required parameter, type, is missing./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_unpermitted_attributes @@ -822,7 +822,7 @@ def test_create_simple_unpermitted_attributes assert_response :bad_request assert_match /subject/, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_unpermitted_attributes_allow_extra_params @@ -1086,7 +1086,7 @@ def test_update_remove_links assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '3', json_response['data']['relationships']['author']['data']['id'] - assert_equal nil, json_response['data']['relationships']['section']['data'] + assert_nil json_response['data']['relationships']['section']['data'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] assert matches_array?([], @@ -1116,7 +1116,7 @@ def test_update_relationship_to_one_nil assert_response :no_content post_object = Post.find(4) - assert_equal nil, post_object.section_id + assert_nil post_object.section_id end def test_update_relationship_to_one_invalid_links_hash_keys_ids @@ -1233,7 +1233,7 @@ def test_update_relationship_to_one_singular_param_id_nil put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections', id: nil}} assert_response :no_content - assert_equal nil, post_object.reload.section_id + assert_nil post_object.reload.section_id end def test_update_relationship_to_one_data_nil @@ -1246,7 +1246,7 @@ def test_update_relationship_to_one_data_nil put :update_relationship, params: {post_id: 3, relationship: 'section', data: nil} assert_response :no_content - assert_equal nil, post_object.reload.section_id + assert_nil post_object.reload.section_id end def test_remove_relationship_to_one @@ -1260,7 +1260,7 @@ def test_remove_relationship_to_one assert_response :no_content post_object = Post.find(3) - assert_equal nil, post_object.section_id + assert_nil post_object.section_id end def test_update_relationship_to_one_singular_param @@ -1832,7 +1832,7 @@ def test_delete_with_validation_error delete :destroy, params: { id: post.id } assert_equal "can't destroy me", json_response['errors'][0]['title'] - assert_response :unprocessable_entity + assert_response :unprocessable_content end def test_delete_single @@ -2380,7 +2380,7 @@ def test_create_validations_missing_attribute } } - assert_response :unprocessable_entity + assert_response :unprocessable_content assert_equal 2, json_response['errors'].size assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][1]['code'] @@ -2402,7 +2402,7 @@ def test_update_validations_missing_attribute } } - assert_response :unprocessable_entity + assert_response :unprocessable_content assert_equal 1, json_response['errors'].size assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] assert_match /name - can't be blank/, response.body @@ -2546,7 +2546,7 @@ def test_get_person_as_author assert_equal '1', json_response['data'][0]['id'] assert_equal 'authors', json_response['data'][0]['type'] assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] - assert_equal nil, json_response['data'][0]['attributes']['email'] + assert_nil json_response['data'][0]['attributes']['email'] end def test_show_person_as_author @@ -2555,7 +2555,7 @@ def test_show_person_as_author assert_equal '1', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] assert_equal 'Joe Author', json_response['data']['attributes']['name'] - assert_equal nil, json_response['data']['attributes']['email'] + assert_nil json_response['data']['attributes']['email'] end def test_get_person_as_author_by_name_filter @@ -2854,7 +2854,7 @@ def test_create_with_invalid_data } } - assert_response :unprocessable_entity + assert_response :unprocessable_content assert_equal "/data/attributes/spouse-name", json_response['errors'][0]['source']['pointer'] assert_equal "can't be blank", json_response['errors'][0]['title'] @@ -3402,7 +3402,7 @@ def test_save_model_callbacks_fail } } - assert_response :unprocessable_entity + assert_response :unprocessable_content assert_match /Save failed or was cancelled/, json_response['errors'][0]['detail'] end end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 9a958fa2e..d595785ea 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -350,7 +350,7 @@ class Post < ActiveRecord::Base has_many :comments has_and_belongs_to_many :tags, join_table: :posts_tags has_many :special_post_tags - has_many :special_tags, through: :special_post_tags + has_many :special_tags, through: :special_post_tags, source: :tag belongs_to :section has_one :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 31821f8fd..f6ff20e7c 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -256,6 +256,9 @@ def test_post_single_minimal_invalid } assert_jsonapi_response 422 + assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] + assert_equal '422', json_response['errors'][0]['status'] + assert_match "can't be blank", json_response['errors'][0]['title'] end def test_update_relationship_without_content_type diff --git a/test/test_helper.rb b/test/test_helper.rb index 206fe8bd6..a74f4a18e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -20,6 +20,8 @@ require 'active_record/railtie' require 'minitest/mock' +require 'minitest/autorun' +require 'minitest/reporters' require 'jsonapi-resources' require 'pry' @@ -28,6 +30,8 @@ require File.expand_path('../helpers/functional_helpers', __FILE__) require File.expand_path('../helpers/configuration_helpers', __FILE__) +Minitest::Reporters.use! + Rails.env = 'test' I18n.load_path += Dir[File.expand_path("../../locales/*.yml", __FILE__)] @@ -56,6 +60,11 @@ class TestApp < Rails::Application config.active_record.time_zone_aware_types = [:time, :datetime] config.active_record.belongs_to_required_by_default = false end + + # Use a modern cache serialization format to avoid Rails 7.2+ deprecations. + if Rails::VERSION::MAJOR >= 7 && config.active_support.respond_to?(:cache_format_version=) + config.active_support.cache_format_version = 7.1 + end end module MyEngine @@ -73,9 +82,9 @@ class Engine < ::Rails::Engine # Monkeypatch ActionController::TestCase to delete the RAW_POST_DATA on subsequent calls in the same test. if Rails::VERSION::MAJOR >= 5 module ClearRawPostHeader - def process(action, *args) + def process(action, *args, **kwargs) @request.delete_header 'RAW_POST_DATA' - super + super(action, *args, **kwargs) end end @@ -484,13 +493,13 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end class ActionController::TestCase - def assert_cacheable_get(action, *args) + def assert_cacheable_get(action, **request_options) assert_nil JSONAPI.configuration.resource_cache normal_queries = [] normal_query_callback = lambda {|_, _, _, _, payload| normal_queries.push payload[:sql] } ActiveSupport::Notifications.subscribed(normal_query_callback, 'sql.active_record') do - get action, *args + get action, **request_options end non_caching_response = json_response_sans_backtraces non_caching_status = response.status @@ -524,7 +533,7 @@ def assert_cacheable_get(action, *args) @controller = nil setup_controller_request_and_response @request.headers.merge!(orig_request_headers.dup) - get action, *args + get action, **request_options end end rescue Exception diff --git a/test/unit/jsonapi_request/jsonapi_error_test.rb b/test/unit/jsonapi_request/jsonapi_error_test.rb new file mode 100644 index 000000000..801b8204d --- /dev/null +++ b/test/unit/jsonapi_request/jsonapi_error_test.rb @@ -0,0 +1,45 @@ +require File.expand_path('../../../test_helper', __FILE__) + +class JSONAPIErrorTest < Minitest::Test + def test_status_code_requires_status + error = assert_raises(ArgumentError) do + JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST) + end + + assert_equal('Status code is required', error.message) + end + + def test_status_code_accepts_symbol + error = JSONAPI::Error.new(code: JSONAPI::VALIDATION_ERROR, status: :unprocessable_content) + + assert_equal('422', error.status) + end + + def test_status_code_accepts_integer + error = JSONAPI::Error.new(code: JSONAPI::VALIDATION_ERROR, status: 422) + + assert_equal('422', error.status) + end + + def test_status_code_accepts_string + error = JSONAPI::Error.new(code: JSONAPI::VALIDATION_ERROR, status: '422') + + assert_equal('422', error.status) + end + + def test_status_code_rejects_unknown_symbol + error = assert_raises(ArgumentError) do + JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, status: :not_a_real_status) + end + + assert_equal('Unrecognized status code :not_a_real_status', error.message) + end + + def test_status_code_rejects_nil + error = assert_raises(ArgumentError) do + JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, status: nil) + end + + assert_equal('Status code is required', error.message) + end +end diff --git a/test/unit/operation/operation_dispatcher_test.rb b/test/unit/operation/operation_dispatcher_test.rb index 278ebd6dd..f7816e583 100644 --- a/test/unit/operation/operation_dispatcher_test.rb +++ b/test/unit/operation/operation_dispatcher_test.rb @@ -85,7 +85,7 @@ def test_replace_to_one_relationship op.process(operations) saturn.reload - assert_equal(saturn.planet_type_id, nil) + assert_nil(saturn.planet_type_id) # Reset operations = [ diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 83c417c61..46c19b80b 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -13,7 +13,7 @@ class PostWithBadAfterSave < ActiveRecord::Base after_save :do_some_after_save_stuff def do_some_after_save_stuff - errors[:base] << 'Boom! Error added in after_save callback.' + errors.add(:base, 'Boom! Error added in after_save callback.') raise ActiveRecord::RecordInvalid.new(self) end end @@ -23,7 +23,7 @@ class PostWithCustomValidationContext < ActiveRecord::Base validate :api_specific_check, on: :json_api_create def api_specific_check - errors[:base] << 'Record is invalid' + errors.add(:base, 'Record is invalid') end end diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index e1062280b..0935474ae 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -39,8 +39,7 @@ def test_engine_name primary_resource_klass: ApiV2Engine::PersonResource ).engine_name - assert_equal nil, - JSONAPI::LinkBuilder.new( + assert_nil JSONAPI::LinkBuilder.new( primary_resource_klass: Api::V1::PersonResource ).engine_name end