Skip to content

Commit a60ba9d

Browse files
committed
RFC: Json Api Errors (WIP)
1 parent d02cd30 commit a60ba9d

File tree

9 files changed

+271
-12
lines changed

9 files changed

+271
-12
lines changed

lib/active_model/serializable_resource.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ def initialize(resource, options = {})
99
@resource = resource
1010
@adapter_opts, @serializer_opts =
1111
options.partition { |k, _| ADAPTER_OPTION_KEYS.include? k }.map { |h| Hash[h] }
12+
13+
# TECHDEBT: clean up single vs. collection of resources
14+
if resource.respond_to?(:each)
15+
if resource.any? { |elem| elem.respond_to?(:errors) && !elem.errors.empty? }
16+
@serializer_opts[:serializer] = ActiveModel::Serializer::ErrorSerializer
17+
@adapter_opts[:adapter] = :'json_api/error'
18+
end
19+
else
20+
if resource.respond_to?(:errors) && !resource.errors.empty?
21+
@serializer_opts[:serializer] = ActiveModel::Serializer::ErrorSerializer
22+
@adapter_opts[:adapter] = :'json_api/error'
23+
end
24+
end
1225
end
1326

1427
delegate :serializable_hash, :as_json, :to_json, to: :adapter

lib/active_model/serializer.rb

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'thread_safe'
22
require 'active_model/serializer/array_serializer'
3+
require 'active_model/serializer/error_serializer'
34
require 'active_model/serializer/include_tree'
45
require 'active_model/serializer/associations'
56
require 'active_model/serializer/configuration'
@@ -27,11 +28,15 @@ class Serializer
2728
)
2829
/x
2930

31+
class_attribute :_attributes, instance_writer: false
32+
self._attributes ||= []
33+
class_attribute :_attributes_keys, instance_writer: false, instance_reader: false
34+
self._attributes_keys ||= {}
35+
class_attribute :_type, instance_writer: false
36+
class_attribute :_fragmented, instance_writer: false
37+
3038
class << self
31-
attr_accessor :_attributes
32-
attr_accessor :_attributes_keys
3339
attr_accessor :_cache
34-
attr_accessor :_fragmented
3540
attr_accessor :_cache_key
3641
attr_accessor :_cache_only
3742
attr_accessor :_cache_except
@@ -40,8 +45,8 @@ class << self
4045
end
4146

4247
def self.inherited(base)
43-
base._attributes = _attributes.try(:dup) || []
44-
base._attributes_keys = _attributes_keys.try(:dup) || {}
48+
base._attributes = _attributes.dup
49+
base._attributes_keys = _attributes_keys.dup
4550
base._cache_digest = digest_caller_file(caller.first)
4651
super
4752
end
@@ -122,7 +127,6 @@ def self.get_serializer_for(klass)
122127
end
123128

124129
attr_accessor :object, :root, :scope
125-
class_attribute :_type, instance_writer: false
126130

127131
def initialize(object, options = {})
128132
self.object = object
@@ -143,13 +147,13 @@ def json_key
143147
end
144148

145149
def attributes
146-
attributes = self.class._attributes.dup
150+
attributes = _attributes.dup
147151

148152
attributes.each_with_object({}) do |name, hash|
149-
unless self.class._fragmented
150-
hash[name] = send(name)
153+
if _fragmented
154+
hash[name] = _fragmented.public_send(name)
151155
else
152-
hash[name] = self.class._fragmented.public_send(name)
156+
hash[name] = send(name)
153157
end
154158
end
155159
end

lib/active_model/serializer/adapter/json_api.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class JsonApi < Base
55
extend ActiveSupport::Autoload
66
autoload :PaginationLinks
77
autoload :FragmentCache
8+
require 'active_model/serializer/adapter/json_api/error'
89

910
# TODO: if we like this abstraction and other API objects to it,
1011
# then extract to its own file and require it.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
module ActiveModel
2+
class Serializer
3+
module Adapter
4+
class JsonApi < Base
5+
class Error < Base
6+
def serializable_hash(*)
7+
@result = []
8+
# TECHDEBT: clean up single vs. collection of resources
9+
if serializer.object.respond_to?(:each)
10+
@result = collection_errors.flat_map do |collection_error|
11+
collection_error.flat_map do |attribute_name, attribute_errors|
12+
attribute_error_objects(attribute_name, attribute_errors)
13+
end
14+
end
15+
else
16+
@result = object_errors.flat_map do |attribute_name, attribute_errors|
17+
attribute_error_objects(attribute_name, attribute_errors)
18+
end
19+
end
20+
{ root => @result }
21+
end
22+
23+
def fragment_cache(cached_hash, non_cached_hash)
24+
JsonApi::FragmentCache.new.fragment_cache(root, cached_hash, non_cached_hash)
25+
end
26+
27+
def root
28+
'errors'.freeze
29+
end
30+
31+
private
32+
33+
# @return [Array<symbol, Array<String>] i.e. attribute_name, [attribute_errors]
34+
def object_errors
35+
cache_check(serializer) do
36+
serializer.object.errors.messages
37+
end
38+
end
39+
40+
def collection_errors
41+
cache_check(serializer) do
42+
serializer.object.flat_map do |elem|
43+
elem.errors.messages
44+
end
45+
end
46+
end
47+
48+
def attribute_error_objects(attribute_name, attribute_errors)
49+
attribute_errors.map do |attribute_error|
50+
{
51+
source: { pointer: "data/attributes/#{attribute_name}" },
52+
detail: attribute_error
53+
}
54+
end
55+
end
56+
end
57+
end
58+
end
59+
end
60+
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class ActiveModel::Serializer::ErrorSerializer < ActiveModel::Serializer
2+
end

test/adapter/json_api/errors_test.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
require 'test_helper'
2+
3+
=begin
4+
## http://jsonapi.org/format/#document-top-level
5+
6+
A document MUST contain at least one of the following top-level members:
7+
8+
- data: the document's "primary data"
9+
- errors: an array of error objects
10+
- meta: a meta object that contains non-standard meta-information.
11+
12+
The members data and errors MUST NOT coexist in the same document.
13+
14+
## http://jsonapi.org/format/#error-objects
15+
16+
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.
17+
18+
An error object MAY have the following members:
19+
20+
- id: a unique identifier for this particular occurrence of the problem.
21+
- links: a links object containing the following members:
22+
- about: a link that leads to further details about this particular occurrence of the problem.
23+
- status: the HTTP status code applicable to this problem, expressed as a string value.
24+
- code: an application-specific error code, expressed as a string value.
25+
- 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.
26+
- detail: a human-readable explanation specific to this occurrence of the problem.
27+
- source: an object containing references to the source of the error, optionally including any of the following members:
28+
- 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].
29+
- parameter: a string indicating which query parameter caused the error.
30+
- meta: a meta object containing non-standard meta-information about the error.
31+
32+
=end
33+
34+
module ActiveModel
35+
class Serializer
36+
module Adapter
37+
class JsonApi < Base
38+
class ErrorsTest < Minitest::Test
39+
include ActiveModel::Serializer::Lint::Tests
40+
41+
def setup
42+
@resource = ModelWithErrors.new
43+
end
44+
45+
def test_active_model_with_error
46+
options = {
47+
serializer: ActiveModel::Serializer::ErrorSerializer,
48+
adapter: :'json_api/error'
49+
}
50+
51+
@resource.errors.add(:name, 'cannot be nil')
52+
53+
serializable_resource = ActiveModel::SerializableResource.new(@resource, options)
54+
assert_equal serializable_resource.serializer_instance.attributes, {}
55+
assert_equal serializable_resource.serializer_instance.object, @resource
56+
57+
expected_errors_object =
58+
{ 'errors'.freeze =>
59+
[
60+
{
61+
source: { pointer: 'data/attributes/name' },
62+
detail: 'cannot be nil'
63+
}
64+
]
65+
}
66+
assert_equal serializable_resource.as_json, expected_errors_object
67+
end
68+
69+
def test_active_model_with_multiple_errors
70+
options = {
71+
serializer: ActiveModel::Serializer::ErrorSerializer,
72+
adapter: :'json_api/error'
73+
}
74+
75+
@resource.errors.add(:name, 'cannot be nil')
76+
@resource.errors.add(:name, 'must be longer')
77+
@resource.errors.add(:id, 'must be a uuid')
78+
79+
serializable_resource = ActiveModel::SerializableResource.new(@resource, options)
80+
assert_equal serializable_resource.serializer_instance.attributes, {}
81+
assert_equal serializable_resource.serializer_instance.object, @resource
82+
83+
expected_errors_object =
84+
{ 'errors'.freeze =>
85+
[
86+
{ :source => { :pointer => 'data/attributes/name' }, :detail => 'cannot be nil' },
87+
{ :source => { :pointer => 'data/attributes/name' }, :detail => 'must be longer' },
88+
{ :source => { :pointer => 'data/attributes/id' }, :detail => 'must be a uuid' }
89+
]
90+
}
91+
assert_equal serializable_resource.as_json, expected_errors_object
92+
end
93+
end
94+
end
95+
end
96+
end
97+
end

test/fixtures/poro.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,53 @@ def updated_at
5353
end
5454
end
5555

56+
# see
57+
# https://github.com/rails/rails/blob/4-2-stable/activemodel/lib/active_model/errors.rb
58+
# The below allows you to do:
59+
#
60+
# model = ModelWithErrors.new
61+
# model.validate! # => ["cannot be nil"]
62+
# model.errors.full_messages # => ["name cannot be nil"]
63+
class ModelWithErrors
64+
include ActiveModel::Serialization
65+
def attributes
66+
{
67+
errors: errors,
68+
name: name
69+
}
70+
end
71+
72+
def id
73+
object_id
74+
end
75+
76+
def cache_key
77+
"#{self.class.name.downcase}/#{id}-#{Time.now.utc.strftime("%Y%m%d%H%M%S%9N")}"
78+
end
79+
80+
# Required dependency for ActiveModel::Errors
81+
extend ActiveModel::Naming
82+
def initialize
83+
@errors = ActiveModel::Errors.new(self)
84+
end
85+
attr_accessor :name
86+
attr_reader :errors
87+
88+
# The following methods are needed to be minimally implemented
89+
90+
def read_attribute_for_validation(attr)
91+
send(attr)
92+
end
93+
94+
def self.human_attribute_name(attr, options = {})
95+
attr
96+
end
97+
98+
def self.lookup_ancestors
99+
[self]
100+
end
101+
end
102+
56103
class Profile < Model
57104
end
58105

test/serializable_resource_test.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,37 @@ def test_serializable_resource_delegates_as_json_to_the_adapter
2323
options = nil
2424
assert_equal @adapter.as_json(options), @serializable_resource.as_json(options)
2525
end
26+
27+
class SerializableResourceErrorsTest < Minitest::Test
28+
def test_serializable_resource_with_errors
29+
options = nil
30+
resource = ModelWithErrors.new
31+
resource.errors.add(:name, 'must be awesome')
32+
serializable_resource = ActiveModel::SerializableResource.new(resource)
33+
expected_response_document =
34+
{ 'errors'.freeze =>
35+
[
36+
{ :source => { :pointer => 'data/attributes/name' }, :detail => 'must be awesome' }
37+
]
38+
}
39+
assert_equal serializable_resource.as_json(options), expected_response_document
40+
end
41+
42+
def test_serializable_resource_with_collection_containing_errors
43+
options = nil
44+
resources = []
45+
resources << resource = ModelWithErrors.new
46+
resource.errors.add(:title, 'must be amazing')
47+
resources << ModelWithErrors.new
48+
serializable_resource = ActiveModel::SerializableResource.new(resources)
49+
expected_response_document =
50+
{ 'errors'.freeze =>
51+
[
52+
{ :source => { :pointer => 'data/attributes/title' }, :detail => 'must be amazing' }
53+
]
54+
}
55+
assert_equal serializable_resource.as_json(options), expected_response_document
56+
end
57+
end
2658
end
2759
end

test/serializers/adapter_for_test.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@ def test_adapter_map
6363
expected_adapter_map = {
6464
'null'.freeze => ActiveModel::Serializer::Adapter::Null,
6565
'json'.freeze => ActiveModel::Serializer::Adapter::Json,
66-
'attributes'.freeze => ActiveModel::Serializer::Adapter::Attributes,
67-
'json_api'.freeze => ActiveModel::Serializer::Adapter::JsonApi
66+
'attributes'.freeze => ActiveModel::Serializer::Adapter::Attributes,
67+
'json_api'.freeze => ActiveModel::Serializer::Adapter::JsonApi,
68+
'json_api/error'.freeze => ActiveModel::Serializer::Adapter::JsonApi::Error,
69+
'null'.freeze => ActiveModel::Serializer::Adapter::Null
6870
}
6971
actual = ActiveModel::Serializer::Adapter.adapter_map
7072
assert_equal actual, expected_adapter_map
@@ -75,6 +77,7 @@ def test_adapters
7577
'attributes'.freeze,
7678
'json'.freeze,
7779
'json_api'.freeze,
80+
'json_api/error'.freeze,
7881
'null'.freeze
7982
]
8083
end

0 commit comments

Comments
 (0)