Skip to content

Commit 5a385a9

Browse files
committed
Ensure media type of Accept header is valid according to the JSON API spec.
* Adds before_action to validate accept header * Add tests to cover all use cases * Respond with 406 status if invalid * Include appropriate error classes and translations
1 parent 8ac47d2 commit 5a385a9

5 files changed

Lines changed: 93 additions & 9 deletions

File tree

lib/jsonapi/acts_as_resource_controller.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
module JSONAPI
44
module ActsAsResourceController
5+
MEDIA_TYPE_MATCHER = /(.+".+"[^,]*|[^,]+)/
56

67
def self.included(base)
78
base.extend ClassMethods
89
base.before_action :ensure_correct_media_type, only: [:create, :update, :create_relationship, :update_relationship]
10+
base.before_action :ensure_valid_accept_media_type
911
base.cattr_reader :server_error_callbacks
1012
end
1113

@@ -99,6 +101,29 @@ def ensure_correct_media_type
99101
handle_exceptions(e)
100102
end
101103

104+
def ensure_valid_accept_media_type
105+
if invalid_accept_media_type?
106+
fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept)
107+
end
108+
rescue => e
109+
handle_exceptions(e)
110+
end
111+
112+
def invalid_accept_media_type?
113+
jsonapi_media_types = media_types('Accept').select do |media_type|
114+
media_type.include?(JSONAPI::MEDIA_TYPE)
115+
end
116+
117+
jsonapi_media_types.size > 0 &&
118+
jsonapi_media_types.none? do |media_type|
119+
media_type.strip == JSONAPI::MEDIA_TYPE
120+
end
121+
end
122+
123+
def media_types(header)
124+
(request.headers[header] || '').match(MEDIA_TYPE_MATCHER).to_a
125+
end
126+
102127
# override to set context
103128
def context
104129
{}

lib/jsonapi/error_codes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module JSONAPI
2222
SAVE_FAILED = '121'
2323
FORBIDDEN = '403'
2424
RECORD_NOT_FOUND = '404'
25+
NOT_ACCEPTABLE = '406'
2526
UNSUPPORTED_MEDIA_TYPE = '415'
2627
LOCKED = '423'
2728
INTERNAL_SERVER_ERROR = '500'
@@ -50,6 +51,7 @@ module JSONAPI
5051
SAVE_FAILED => 'SAVE_FAILED',
5152
FORBIDDEN => 'FORBIDDEN',
5253
RECORD_NOT_FOUND => 'RECORD_NOT_FOUND',
54+
NOT_ACCEPTABLE => 'NOT_ACCEPTABLE',
5355
UNSUPPORTED_MEDIA_TYPE => 'UNSUPPORTED_MEDIA_TYPE',
5456
LOCKED => 'LOCKED',
5557
INTERNAL_SERVER_ERROR => 'INTERNAL_SERVER_ERROR'

lib/jsonapi/exceptions.rb

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ def errors
1818

1919
[JSONAPI::Error.new(code: JSONAPI::INTERNAL_SERVER_ERROR,
2020
status: :internal_server_error,
21-
title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title',
21+
title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title',
2222
default: 'Internal Server Error'),
23-
detail: I18n.t('jsonapi-resources.exceptions.internal_server_error.detail',
23+
detail: I18n.t('jsonapi-resources.exceptions.internal_server_error.detail',
2424
default: 'Internal Server Error'),
2525
meta: meta)]
2626
end
@@ -35,9 +35,9 @@ def initialize(resource)
3535
def errors
3636
[JSONAPI::Error.new(code: JSONAPI::INVALID_RESOURCE,
3737
status: :bad_request,
38-
title: I18n.t('jsonapi-resources.exceptions.invalid_resource.title',
38+
title: I18n.t('jsonapi-resources.exceptions.invalid_resource.title',
3939
default: 'Invalid resource'),
40-
detail: I18n.t('jsonapi-resources.exceptions.invalid_resource.detail',
40+
detail: I18n.t('jsonapi-resources.exceptions.invalid_resource.detail',
4141
default: "#{resource} is not a valid resource.", resource: resource))]
4242
end
4343
end
@@ -51,9 +51,9 @@ def initialize(id)
5151
def errors
5252
[JSONAPI::Error.new(code: JSONAPI::RECORD_NOT_FOUND,
5353
status: :not_found,
54-
title: I18n.translate('jsonapi-resources.exceptions.record_not_found.title',
54+
title: I18n.translate('jsonapi-resources.exceptions.record_not_found.title',
5555
default: 'Record not found'),
56-
detail: I18n.translate('jsonapi-resources.exceptions.record_not_found.detail',
56+
detail: I18n.translate('jsonapi-resources.exceptions.record_not_found.detail',
5757
default: "The record identified by #{id} could not be found.", id: id))]
5858
end
5959
end
@@ -67,7 +67,7 @@ def initialize(media_type)
6767
def errors
6868
[JSONAPI::Error.new(code: JSONAPI::UNSUPPORTED_MEDIA_TYPE,
6969
status: :unsupported_media_type,
70-
title: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.title',
70+
title: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.title',
7171
default: 'Unsupported media type'),
7272
detail: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.detail',
7373
default: "All requests that create or update must use the '#{JSONAPI::MEDIA_TYPE}' Content-Type. This request specified '#{media_type}'.",
@@ -76,6 +76,26 @@ def errors
7676
end
7777
end
7878

79+
class NotAcceptableError < Error
80+
attr_accessor :media_type
81+
82+
def initialize(media_type)
83+
@media_type = media_type
84+
end
85+
86+
def errors
87+
[JSONAPI::Error.new(code: JSONAPI::NOT_ACCEPTABLE,
88+
status: :not_acceptable,
89+
title: I18n.translate('jsonapi-resources.exceptions.not_acceptable.title',
90+
default: 'Not acceptable'),
91+
detail: I18n.translate('jsonapi-resources.exceptions.not_acceptable.detail',
92+
default: "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{media_type}'.",
93+
needed_media_type: JSONAPI::MEDIA_TYPE,
94+
media_type: media_type))]
95+
end
96+
end
97+
98+
7999
class HasManyRelationExists < Error
80100
attr_accessor :id
81101
def initialize(id)
@@ -85,7 +105,7 @@ def initialize(id)
85105
def errors
86106
[JSONAPI::Error.new(code: JSONAPI::RELATION_EXISTS,
87107
status: :bad_request,
88-
title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title',
108+
title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title',
89109
default: 'Relation exists'),
90110
detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail',
91111
default: "The relation to #{id} already exists.",
@@ -97,7 +117,7 @@ class ToManySetReplacementForbidden < Error
97117
def errors
98118
[JSONAPI::Error.new(code: JSONAPI::FORBIDDEN,
99119
status: :forbidden,
100-
title: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.title',
120+
title: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.title',
101121
default: 'Complete replacement forbidden'),
102122
detail: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.detail',
103123
default: 'Complete replacement forbidden for this relationship'))]

locales/en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ en:
1010
record_not_found:
1111
title: 'Record not found'
1212
detail: "The record identified by %{id} could not be found."
13+
not_acceptable:
14+
title: 'Not acceptable'
15+
detail: "All requests must use the '%{needed_media_type}' Accept without media type parameters. This request specified '%{media_type}'."
1316
unsupported_media_type:
1417
title: 'Unsupported media type'
1518
detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}.'"

test/controllers/controller_test.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@ def test_index
1515
assert json_response['data'].is_a?(Array)
1616
end
1717

18+
def test_accept_header_missing
19+
@request.headers['Accept'] = nil
20+
21+
get :index
22+
assert_response :success
23+
end
24+
25+
def test_accept_header_jsonapi_mixed
26+
@request.headers['Accept'] =
27+
"#{JSONAPI::MEDIA_TYPE},#{JSONAPI::MEDIA_TYPE};charset=test"
28+
29+
get :index
30+
assert_response :success
31+
end
32+
33+
def test_accept_header_jsonapi_modified
34+
@request.headers['Accept'] = "#{JSONAPI::MEDIA_TYPE};charset=test"
35+
36+
get :index
37+
assert_response 406
38+
assert_equal 'Not acceptable', json_response['errors'][0]['title']
39+
assert_equal "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{@request.headers['Accept']}'.", json_response['errors'][0]['detail']
40+
end
41+
42+
def test_accept_header_jsonapi_multiple_modified
43+
@request.headers['Accept'] =
44+
"#{JSONAPI::MEDIA_TYPE};charset=test,#{JSONAPI::MEDIA_TYPE};charset=test"
45+
46+
get :index
47+
assert_response 406
48+
assert_equal 'Not acceptable', json_response['errors'][0]['title']
49+
assert_equal "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{@request.headers['Accept']}'.", json_response['errors'][0]['detail']
50+
end
51+
1852
def test_exception_class_whitelist
1953
original_config = JSONAPI.configuration.dup
2054
JSONAPI.configuration.operations_processor = :error_raising

0 commit comments

Comments
 (0)