Require explicit adapter/serializer to render JSON API errors

- Separate collection errors from resource errors in adapter
- Refactor to ErrorsSerializer; first-class json error methods
- DOCS
- Rails 4.0 requires assert exact exception class, boo
This commit is contained in:
Benjamin Fleischer 2015-12-02 11:56:15 -06:00
parent dfe162638c
commit 96107c56aa
11 changed files with 196 additions and 88 deletions

View File

@ -14,7 +14,9 @@ This is the documentation of ActiveModelSerializers, it's focused on the **0.10.
- [Caching](general/caching.md) - [Caching](general/caching.md)
- [Logging](general/logging.md) - [Logging](general/logging.md)
- [Instrumentation](general/instrumentation.md) - [Instrumentation](general/instrumentation.md)
- [JSON API Schema](jsonapi/schema.md) - JSON API
- [Schema](jsonapi/schema.md)
- [Errors](jsonapi/errors.md)
- [ARCHITECTURE](ARCHITECTURE.md) - [ARCHITECTURE](ARCHITECTURE.md)
## How to ## How to

57
docs/jsonapi/errors.md Normal file
View File

@ -0,0 +1,57 @@
[Back to Guides](../README.md)
# JSON API Errors
Rendering error documents requires specifying the serializer and the adapter:
- `adapter: :'json_api/error'`
- Serializer:
- For a single resource: `serializer: ActiveModel::Serializer::ErrorSerializer`.
- For a collection: `serializer: ActiveModel::Serializer::ErrorsSerializer`, `each_serializer: ActiveModel::Serializer::ErrorSerializer`.
The resource **MUST** have a non-empty associated `#errors` object.
The `errors` object must have a `#messages` method that returns a hash of error name to array of
descriptions.
## Use in controllers
```ruby
resource = Profile.new(name: 'Name 1',
description: 'Description 1',
comments: 'Comments 1')
resource.errors.add(:name, 'cannot be nil')
resource.errors.add(:name, 'must be longer')
resource.errors.add(:id, 'must be a uuid')
render json: resource, status: 422, adapter: 'json_api/error', serializer: ActiveModel::Serializer::ErrorSerializer
# #=>
# { :errors =>
# [
# { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
# { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
# { :source => { :pointer => '/data/attributes/id' }, :detail => 'must be a uuid' }
# ]
# }.to_json
```
## Direct error document generation
```ruby
options = nil
resource = ModelWithErrors.new
resource.errors.add(:name, 'must be awesome')
serializable_resource = ActiveModel::SerializableResource.new(
resource, {
serializer: ActiveModel::Serializer::ErrorSerializer,
adapter: 'json_api/error'
})
serializable_resource.as_json(options)
# #=>
# {
# :errors =>
# [
# { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be awesome' }
# ]
# }
```

View File

@ -18,14 +18,6 @@ module ActiveModel
options.partition { |k, _| ADAPTER_OPTION_KEYS.include? k }.map { |h| Hash[h] } options.partition { |k, _| ADAPTER_OPTION_KEYS.include? k }.map { |h| Hash[h] }
end end
def errors?
if resource.respond_to?(:each)
resource.any? { |elem| elem.respond_to?(:errors) && !elem.errors.empty? }
else
resource.respond_to?(:errors) && !resource.errors.empty?
end
end
def serialization_scope=(scope) def serialization_scope=(scope)
serializer_opts[:scope] = scope serializer_opts[:scope] = scope
end end
@ -39,11 +31,7 @@ module ActiveModel
end end
def adapter def adapter
@adapter ||= @adapter ||= ActiveModelSerializers::Adapter.create(serializer_instance, adapter_opts)
begin
adapter_opts[:adapter] = :'json_api/error' if errors?
ActiveModelSerializers::Adapter.create(serializer_instance, adapter_opts)
end
end end
alias_method :adapter_instance, :adapter alias_method :adapter_instance, :adapter
@ -58,7 +46,6 @@ module ActiveModel
@serializer ||= @serializer ||=
begin begin
@serializer = serializer_opts.delete(:serializer) @serializer = serializer_opts.delete(:serializer)
@serializer = ActiveModel::Serializer::ErrorSerializer if errors?
@serializer ||= ActiveModel::Serializer.serializer_for(resource) @serializer ||= ActiveModel::Serializer.serializer_for(resource)
if serializer_opts.key?(:each_serializer) if serializer_opts.key?(:each_serializer)

View File

@ -2,6 +2,7 @@ require 'thread_safe'
require 'active_model/serializer/collection_serializer' require 'active_model/serializer/collection_serializer'
require 'active_model/serializer/array_serializer' require 'active_model/serializer/array_serializer'
require 'active_model/serializer/error_serializer' require 'active_model/serializer/error_serializer'
require 'active_model/serializer/errors_serializer'
require 'active_model/serializer/include_tree' require 'active_model/serializer/include_tree'
require 'active_model/serializer/associations' require 'active_model/serializer/associations'
require 'active_model/serializer/attributes' require 'active_model/serializer/attributes'

View File

@ -1,2 +1,6 @@
class ActiveModel::Serializer::ErrorSerializer < ActiveModel::Serializer class ActiveModel::Serializer::ErrorSerializer < ActiveModel::Serializer
# @return [Hash<field_name,Array<error_message>>]
def as_json
object.errors.messages
end
end end

View File

@ -0,0 +1,23 @@
require 'active_model/serializer/error_serializer'
class ActiveModel::Serializer::ErrorsSerializer < ActiveModel::Serializer
include Enumerable
delegate :each, to: :@serializers
attr_reader :object, :root
def initialize(resources, options = {})
@root = options[:root]
@object = resources
@serializers = resources.map do |resource|
serializer_class = options.fetch(:serializer) { ActiveModel::Serializer::ErrorSerializer }
serializer_class.new(resource, options.except(:serializer))
end
end
def json_key
nil
end
protected
attr_reader :serializers
end

View File

@ -13,7 +13,7 @@ module ActiveModelSerializers
# TODO: if we like this abstraction and other API objects to it, # TODO: if we like this abstraction and other API objects to it,
# then extract to its own file and require it. # then extract to its own file and require it.
module ApiObjects module ApiObjects
module JsonApi module Jsonapi
ActiveModelSerializers.config.jsonapi_version = '1.0' ActiveModelSerializers.config.jsonapi_version = '1.0'
ActiveModelSerializers.config.jsonapi_toplevel_meta = {} ActiveModelSerializers.config.jsonapi_toplevel_meta = {}
# Make JSON API top-level jsonapi member opt-in # Make JSON API top-level jsonapi member opt-in
@ -62,7 +62,7 @@ module ActiveModelSerializers
hash[:data] = is_collection ? primary_data : primary_data[0] hash[:data] = is_collection ? primary_data : primary_data[0]
hash[:included] = included if included.any? hash[:included] = included if included.any?
ApiObjects::JsonApi.add!(hash) ApiObjects::Jsonapi.add!(hash)
if instance_options[:links] if instance_options[:links]
hash[:links] ||= {} hash[:links] ||= {}

View File

@ -2,90 +2,101 @@ module ActiveModelSerializers
module Adapter module Adapter
class JsonApi < Base class JsonApi < Base
class Error < Base class Error < Base
=begin UnknownSourceTypeError = Class.new(ArgumentError)
## http://jsonapi.org/format/#document-top-level # rubocop:disable Style/AsciiComments
# TODO: look into caching
A document MUST contain at least one of the following top-level members: # definition:
# ☐ toplevel_errors array (required)
# ☑ toplevel_meta
# ☑ toplevel_jsonapi
def serializable_hash(*)
hash = {}
# PR Please :)
# Jsonapi.add!(hash)
- data: the document's "primary data" # Checking object since we're not using an ArraySerializer
- errors: an array of error objects if serializer.object.respond_to?(:each)
- meta: a meta object that contains non-standard meta-information. hash[:errors] = collection_errors
else
hash[:errors] = Error.resource_errors(serializer)
end
hash
end
The members data and errors MUST NOT coexist in the same document. # @param [ActiveModel::Serializer::ErrorSerializer]
# @return [Array<Symbol, Array<String>] i.e. attribute_name, [attribute_errors]
def self.resource_errors(error_serializer)
error_serializer.as_json.flat_map do |attribute_name, attribute_errors|
attribute_error_objects(attribute_name, attribute_errors)
end
end
## http://jsonapi.org/format/#error-objects # definition:
# JSON Object
Error objects provide additional information about problems encountered while performing an operation. Error objects MUST be returned as an array keyed by errors in the top level of a JSON API document. #
# properties:
An error object MAY have the following members: # ☐ id : String
# ☐ status : String
- id: a unique identifier for this particular occurrence of the problem. # ☐ code : String
- links: a links object containing the following members: # ☐ title : String
- about: a link that leads to further details about this particular occurrence of the problem. # ☑ detail : String
- status: the HTTP status code applicable to this problem, expressed as a string value. # ☐ links
- code: an application-specific error code, expressed as a string value. # ☐ meta
- title: a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. # ☑ error_source
- detail: a human-readable explanation specific to this occurrence of the problem. #
- source: an object containing references to the source of the error, optionally including any of the following members: # description:
- pointer: a JSON Pointer [RFC6901] to the associated entity in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. # id : A unique identifier for this particular occurrence of the problem.
- parameter: a string indicating which query parameter caused the error. # status : The HTTP status code applicable to this problem, expressed as a string value
- meta: a meta object containing non-standard meta-information about the error. # code : An application-specific error code, expressed as a string value.
# title : A short, human-readable summary of the problem. It **SHOULD NOT** change from
=end # occurrence to occurrence of the problem, except for purposes of localization.
def self.attributes(attribute_name, attribute_errors) # detail : A human-readable explanation specific to this occurrence of the problem.
def self.attribute_error_objects(attribute_name, attribute_errors)
attribute_errors.map do |attribute_error| attribute_errors.map do |attribute_error|
{ {
source: { pointer: ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name) }, source: error_source(:pointer, attribute_name),
detail: attribute_error detail: attribute_error
} }
end end
end end
def serializable_hash(*) # description:
@result = [] # oneOf
# TECHDEBT: clean up single vs. collection of resources # ☑ pointer : String
if serializer.object.respond_to?(:each) # ☑ parameter : String
@result = collection_errors.flat_map do |collection_error| #
collection_error.flat_map do |attribute_name, attribute_errors| # description:
attribute_error_objects(attribute_name, attribute_errors) # pointer: A JSON Pointer RFC6901 to the associated entity in the request document e.g. "/data"
end # for a primary data object, or "/data/attributes/title" for a specific attribute.
end # https://tools.ietf.org/html/rfc6901
#
# parameter: A string indicating which query parameter caused the error
def self.error_source(source_type, attribute_name)
case source_type
when :pointer
{
pointer: ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name)
}
when :parameter
{
parameter: attribute_name
}
else else
@result = object_errors.flat_map do |attribute_name, attribute_errors| fail UnknownSourceTypeError, "Unknown source type '#{source_type}' for attribute_name '#{attribute_name}'"
attribute_error_objects(attribute_name, attribute_errors)
end
end end
{ root => @result }
end
def fragment_cache(cached_hash, non_cached_hash)
JsonApi::FragmentCache.new.fragment_cache(root, cached_hash, non_cached_hash)
end
def root
'errors'.freeze
end end
private private
# @return [Array<symbol, Array<String>] i.e. attribute_name, [attribute_errors] # @return [Array<#object_errors>]
def object_errors
cache_check(serializer) do
serializer.object.errors.messages
end
end
def collection_errors def collection_errors
cache_check(serializer) do serializer.flat_map do |error_serializer|
serializer.object.flat_map do |elem| Error.resource_errors(error_serializer)
elem.errors.messages
end
end end
end end
def attribute_error_objects(attribute_name, attribute_errors) # rubocop:enable Style/AsciiComments
Error.attributes(attribute_name, attribute_errors)
end
end end
end end
end end

View File

@ -8,7 +8,7 @@ module ActionController
get :render_resource_with_errors get :render_resource_with_errors
expected_errors_object = expected_errors_object =
{ 'errors'.freeze => { :errors =>
[ [
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' }, { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' }, { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
@ -30,7 +30,7 @@ module ActionController
resource.errors.add(:name, 'cannot be nil') resource.errors.add(:name, 'cannot be nil')
resource.errors.add(:name, 'must be longer') resource.errors.add(:name, 'must be longer')
resource.errors.add(:id, 'must be a uuid') resource.errors.add(:id, 'must be a uuid')
render json: resource, adapter: :json_api render json: resource, adapter: 'json_api/error', serializer: ActiveModel::Serializer::ErrorSerializer
end end
end end

View File

@ -23,7 +23,7 @@ module ActiveModelSerializers
assert_equal serializable_resource.serializer_instance.object, @resource assert_equal serializable_resource.serializer_instance.object, @resource
expected_errors_object = expected_errors_object =
{ 'errors'.freeze => { :errors =>
[ [
{ {
source: { pointer: '/data/attributes/name' }, source: { pointer: '/data/attributes/name' },
@ -49,7 +49,7 @@ module ActiveModelSerializers
assert_equal serializable_resource.serializer_instance.object, @resource assert_equal serializable_resource.serializer_instance.object, @resource
expected_errors_object = expected_errors_object =
{ 'errors'.freeze => { :errors =>
[ [
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' }, { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' }, { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
@ -58,6 +58,20 @@ module ActiveModelSerializers
} }
assert_equal serializable_resource.as_json, expected_errors_object assert_equal serializable_resource.as_json, expected_errors_object
end end
# see http://jsonapi.org/examples/
def test_parameter_source_type_error
parameter = 'auther'
error_source = ActiveModelSerializers::Adapter::JsonApi::Error.error_source(:parameter, parameter)
assert_equal({ parameter: parameter }, error_source)
end
def test_unknown_source_type_error
value = 'auther'
assert_raises(ActiveModelSerializers::Adapter::JsonApi::Error::UnknownSourceTypeError) do
ActiveModelSerializers::Adapter::JsonApi::Error.error_source(:hyper, value)
end
end
end end
end end
end end

View File

@ -37,9 +37,13 @@ module ActiveModel
options = nil options = nil
resource = ModelWithErrors.new resource = ModelWithErrors.new
resource.errors.add(:name, 'must be awesome') resource.errors.add(:name, 'must be awesome')
serializable_resource = ActiveModel::SerializableResource.new(resource) serializable_resource = ActiveModel::SerializableResource.new(
resource, {
serializer: ActiveModel::Serializer::ErrorSerializer,
adapter: 'json_api/error'
})
expected_response_document = expected_response_document =
{ 'errors'.freeze => { :errors =>
[ [
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'must be awesome' } { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be awesome' }
] ]
@ -53,9 +57,14 @@ module ActiveModel
resources << resource = ModelWithErrors.new resources << resource = ModelWithErrors.new
resource.errors.add(:title, 'must be amazing') resource.errors.add(:title, 'must be amazing')
resources << ModelWithErrors.new resources << ModelWithErrors.new
serializable_resource = ActiveModel::SerializableResource.new(resources) serializable_resource = ActiveModel::SerializableResource.new(
resources, {
serializer: ActiveModel::Serializer::ErrorsSerializer,
each_serializer: ActiveModel::Serializer::ErrorSerializer,
adapter: 'json_api/error'
})
expected_response_document = expected_response_document =
{ 'errors'.freeze => { :errors =>
[ [
{ :source => { :pointer => '/data/attributes/title' }, :detail => 'must be amazing' } { :source => { :pointer => '/data/attributes/title' }, :detail => 'must be amazing' }
] ]