Skip to content

Commit 96107c5

Browse files
committed
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
1 parent dfe1626 commit 96107c5

File tree

11 files changed

+196
-88
lines changed

11 files changed

+196
-88
lines changed

docs/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ This is the documentation of ActiveModelSerializers, it's focused on the **0.10.
1414
- [Caching](general/caching.md)
1515
- [Logging](general/logging.md)
1616
- [Instrumentation](general/instrumentation.md)
17-
- [JSON API Schema](jsonapi/schema.md)
17+
- JSON API
18+
- [Schema](jsonapi/schema.md)
19+
- [Errors](jsonapi/errors.md)
1820
- [ARCHITECTURE](ARCHITECTURE.md)
1921

2022
## How to

docs/jsonapi/errors.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
[Back to Guides](../README.md)
2+
3+
# JSON API Errors
4+
5+
Rendering error documents requires specifying the serializer and the adapter:
6+
7+
- `adapter: :'json_api/error'`
8+
- Serializer:
9+
- For a single resource: `serializer: ActiveModel::Serializer::ErrorSerializer`.
10+
- For a collection: `serializer: ActiveModel::Serializer::ErrorsSerializer`, `each_serializer: ActiveModel::Serializer::ErrorSerializer`.
11+
12+
The resource **MUST** have a non-empty associated `#errors` object.
13+
The `errors` object must have a `#messages` method that returns a hash of error name to array of
14+
descriptions.
15+
16+
## Use in controllers
17+
18+
```ruby
19+
resource = Profile.new(name: 'Name 1',
20+
description: 'Description 1',
21+
comments: 'Comments 1')
22+
resource.errors.add(:name, 'cannot be nil')
23+
resource.errors.add(:name, 'must be longer')
24+
resource.errors.add(:id, 'must be a uuid')
25+
26+
render json: resource, status: 422, adapter: 'json_api/error', serializer: ActiveModel::Serializer::ErrorSerializer
27+
# #=>
28+
# { :errors =>
29+
# [
30+
# { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
31+
# { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
32+
# { :source => { :pointer => '/data/attributes/id' }, :detail => 'must be a uuid' }
33+
# ]
34+
# }.to_json
35+
```
36+
37+
## Direct error document generation
38+
39+
```ruby
40+
options = nil
41+
resource = ModelWithErrors.new
42+
resource.errors.add(:name, 'must be awesome')
43+
44+
serializable_resource = ActiveModel::SerializableResource.new(
45+
resource, {
46+
serializer: ActiveModel::Serializer::ErrorSerializer,
47+
adapter: 'json_api/error'
48+
})
49+
serializable_resource.as_json(options)
50+
# #=>
51+
# {
52+
# :errors =>
53+
# [
54+
# { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be awesome' }
55+
# ]
56+
# }
57+
```

lib/active_model/serializable_resource.rb

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,6 @@ def initialize(resource, options = {})
1818
options.partition { |k, _| ADAPTER_OPTION_KEYS.include? k }.map { |h| Hash[h] }
1919
end
2020

21-
def errors?
22-
if resource.respond_to?(:each)
23-
resource.any? { |elem| elem.respond_to?(:errors) && !elem.errors.empty? }
24-
else
25-
resource.respond_to?(:errors) && !resource.errors.empty?
26-
end
27-
end
28-
2921
def serialization_scope=(scope)
3022
serializer_opts[:scope] = scope
3123
end
@@ -39,11 +31,7 @@ def serialization_scope_name=(scope_name)
3931
end
4032

4133
def adapter
42-
@adapter ||=
43-
begin
44-
adapter_opts[:adapter] = :'json_api/error' if errors?
45-
ActiveModelSerializers::Adapter.create(serializer_instance, adapter_opts)
46-
end
34+
@adapter ||= ActiveModelSerializers::Adapter.create(serializer_instance, adapter_opts)
4735
end
4836
alias_method :adapter_instance, :adapter
4937

@@ -58,7 +46,6 @@ def serializer
5846
@serializer ||=
5947
begin
6048
@serializer = serializer_opts.delete(:serializer)
61-
@serializer = ActiveModel::Serializer::ErrorSerializer if errors?
6249
@serializer ||= ActiveModel::Serializer.serializer_for(resource)
6350

6451
if serializer_opts.key?(:each_serializer)

lib/active_model/serializer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require 'active_model/serializer/collection_serializer'
33
require 'active_model/serializer/array_serializer'
44
require 'active_model/serializer/error_serializer'
5+
require 'active_model/serializer/errors_serializer'
56
require 'active_model/serializer/include_tree'
67
require 'active_model/serializer/associations'
78
require 'active_model/serializer/attributes'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
class ActiveModel::Serializer::ErrorSerializer < ActiveModel::Serializer
2+
# @return [Hash<field_name,Array<error_message>>]
3+
def as_json
4+
object.errors.messages
5+
end
26
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
require 'active_model/serializer/error_serializer'
2+
class ActiveModel::Serializer::ErrorsSerializer < ActiveModel::Serializer
3+
include Enumerable
4+
delegate :each, to: :@serializers
5+
attr_reader :object, :root
6+
7+
def initialize(resources, options = {})
8+
@root = options[:root]
9+
@object = resources
10+
@serializers = resources.map do |resource|
11+
serializer_class = options.fetch(:serializer) { ActiveModel::Serializer::ErrorSerializer }
12+
serializer_class.new(resource, options.except(:serializer))
13+
end
14+
end
15+
16+
def json_key
17+
nil
18+
end
19+
20+
protected
21+
22+
attr_reader :serializers
23+
end

lib/active_model_serializers/adapter/json_api.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class JsonApi < Base
1313
# TODO: if we like this abstraction and other API objects to it,
1414
# then extract to its own file and require it.
1515
module ApiObjects
16-
module JsonApi
16+
module Jsonapi
1717
ActiveModelSerializers.config.jsonapi_version = '1.0'
1818
ActiveModelSerializers.config.jsonapi_toplevel_meta = {}
1919
# Make JSON API top-level jsonapi member opt-in
@@ -62,7 +62,7 @@ def serializable_hash(options = nil)
6262
hash[:data] = is_collection ? primary_data : primary_data[0]
6363
hash[:included] = included if included.any?
6464

65-
ApiObjects::JsonApi.add!(hash)
65+
ApiObjects::Jsonapi.add!(hash)
6666

6767
if instance_options[:links]
6868
hash[:links] ||= {}

lib/active_model_serializers/adapter/json_api/error.rb

Lines changed: 74 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,90 +2,101 @@ module ActiveModelSerializers
22
module Adapter
33
class JsonApi < Base
44
class Error < Base
5-
=begin
6-
## http://jsonapi.org/format/#document-top-level
5+
UnknownSourceTypeError = Class.new(ArgumentError)
6+
# rubocop:disable Style/AsciiComments
7+
# TODO: look into caching
78

8-
A document MUST contain at least one of the following top-level members:
9-
10-
- data: the document's "primary data"
11-
- errors: an array of error objects
12-
- meta: a meta object that contains non-standard meta-information.
13-
14-
The members data and errors MUST NOT coexist in the same document.
15-
16-
## http://jsonapi.org/format/#error-objects
17-
18-
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.
9+
# definition:
10+
# ☐ toplevel_errors array (required)
11+
# ☑ toplevel_meta
12+
# ☑ toplevel_jsonapi
13+
def serializable_hash(*)
14+
hash = {}
15+
# PR Please :)
16+
# Jsonapi.add!(hash)
1917

20-
An error object MAY have the following members:
18+
# Checking object since we're not using an ArraySerializer
19+
if serializer.object.respond_to?(:each)
20+
hash[:errors] = collection_errors
21+
else
22+
hash[:errors] = Error.resource_errors(serializer)
23+
end
24+
hash
25+
end
2126

22-
- id: a unique identifier for this particular occurrence of the problem.
23-
- links: a links object containing the following members:
24-
- about: a link that leads to further details about this particular occurrence of the problem.
25-
- status: the HTTP status code applicable to this problem, expressed as a string value.
26-
- code: an application-specific error code, expressed as a string value.
27-
- 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.
28-
- detail: a human-readable explanation specific to this occurrence of the problem.
29-
- source: an object containing references to the source of the error, optionally including any of the following members:
30-
- 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].
31-
- parameter: a string indicating which query parameter caused the error.
32-
- meta: a meta object containing non-standard meta-information about the error.
27+
# @param [ActiveModel::Serializer::ErrorSerializer]
28+
# @return [Array<Symbol, Array<String>] i.e. attribute_name, [attribute_errors]
29+
def self.resource_errors(error_serializer)
30+
error_serializer.as_json.flat_map do |attribute_name, attribute_errors|
31+
attribute_error_objects(attribute_name, attribute_errors)
32+
end
33+
end
3334

34-
=end
35-
def self.attributes(attribute_name, attribute_errors)
35+
# definition:
36+
# JSON Object
37+
#
38+
# properties:
39+
# ☐ id : String
40+
# ☐ status : String
41+
# ☐ code : String
42+
# ☐ title : String
43+
# ☑ detail : String
44+
# ☐ links
45+
# ☐ meta
46+
# ☑ error_source
47+
#
48+
# description:
49+
# id : A unique identifier for this particular occurrence of the problem.
50+
# status : The HTTP status code applicable to this problem, expressed as a string value
51+
# code : An application-specific error code, expressed as a string value.
52+
# title : A short, human-readable summary of the problem. It **SHOULD NOT** change from
53+
# occurrence to occurrence of the problem, except for purposes of localization.
54+
# detail : A human-readable explanation specific to this occurrence of the problem.
55+
def self.attribute_error_objects(attribute_name, attribute_errors)
3656
attribute_errors.map do |attribute_error|
3757
{
38-
source: { pointer: ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name) },
58+
source: error_source(:pointer, attribute_name),
3959
detail: attribute_error
4060
}
4161
end
4262
end
4363

44-
def serializable_hash(*)
45-
@result = []
46-
# TECHDEBT: clean up single vs. collection of resources
47-
if serializer.object.respond_to?(:each)
48-
@result = collection_errors.flat_map do |collection_error|
49-
collection_error.flat_map do |attribute_name, attribute_errors|
50-
attribute_error_objects(attribute_name, attribute_errors)
51-
end
52-
end
64+
# description:
65+
# oneOf
66+
# ☑ pointer : String
67+
# ☑ parameter : String
68+
#
69+
# description:
70+
# pointer: A JSON Pointer RFC6901 to the associated entity in the request document e.g. "/data"
71+
# for a primary data object, or "/data/attributes/title" for a specific attribute.
72+
# https://tools.ietf.org/html/rfc6901
73+
#
74+
# parameter: A string indicating which query parameter caused the error
75+
def self.error_source(source_type, attribute_name)
76+
case source_type
77+
when :pointer
78+
{
79+
pointer: ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name)
80+
}
81+
when :parameter
82+
{
83+
parameter: attribute_name
84+
}
5385
else
54-
@result = object_errors.flat_map do |attribute_name, attribute_errors|
55-
attribute_error_objects(attribute_name, attribute_errors)
56-
end
86+
fail UnknownSourceTypeError, "Unknown source type '#{source_type}' for attribute_name '#{attribute_name}'"
5787
end
58-
{ root => @result }
59-
end
60-
61-
def fragment_cache(cached_hash, non_cached_hash)
62-
JsonApi::FragmentCache.new.fragment_cache(root, cached_hash, non_cached_hash)
63-
end
64-
65-
def root
66-
'errors'.freeze
6788
end
6889

6990
private
7091

71-
# @return [Array<symbol, Array<String>] i.e. attribute_name, [attribute_errors]
72-
def object_errors
73-
cache_check(serializer) do
74-
serializer.object.errors.messages
75-
end
76-
end
77-
92+
# @return [Array<#object_errors>]
7893
def collection_errors
79-
cache_check(serializer) do
80-
serializer.object.flat_map do |elem|
81-
elem.errors.messages
82-
end
94+
serializer.flat_map do |error_serializer|
95+
Error.resource_errors(error_serializer)
8396
end
8497
end
8598

86-
def attribute_error_objects(attribute_name, attribute_errors)
87-
Error.attributes(attribute_name, attribute_errors)
88-
end
99+
# rubocop:enable Style/AsciiComments
89100
end
90101
end
91102
end

test/action_controller/json_api/errors_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def test_active_model_with_multiple_errors
88
get :render_resource_with_errors
99

1010
expected_errors_object =
11-
{ 'errors'.freeze =>
11+
{ :errors =>
1212
[
1313
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
1414
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
@@ -30,7 +30,7 @@ def render_resource_with_errors
3030
resource.errors.add(:name, 'cannot be nil')
3131
resource.errors.add(:name, 'must be longer')
3232
resource.errors.add(:id, 'must be a uuid')
33-
render json: resource, adapter: :json_api
33+
render json: resource, adapter: 'json_api/error', serializer: ActiveModel::Serializer::ErrorSerializer
3434
end
3535
end
3636

test/adapter/json_api/errors_test.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_active_model_with_error
2323
assert_equal serializable_resource.serializer_instance.object, @resource
2424

2525
expected_errors_object =
26-
{ 'errors'.freeze =>
26+
{ :errors =>
2727
[
2828
{
2929
source: { pointer: '/data/attributes/name' },
@@ -49,7 +49,7 @@ def test_active_model_with_multiple_errors
4949
assert_equal serializable_resource.serializer_instance.object, @resource
5050

5151
expected_errors_object =
52-
{ 'errors'.freeze =>
52+
{ :errors =>
5353
[
5454
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
5555
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
@@ -58,6 +58,20 @@ def test_active_model_with_multiple_errors
5858
}
5959
assert_equal serializable_resource.as_json, expected_errors_object
6060
end
61+
62+
# see http://jsonapi.org/examples/
63+
def test_parameter_source_type_error
64+
parameter = 'auther'
65+
error_source = ActiveModelSerializers::Adapter::JsonApi::Error.error_source(:parameter, parameter)
66+
assert_equal({ parameter: parameter }, error_source)
67+
end
68+
69+
def test_unknown_source_type_error
70+
value = 'auther'
71+
assert_raises(ActiveModelSerializers::Adapter::JsonApi::Error::UnknownSourceTypeError) do
72+
ActiveModelSerializers::Adapter::JsonApi::Error.error_source(:hyper, value)
73+
end
74+
end
6175
end
6276
end
6377
end

0 commit comments

Comments
 (0)