Skip to content

Commit 0ba944d

Browse files
committed
RFC: Json Api Errors (WIP)
- ActiveModelSerializers::JsonPointer - ActiveModel::Serializer::Adapter::JsonApi::Error - ActiveModel::Serializer::Adapter::JsonApi::Error.attributes - Fix rubocop config
1 parent df815c4 commit 0ba944d

File tree

16 files changed

+329
-2
lines changed

16 files changed

+329
-2
lines changed

lib/active_model/serializable_resource.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ def initialize(resource, options = {})
1616
@resource = resource
1717
@adapter_opts, @serializer_opts =
1818
options.partition { |k, _| ADAPTER_OPTION_KEYS.include? k }.map { |h| Hash[h] }
19+
20+
# TECHDEBT: clean up single vs. collection of resources
21+
if resource.respond_to?(:each)
22+
if resource.any? { |elem| elem.respond_to?(:errors) && !elem.errors.empty? }
23+
@serializer_opts[:serializer] = ActiveModel::Serializer::ErrorSerializer
24+
@adapter_opts[:adapter] = :'json_api/error'
25+
end
26+
else
27+
if resource.respond_to?(:errors) && !resource.errors.empty?
28+
@serializer_opts[:serializer] = ActiveModel::Serializer::ErrorSerializer
29+
@adapter_opts[:adapter] = :'json_api/error'
30+
end
31+
end
1932
end
2033

2134
def serialization_scope=(scope)

lib/active_model/serializer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require 'thread_safe'
22
require 'active_model/serializer/collection_serializer'
33
require 'active_model/serializer/array_serializer'
4+
require 'active_model/serializer/error_serializer'
45
require 'active_model/serializer/include_tree'
56
require 'active_model/serializer/associations'
67
require 'active_model/serializer/attributes'
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

lib/active_model/serializer/lint.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,20 @@ def test_model_name
129129
assert_instance_of resource_class.model_name, ActiveModel::Name
130130
end
131131

132+
def test_active_model_errors
133+
assert_respond_to resource, :errors
134+
end
135+
136+
def test_active_model_errors_human_attribute_name
137+
assert_respond_to resource.class, :human_attribute_name
138+
assert_equal(-2, resource.class.method(:human_attribute_name).arity)
139+
end
140+
141+
def test_active_model_errors_lookup_ancestors
142+
assert_respond_to resource.class, :lookup_ancestors
143+
assert_equal 0, resource.class.method(:lookup_ancestors).arity
144+
end
145+
132146
private
133147

134148
def resource

lib/active_model_serializers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ module ActiveModelSerializers
1010
autoload :Logging
1111
autoload :Test
1212
autoload :Adapter
13+
autoload :JsonPointer
1314

1415
class << self; attr_accessor :logger; end
1516
self.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))

lib/active_model_serializers/adapter/json_api.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class JsonApi < Base
88
require 'active_model/serializer/adapter/json_api/meta'
99
autoload :Deserialization
1010
require 'active_model/serializer/adapter/json_api/api_objects'
11+
autoload :Error
1112

1213
# TODO: if we like this abstraction and other API objects to it,
1314
# then extract to its own file and require it.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
module ActiveModelSerializers
2+
module Adapter
3+
class JsonApi < Base
4+
class Error < Base
5+
=begin
6+
## http://jsonapi.org/format/#document-top-level
7+
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.
19+
20+
An error object MAY have the following members:
21+
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.
33+
34+
=end
35+
def self.attributes(attribute_name, attribute_errors)
36+
attribute_errors.map do |attribute_error|
37+
{
38+
source: { pointer: ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name) },
39+
detail: attribute_error
40+
}
41+
end
42+
end
43+
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
53+
else
54+
@result = object_errors.flat_map do |attribute_name, attribute_errors|
55+
attribute_error_objects(attribute_name, attribute_errors)
56+
end
57+
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
67+
end
68+
69+
private
70+
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+
78+
def collection_errors
79+
cache_check(serializer) do
80+
serializer.object.flat_map do |elem|
81+
elem.errors.messages
82+
end
83+
end
84+
end
85+
86+
def attribute_error_objects(attribute_name, attribute_errors)
87+
Error.attributes(attribute_name, attribute_errors)
88+
end
89+
end
90+
end
91+
end
92+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module ActiveModelSerializers
2+
module JsonPointer
3+
module_function
4+
5+
POINTERS = {
6+
attribute: '/data/attributes/%s'.freeze,
7+
primary_data: '/data'.freeze
8+
}.freeze
9+
10+
def new(pointer_type, value = nil)
11+
format(POINTERS[pointer_type], value)
12+
end
13+
end
14+
end

lib/active_model_serializers/model.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ class Model
66
include ActiveModel::Model
77
include ActiveModel::Serializers::JSON
88

9-
attr_reader :attributes
9+
attr_reader :attributes, :errors
1010

1111
def initialize(attributes = {})
1212
@attributes = attributes
13+
@errors = ActiveModel::Errors.new(self)
1314
super
1415
end
1516

@@ -35,5 +36,14 @@ def read_attribute_for_serialization(key)
3536
attributes[key]
3637
end
3738
end
39+
40+
# The following methods are needed to be minimally implemented for ActiveModel::Errors
41+
def self.human_attribute_name(attr, _options = {})
42+
attr
43+
end
44+
45+
def self.lookup_ancestors
46+
[self]
47+
end
3848
end
3949
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
require 'test_helper'
2+
3+
module ActionController
4+
module Serialization
5+
class JsonApi
6+
class ErrorsTest < ActionController::TestCase
7+
def test_active_model_with_multiple_errors
8+
get :render_resource_with_errors
9+
10+
expected_errors_object =
11+
{ 'errors'.freeze =>
12+
[
13+
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' },
14+
{ :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' },
15+
{ :source => { :pointer => '/data/attributes/id' }, :detail => 'must be a uuid' }
16+
]
17+
}.to_json
18+
assert_equal json_reponse_body.to_json, expected_errors_object
19+
end
20+
21+
def json_reponse_body
22+
JSON.load(@response.body)
23+
end
24+
25+
class ErrorsTestController < ActionController::Base
26+
def render_resource_with_errors
27+
resource = Profile.new(name: 'Name 1',
28+
description: 'Description 1',
29+
comments: 'Comments 1')
30+
resource.errors.add(:name, 'cannot be nil')
31+
resource.errors.add(:name, 'must be longer')
32+
resource.errors.add(:id, 'must be a uuid')
33+
render json: resource, adapter: :json_api
34+
end
35+
end
36+
37+
tests ErrorsTestController
38+
end
39+
end
40+
end
41+
end

0 commit comments

Comments
 (0)