diff --git a/.gitignore b/.gitignore index aa3d12f..39b02a7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,3 @@ .byebug_history test_db Gemfile.lock - diff --git a/README.md b/README.md index 98e4e05..e39d817 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ gem 'jsonapi-utils', '~> 0.4.9' For Rails 5: ```ruby -gem 'jsonapi-utils', '~> 0.6.0.beta' +gem 'jsonapi-utils', '~> 0.7.0' ``` And then execute: @@ -110,8 +110,8 @@ end Arguments: - - `json`: object to be rendered as a JSON document: ActiveRecord object, Hash or Array of Hashes; - - `status`: HTTP status code (Integer or Symbol). If ommited a status code will be automatically infered; + - `json`: object to be rendered as a JSON document: ActiveRecord object, Hash or Array; + - `status`: HTTP status code (Integer, String or Symbol). If ommited a status code will be automatically infered; - `options`: - `resource`: explicitly points the resource to be used in the serialization. By default, JU will select resources by inferencing from controller's name. - `count`: explicitly points the total count of records for the request in order to build a proper pagination. By default, JU will count the total number of records. @@ -155,8 +155,8 @@ It renders a JSON API-compliant error response. Arguments: - Exception - - `json`: object to be rendered as a JSON document: ActiveRecord, Exception, Array of Hashes or any object which implements the `errors` method; - - `status`: HTTP status code (Integer or Symbol). If ommited a status code will be automatically infered from the error body. + - `json`: object to be rendered as a JSON document: ActiveRecord, Exception, Array or any object which implements the `errors` method; + - `status`: HTTP status code (Integer, String or Symbol). If ommited a status code will be automatically infered from the error body. Other examples: @@ -164,7 +164,7 @@ Other examples: # Render errors from a custom exception: jsonapi_render_errors Exceptions::MyCustomError.new(user) -# Render errors from an Array of Hashes: +# Render errors from an Array: errors = [{ id: 'validation', title: 'Something went wrong', code: '100' }] jsonapi_render_errors json: errors, status: :unprocessable_entity ``` @@ -188,7 +188,7 @@ end ``` Arguments: - - First: ActiveRecord object, Hash or Array of Hashes; + - First: ActiveRecord object, Hash or Array; - Last: Hash of options (same as `JSONAPI::Utils#jsonapi_render`). #### Paginators diff --git a/lib/jsonapi/utils/exceptions.rb b/lib/jsonapi/utils/exceptions.rb index fb5faa1..b243c76 100644 --- a/lib/jsonapi/utils/exceptions.rb +++ b/lib/jsonapi/utils/exceptions.rb @@ -1,126 +1,2 @@ -require 'jsonapi/utils/version' - -module JSONAPI - module Utils - module Exceptions - class ActiveRecord < ::JSONAPI::Exceptions::Error - attr_reader :object, :resource, :relationships, :relationship_keys, :foreign_keys - - def initialize(object, resource_klass, context) - @object = object - @resource = resource_klass.new(object, context) - - # Need to reflect on resource's relationships for error reporting. - @relationships = resource_klass._relationships.values - @relationship_keys = @relationships.map(&:name).map(&:to_sym) - @foreign_keys = @relationships.map(&:foreign_key).map(&:to_sym) - end - - def errors - object.errors.messages.flat_map do |key, messages| - messages.map do |message| - error_meta = error_base - .merge(title: title_member(key, message)) - .merge(id: id_member(key)) - .merge(source_member(key)) - - JSONAPI::Error.new(error_meta) - end - end - end - - private - - def id_member(key) - id = resource_key_for(key) - key_formatter = JSONAPI.configuration.key_formatter - key_formatter.format(id).to_sym - end - - # Determine if this is a foreign key, which will need to look up its - # matching association name. - def resource_key_for(key) - if foreign_keys.include?(key) - relationships.find { |r| r.foreign_key == key }.name.to_sym - else - key - end - end - - def source_member(key) - Hash.new.tap do |hash| - resource_key = resource_key_for(key) - - # Pointer should only be created for whitelisted attributes. - return hash unless resource.fetchable_fields.include?(resource_key) || key == :base - - id = id_member(key) - - hash[:source] = {} - hash[:source][:pointer] = - # Relationship - if relationship_keys.include?(resource_key) - "/data/relationships/#{id}" - # Base - elsif key == :base - '/data' - # Attribute - else - "/data/attributes/#{id}" - end - end - end - - def title_member(key, message) - if key == :base - message - else - resource_key = resource_key_for(key) - [translation_for(resource_key), message].join(' ') - end - end - - def translation_for(key) - object.class.human_attribute_name(key) - end - - def error_base - { - code: JSONAPI::VALIDATION_ERROR, - status: :unprocessable_entity - } - end - end - - class BadRequest < ::JSONAPI::Exceptions::Error - def code - '400' - end - - def errors - [JSONAPI::Error.new( - code: code, - status: :bad_request, - title: 'Bad Request', - detail: 'This request is not supported.' - )] - end - end - - class InternalServerError < ::JSONAPI::Exceptions::Error - def code - '500' - end - - def errors - [JSONAPI::Error.new( - code: code, - status: :internal_server_error, - title: 'Internal Server Error', - detail: 'An internal error ocurred while processing the request.' - )] - end - end - end - end -end +require 'jsonapi/utils/exceptions/active_record' +require 'jsonapi/utils/exceptions/internal_server_error' diff --git a/lib/jsonapi/utils/exceptions/active_record.rb b/lib/jsonapi/utils/exceptions/active_record.rb new file mode 100644 index 0000000..2c1b036 --- /dev/null +++ b/lib/jsonapi/utils/exceptions/active_record.rb @@ -0,0 +1,197 @@ +module JSONAPI + module Utils + module Exceptions + class ActiveRecord < ::JSONAPI::Exceptions::Error + attr_reader :object, :resource, :relationships, :relationship_names, :foreign_keys + + # Construct an error decorator over ActiveRecord objects. + # + # @param object [ActiveRecord::Base] Invalid ActiveRecord object. + # e.g.: User.new(name: nil).tap(&:save) + # + # @param resource_klass [JSONAPI::Resource] Resource class to be used for reflection. + # e.g.: UserResuource + # + # @return [JSONAPI::Utils::Exceptions::ActiveRecord] + # + # @api public + def initialize(object, resource_klass, context) + @object = object + @resource = resource_klass.new(object, context) + + # Need to reflect on resource's relationships for error reporting. + @relationships = resource_klass._relationships.values + @relationship_names = @relationships.map(&:name).map(&:to_sym) + @foreign_keys = @relationships.map(&:foreign_key).map(&:to_sym) + @resource_key_for = {} + @formatted_key = {} + end + + # Decorate errors for AR invalid objects. + # + # @note That's the method used by formatters to build the response's error body. + # + # @return [Array] + # + # @api public + def errors + object.errors.messages.flat_map do |field, messages| + messages.map.with_index do |message, index| + build_error(field, message, index) + end + end + end + + private + + # Turn AR error into JSONAPI::Error. + # + # @param field [Symbol] Name of the invalid field + # e.g.: :title + # + # @param message [String] Error message + # e.g.: "can't be blank" + # + # @param index [Integer] Index of the error detail + # + # @return [JSONAPI::Error] + # + # @api private + def build_error(field, message, index = 0) + error = error_base + .merge( + id: id_member(field, index), + title: message, + detail: detail_member(field, message) + ).merge(source_member(field)) + JSONAPI::Error.new(error) + end + + # Build the "id" member value for the JSON API error object. + # e.g.: for :first_name, :too_short => "first-name#too-short" + # + # @note The returned value depends on the key formatter type defined + # via configuration, e.g.: config.json_key_format = :dasherized_key + # + # @param field [Symbol] Name of the invalid field + # e.g.: :first_name + # + # @param index [Integer] Index of the error detail + # + # @return [String] + # + # @api private + def id_member(field, index) + [ + key_format(field), + key_format( + object.errors.details + .dig(field, index, :error) + .to_s.downcase + .split + .join('_') + ) + ].join('#') + end + + # Bring the formatted resource key for a given field. + # e.g.: for :first_name => :"first-name" + # + # @note The returned value depends on the key formatter type defined + # via configuration, e.g.: config.json_key_format = :dasherized_key + # + # @param field [Symbol] Name of the invalid field + # e.g.: :title + # + # @return [Symbol] + # + # @api private + def key_format(field) + @formatted_key[field] ||= JSONAPI.configuration + .key_formatter + .format(resource_key_for(field)) + .to_sym + end + + # Build the "source" member value for the JSON API error object. + # e.g.: :title => "/data/attributes/title" + # + # @param field [Symbol] Name of the invalid field + # e.g.: :title + # + # @return [Hash] + # + # @api private + def source_member(field) + resource_key = resource_key_for(field) + return {} unless field == :base || resource.fetchable_fields.include?(resource_key) + id = key_format(field) + + pointer = + if field == :base then '/data' + elsif relationship_names.include?(resource_key) then "/data/relationships/#{id}" + else "/data/attributes/#{id}" + end + + { source: { pointer: pointer } } + end + + # Build the "detail" member value for the JSON API error object. + # e.g.: :first_name, "can't be blank" => "First name can't be blank" + # + # @param field [Symbol] Name of the invalid field + # e.g.: :first_name + # + # @return [String] + # + # @api private + def detail_member(field, message) + return message if field == :base + resource_key = resource_key_for(field) + [translation_for(resource_key), message].join(' ') + end + + # Return the resource's attribute or relationship key name for a given field name. + # e.g.: :title => :title, :user_id => :author + # + # @param field [Symbol] Name of the invalid field + # e.g.: :title + # + # @return [Symbol] + # + # @api private + def resource_key_for(field) + @resource_key_for[field] ||= begin + return field unless foreign_keys.include?(field) + relationships.find { |r| r.foreign_key == field }.name.to_sym + end + end + + # Turn the field name into human-friendly one. + # e.g.: :first_name => "First name" + # + # @param field [Symbol] Name of the invalid field + # e.g.: :first_name + # + # @return [String] + # + # @api private + def translation_for(field) + object.class.human_attribute_name(field) + end + + # Return the base data used for all errors of this kind. + # + # @return [Hash] + # + # @api private + def error_base + { + code: JSONAPI::VALIDATION_ERROR, + status: :unprocessable_entity + } + end + end + end + end +end diff --git a/lib/jsonapi/utils/exceptions/internal_server_error.rb b/lib/jsonapi/utils/exceptions/internal_server_error.rb new file mode 100644 index 0000000..87d44d1 --- /dev/null +++ b/lib/jsonapi/utils/exceptions/internal_server_error.rb @@ -0,0 +1,30 @@ +module JSONAPI + module Utils + module Exceptions + class InternalServerError < ::JSONAPI::Exceptions::Error + # HTTP status code + # + # @return [String] + # + # @api public + def code + '500' + end + + # Decorate errors for 500 responses. + # + # @return [Array] + # + # @api public + def errors + [JSONAPI::Error.new( + code: code, + status: :internal_server_error, + title: 'Internal Server Error', + detail: 'An internal error ocurred while processing the request.' + )] + end + end + end + end +end diff --git a/lib/jsonapi/utils/response/formatters.rb b/lib/jsonapi/utils/response/formatters.rb index f8d3dde..460e3a0 100644 --- a/lib/jsonapi/utils/response/formatters.rb +++ b/lib/jsonapi/utils/response/formatters.rb @@ -2,72 +2,168 @@ module JSONAPI module Utils module Response module Formatters - def jsonapi_format(records, options = {}) - if records.is_a?(Hash) - hash = records.with_indifferent_access - records = hash_to_active_record(hash[:data], options[:model]) + # Helper method to format ActiveRecord or Hash objects into JSON API-compliant ones. + # + # @note The return of this method represents what will actually be displayed in the response body. + # @note It can also be called as #jsonapi_serialize due to backward compatibility issues. + # + # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] + # Object to be formatted into JSON + # e.g.: User.first, User.all, { data: { id: 1, first_name: 'Tiago' } }, + # [{ data: { id: 1, first_name: 'Tiago' } }] + # + # @option options [JSONAPI::Resource] resource: it tells the formatter which resource + # class to be used rather than use an infered one (default behaviour) + # + # @option options [ActiveRecord::Base] model: ActiveRecord model class to be instantiated + # when a Hash or Array of Hashes is passed as the "object" argument + # + # @option options [Integer] count: if it's rendering a collection of resources, the default + # gem's counting method can be bypassed by the use of this options. It's shows then the total + # records resulting from that request and also calculates the pagination. + # + # @return [Hash] + # + # @api public + def jsonapi_format(object, options = {}) + if object.is_a?(Hash) + hash = object.with_indifferent_access + object = hash_to_active_record(hash[:data], options[:model]) end - fix_request_options(params, records) - build_response_document(records, options).contents + fix_custom_request_options(object) + build_response_document(object, options).contents end alias_method :jsonapi_serialize, :jsonapi_format - def jsonapi_format_errors(data) - data = JSONAPI::Utils::Exceptions::ActiveRecord.new(data, @request.resource_klass, context) if active_record_obj?(data) - errors = data.respond_to?(:errors) ? data.errors : data + # Helper method to format ActiveRecord or any object that responds to #errors + # into JSON API-compliant error response bodies. + # + # @note The return of this method represents what will actually be displayed in the response body. + # @note It can also be called as #jsonapi_serialize_errors due to backward compatibility issues. + # + # @param object [ActiveRecord::Base or any object that responds to #errors] + # Error object to be serialized into JSON + # e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object) + # + # @return [Array] + # + # @api public + def jsonapi_format_errors(object) + if active_record_obj?(object) + object = JSONAPI::Utils::Exceptions::ActiveRecord.new(object, @request.resource_klass, context) + end + errors = object.respond_to?(:errors) ? object.errors : object JSONAPI::Utils::Support::Error.sanitize(errors).uniq end + alias_method :jsonapi_serialize_errors, :jsonapi_format_errors + private - def active_record_obj?(data) - defined?(ActiveRecord::Base) && (data.is_a?(ActiveRecord::Base) || data.singleton_class.include?(ActiveModel::Model)) + # Check whether the given object is an ActiveRecord-like one. + # + # @param object [Object] Object to be checked + # + # @return [TrueClass, FalseClass] + # + # @api private + def active_record_obj?(object) + defined?(ActiveRecord::Base) && + (object.is_a?(ActiveRecord::Base) || + object.singleton_class.include?(ActiveModel::Model)) end - def build_response_document(records, options) + # Build the full response document. + # + # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] + # Object to be formatted into JSON + # e.g.: User.first, User.all, { data: { id: 1, first_name: 'Tiago' } }, + # [{ data: { id: 1, first_name: 'Tiago' } }] + # + # @option options [JSONAPI::Resource] resource: it tells the builder which resource + # class to be used rather than use an infered one (default behaviour) + # + # @option options [Integer] count: if it's rendering a collection of resources, the default + # gem's counting method can be bypassed by the use of this options. It's shows then the total + # records resulting from that request and also calculates the pagination. + # + # @return [JSONAPI::ResponseDocument] + # + # @api private + def build_response_document(object, options) results = JSONAPI::OperationResults.new - if records.respond_to?(:to_ary) - @_records = build_collection(records, options) - results.add_result(JSONAPI::ResourcesOperationResult.new(:ok, @_records, result_options(records, options))) + if object.respond_to?(:to_ary) + records = build_collection(object, options) + results.add_result(JSONAPI::ResourcesOperationResult.new(:ok, records, result_options(object, options))) else - @_record = turn_into_resource(records, options) - results.add_result(JSONAPI::ResourceOperationResult.new(:ok, @_record)) + record = turn_into_resource(object, options) + results.add_result(JSONAPI::ResourceOperationResult.new(:ok, record)) end @_response_document = create_response_document(results) end - def fix_request_options(params, records) - return if request.method !~ /get/i || - params.nil? || - %w(index show create update destroy).include?(params[:action]) - action = records.respond_to?(:to_ary) ? 'index' : 'show' + # Apply a proper action setup for custom requests/actions. + # + # @note The setup_(index|show)_action comes from JSONAPI::Resources' API. + # + # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] + # It's checked whether this object refers to a collection or not. + # + # @api private + def fix_custom_request_options(object) + return unless custom_get_request_with_params? + action = object.respond_to?(:to_ary) ? 'index' : 'show' @request.send("setup_#{action}_action", params) end - def result_options(records, options) - {}.tap do |data| - if JSONAPI.configuration.default_paginator != :none && - JSONAPI.configuration.top_level_links_include_pagination - data[:pagination_params] = pagination_params(records, options) - end - - if JSONAPI.configuration.top_level_meta_include_record_count - data[:record_count] = count_records(records, options) - end - end + # Check whether it's a custom GET request with params. + # + # @return [TrueClass, FalseClass] + # + # @api private + def custom_get_request_with_params? + request.method =~ /get/i && !%w(index show).include?(params[:action]) && !params.nil? end - def build_collection(records, options = {}) + # Turn a collection of AR or Hash objects into a collection of JSONAPI::Resource ones. + # + # @param records [ActiveRecord::Relation, Hash, Array] + # Objects to be instantiated as JSONAPI::Resource ones. + # e.g.: User.all, [{ data: { id: 1, first_name: 'Tiago' } }] + # + # @option options [JSONAPI::Resource] resource: it tells the buider which resource + # class to be used rather than use an infered one (default behaviour) + # + # @option options [Integer] count: if it's rendering a collection of resources, the default + # gem's counting method can be bypassed by the use of this options. It's shows then the total + # records resulting from that request and also calculates the pagination. + # + # @return [Array] + # + # @api private + def build_collection(records, options) records = apply_filter(records, options) - records = apply_pagination(records, options) records = apply_sort(records) + records = apply_pagination(records, options) records.respond_to?(:to_ary) ? records.map { |record| turn_into_resource(record, options) } : [] end - def turn_into_resource(record, options = {}) + # Turn an AR or Hash object into a JSONAPI::Resource one. + # + # @param records [ActiveRecord::Relation, Hash, Array] + # Object to be instantiated as a JSONAPI::Resource one. + # e.g.: User.first, { data: { id: 1, first_name: 'Tiago' } } + # + # @option options [JSONAPI::Resource] resource: it tells which resource + # class to be used rather than use an infered one (default behaviour) + # + # @return [JSONAPI::Resource] + # + # @api private + def turn_into_resource(record, options) if options[:resource] options[:resource].to_s.constantize.new(record, context) else @@ -75,6 +171,44 @@ def turn_into_resource(record, options = {}) end end + # Apply some result options like pagination params and count to a collection response. + # + # @param records [ActiveRecord::Relation, Hash, Array] + # Object to be formatted into JSON + # e.g.: User.all, [{ data: { id: 1, first_name: 'Tiago' } }] + # + # @option options [Integer] count: if it's rendering a collection of resources, the default + # gem's counting method can be bypassed by the use of this options. It's shows then the total + # records resulting from that request and also calculates the pagination. + # + # @return [Hash] + # + # @api private + def result_options(records, options) + {}.tap do |data| + if JSONAPI.configuration.default_paginator != :none && + JSONAPI.configuration.top_level_links_include_pagination + data[:pagination_params] = pagination_params(records, options) + end + + if JSONAPI.configuration.top_level_meta_include_record_count + data[:record_count] = count_records(records, options) + end + end + end + + # Convert Hash or collection of Hashes into AR objects. + # + # @param data [Hash, Array] Hash or collection to be converted + # e.g.: { data: { id: 1, first_name: 'Tiago' } }, + # [{ data: { id: 1, first_name: 'Tiago' } }], + # + # @option options [ActiveRecord::Base] model: ActiveRecord model class to be + # used as base for the objects' intantialization. + # + # @return [ActiveRecord::Base, ActiveRecord::Relation] + # + # @api private def hash_to_active_record(data, model) return data if model.nil? coerced = [data].flatten.map { |hash| model.new(hash) } diff --git a/lib/jsonapi/utils/response/renders.rb b/lib/jsonapi/utils/response/renders.rb index b7d6e92..7777edf 100644 --- a/lib/jsonapi/utils/response/renders.rb +++ b/lib/jsonapi/utils/response/renders.rb @@ -2,36 +2,87 @@ module JSONAPI module Utils module Response module Renders + # Helper method to render JSON API-compliant responses. + # + # @param json [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] + # Object to be serialized into JSON + # e.g.: User.first, User.all, { data: { id: 1, first_name: 'Tiago' } }, + # [{ data: { id: 1, first_name: 'Tiago' } }] + # + # @param status [Integer, String, Symbol] HTTP status code + # e.g.: 201, '201', :created + # + # @option options [JSONAPI::Resource] resource: it tells the render which resource + # class to be used rather than use an infered one (default behaviour) + # + # @option options [ActiveRecord::Base] model: ActiveRecord model class to be instantiated + # when a Hash or Array of Hashes is passed to the "json" key argument + # + # @option options [Integer] count: if it's rendering a collection of resources, the default + # gem's counting method can be bypassed by the use of this options. It's shows then the total + # records resulting from that request and also calculates the pagination. + # + # @return [String] + # + # @api public def jsonapi_render(json:, status: nil, options: {}) body = jsonapi_format(json, options) - render json: body, status: status || @_response_document.status + render json: body, status: (status || @_response_document.status) rescue => e - handle_exceptions(e) + handle_exceptions(e) # http://bit.ly/2sEEGTN ensure correct_media_type end - def jsonapi_render_errors(exception = nil, json: nil, status: nil) - body = jsonapi_format_errors(exception || json) - status = status || body.try(:first).try(:[], :status) + # Helper method to render JSON API-compliant error responses. + # + # @param error [ActiveRecord::Base or any object that responds to #errors] + # Error object to be serialized into JSON + # e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object) + # + # @param json [ActiveRecord::Base or any object that responds to #errors] + # Error object to be serialized into JSON + # e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object) + # + # @param status [Integer, String, Symbol] HTTP status code + # e.g.: 422, '422', :unprocessable_entity + # + # @return [String] + # + # @api public + def jsonapi_render_errors(error = nil, json: nil, status: nil) + body = jsonapi_format_errors(error || json) + status = status || body.try(:first).try(:[], :status) || :bad_request render json: { errors: body }, status: status ensure correct_media_type end + # Helper method to render HTTP 500 Interval Server Error. + # + # @api public def jsonapi_render_internal_server_error jsonapi_render_errors(::JSONAPI::Utils::Exceptions::InternalServerError.new) end + # Helper method to render HTTP 400 Bad Request. + # + # @api public def jsonapi_render_bad_request jsonapi_render_errors(::JSONAPI::Utils::Exceptions::BadRequest.new) end + # Helper method to render HTTP 404 Bad Request. + # + # @api public def jsonapi_render_not_found(exception) - id = exception.message.match(/=([\w-]+)/).try(:[], 1) || '(no identifier)' + id = exception.message =~ /=([\w-]+)/ && $1 || '(no identifier)' jsonapi_render_errors(JSONAPI::Exceptions::RecordNotFound.new(id)) end + # Helper method to render HTTP 404 Bad Request with null "data". + # + # @api public def jsonapi_render_not_found_with_null render json: { data: nil }, status: 200 end diff --git a/lib/jsonapi/utils/version.rb b/lib/jsonapi/utils/version.rb index 6a9159f..436b181 100644 --- a/lib/jsonapi/utils/version.rb +++ b/lib/jsonapi/utils/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Utils - VERSION = '0.6.0'.freeze + VERSION = '0.7.0'.freeze end end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 3ee566a..aeef1f4 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -236,8 +236,9 @@ it 'renders a 422 response' do expect { subject }.to change(Post, :count).by(0) expect(response).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('title') - expect(errors.dig(0, 'title')).to eq('Title can\'t be blank') + expect(errors.dig(0, 'id')).to eq('title#blank') + expect(errors.dig(0, 'title')).to eq('can\'t be blank') + expect(errors.dig(0, 'detail')).to eq('Title can\'t be blank') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/title') end @@ -254,8 +255,9 @@ expect { subject }.to change(Post, :count).by(0) expect(subject).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('author') - expect(errors.dig(0, 'title')).to eq('Author can\'t be blank') + expect(errors.dig(0, 'id')).to eq('author#blank') + expect(errors.dig(0, 'title')).to eq('can\'t be blank') + expect(errors.dig(0, 'detail')).to eq('Author can\'t be blank') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data/relationships/author') end @@ -272,8 +274,9 @@ expect { subject }.to change(Post, :count).by(0) expect(subject).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('category') - expect(errors.dig(0, 'title')).to eq('Category can\'t be blank') + expect(errors.dig(0, 'id')).to eq('category#blank') + expect(errors.dig(0, 'title')).to eq('can\'t be blank') + expect(errors.dig(0, 'detail')).to eq('Category can\'t be blank') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data/relationships/category') end @@ -283,15 +286,16 @@ subject { post :create, params: params.merge(invalid_body) } let(:invalid_body) do - body.tap { |b| b[:data][:attributes][:title] = 'Fail Hidden' } + body.tap { |body| body[:data][:attributes][:title] = 'Fail Hidden' } end it 'renders a 422 response' do expect { subject }.to change(Post, :count).by(0) expect(subject).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('hidden_field') - expect(errors.dig(0, 'title')).to eq('Hidden field error was tripped') + expect(errors.dig(0, 'id')).to eq('hidden_field#error_was_tripped') + expect(errors.dig(0, 'title')).to eq('error was tripped') + expect(errors.dig(0, 'detail')).to eq('Hidden field error was tripped') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to be_nil end @@ -317,8 +321,9 @@ expect { subject }.to change(Post, :count).by(0) expect(subject).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('content-type') - expect(errors.dig(0, 'title')).to eq('Content type can\'t be blank') + expect(errors.dig(0, 'id')).to eq('content-type#blank') + expect(errors.dig(0, 'title')).to eq('can\'t be blank') + expect(errors.dig(0, 'detail')).to eq('Content type can\'t be blank') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/content-type') end @@ -337,8 +342,9 @@ it 'renders a 422 response' do expect { subject }.to change(Post, :count).by(0) expect(response).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('title') - expect(errors.dig(0, 'title')).to eq('Заголовок не может быть пустым') + expect(errors.dig(0, 'id')).to eq('title#blank') + expect(errors.dig(0, 'title')).to eq('не может быть пустым') + expect(errors.dig(0, 'detail')).to eq('Заголовок не может быть пустым') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/title') end @@ -365,7 +371,7 @@ expect { subject }.to change(Post, :count).by(0) expect(response).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('base') + expect(errors.dig(0, 'id')).to eq('base#this_is_an_error_on_the_base') expect(errors.dig(0, 'title')).to eq('This is an error on the base') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data') diff --git a/spec/controllers/profile_controller_spec.rb b/spec/controllers/profile_controller_spec.rb index 7974b7f..b32cba1 100644 --- a/spec/controllers/profile_controller_spec.rb +++ b/spec/controllers/profile_controller_spec.rb @@ -30,8 +30,9 @@ it 'renders a 422 response' do patch :update, params: body expect(response).to have_http_status :unprocessable_entity - expect(errors.dig(0, 'id')).to eq('nickname') - expect(errors.dig(0, 'title')).to eq("Nickname can't be blank") + expect(errors.dig(0, 'id')).to eq('nickname#blank') + expect(errors.dig(0, 'title')).to eq("can't be blank") + expect(errors.dig(0, 'detail')).to eq("Nickname can't be blank") expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/nickname') end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 6f0a5bd..3b0248b 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -7,12 +7,11 @@ @user = FactoryGirl.create_list(:user, 3, :with_posts).first end - let(:user) { @user } - before(:each) do JSONAPI.configuration.json_key_format = :underscored_key end + let(:user) { @user } let(:relationships) { UserResource._relationships.keys.map(&:to_s) } let(:fields) { UserResource.fields.reject { |e| e == :id }.map(&:to_s) - relationships } let(:attributes) { { first_name: 'Yehuda', last_name: 'Katz' } } @@ -411,20 +410,22 @@ it_behaves_like '400 response', admin: true end - context 'when validation fails' do + context 'with validation error and no status code set' do before { user_params.dig(:data, :attributes).merge!(first_name: nil, last_name: nil) } - it 'renders a 422 response' do + it 'renders a 400 response by default' do expect { post :create, params: user_params }.to change(User, :count).by(0) - expect(response).to have_http_status :unprocessable_entity + expect(response).to have_http_status :bad_request expect(errors.dig(0, 'id')).to eq('first_name') - expect(errors.dig(0, 'title')).to eq('First name can\'t be blank') + expect(errors.dig(0, 'title')).to eq('can\'t be blank') + expect(errors.dig(0, 'detail')).to eq('First name can\'t be blank') expect(errors.dig(0, 'code')).to eq('100') expect(errors.dig(0, 'source')).to be_nil expect(errors.dig(1, 'id')).to eq('last_name') - expect(errors.dig(1, 'title')).to eq('Last name can\'t be blank') + expect(errors.dig(1, 'title')).to eq('can\'t be blank') + expect(errors.dig(1, 'detail')).to eq('Last name can\'t be blank') expect(errors.dig(1, 'code')).to eq('100') expect(errors.dig(1, 'source')).to be_nil end diff --git a/spec/support/controllers.rb b/spec/support/controllers.rb index b67c269..9f556d7 100644 --- a/spec/support/controllers.rb +++ b/spec/support/controllers.rb @@ -45,7 +45,7 @@ def create if post.save jsonapi_render json: post, status: :created else - jsonapi_render_errors json: post, status: :unprocessable_entity + jsonapi_render_errors json: post end end @@ -54,7 +54,7 @@ def update_with_error_on_base post = Post.find(params[:id]) # Example of response rendering with error on base post.errors.add(:base, 'This is an error on the base') - jsonapi_render_errors json: post, status: :unprocessable_entity + jsonapi_render_errors json: post end private @@ -96,11 +96,10 @@ def create else # Example of error rendering for Array of Hashes: errors = [ - { id: 'first_name', title: 'First name can\'t be blank', code: '100' }, - { id: 'last_name', title: 'Last name can\'t be blank', code: '100' } + { id: 'first_name', code: '100', title: 'can\'t be blank', detail: 'First name can\'t be blank' }, + { id: 'last_name', code: '100', title: 'can\'t be blank', detail: 'Last name can\'t be blank' } ] - - jsonapi_render_errors json: errors, status: :unprocessable_entity + jsonapi_render_errors json: errors end end @@ -139,6 +138,6 @@ def show def update profile = Profile.new profile.valid? - jsonapi_render_errors json: profile, status: :unprocessable_entity + jsonapi_render_errors json: profile end end