Skip to content

Commit a4f8588

Browse files
committed
Merge pull request #700 from jerelmiller/fix-media-type-validations
Ensure media type of Accept header is valid according to the JSON API spec
2 parents df16278 + bf2e059 commit a4f8588

10 files changed

Lines changed: 298 additions & 96 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,7 @@ module JSONAPI
13551355
SAVE_FAILED = '121'
13561356
FORBIDDEN = '403'
13571357
RECORD_NOT_FOUND = '404'
1358+
NOT_ACCEPTABLE = '406'
13581359
UNSUPPORTED_MEDIA_TYPE = '415'
13591360
LOCKED = '423'
13601361
end

lib/jsonapi/acts_as_resource_controller.rb

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

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

68
def self.included(base)
79
base.extend ClassMethods
810
base.before_action :ensure_correct_media_type, only: [:create, :update, :create_relationship, :update_relationship]
11+
base.before_action :ensure_valid_accept_media_type
912
base.cattr_reader :server_error_callbacks
1013
end
1114

@@ -99,6 +102,36 @@ def ensure_correct_media_type
99102
handle_exceptions(e)
100103
end
101104

105+
def ensure_valid_accept_media_type
106+
if invalid_accept_media_type?
107+
fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept)
108+
end
109+
rescue => e
110+
handle_exceptions(e)
111+
end
112+
113+
def invalid_accept_media_type?
114+
media_types = media_types_for('Accept')
115+
116+
return false if media_types.blank? || media_types.include?(ALL_MEDIA_TYPES)
117+
118+
jsonapi_media_types = media_types.select do |media_type|
119+
media_type.include?(JSONAPI::MEDIA_TYPE)
120+
end
121+
122+
jsonapi_media_types.size.zero? ||
123+
jsonapi_media_types.none? do |media_type|
124+
media_type == JSONAPI::MEDIA_TYPE
125+
end
126+
end
127+
128+
def media_types_for(header)
129+
(request.headers[header] || '')
130+
.match(MEDIA_TYPE_MATCHER)
131+
.to_a
132+
.map(&:strip)
133+
end
134+
102135
# override to set context
103136
def context
104137
{}

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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,56 @@ 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+
52+
def test_accept_header_all
53+
@request.headers['Accept'] = "*/*"
54+
55+
get :index
56+
assert_response :success
57+
end
58+
59+
def test_accept_header_not_jsonapi
60+
@request.headers['Accept'] = 'text/plain'
61+
62+
get :index
63+
assert_response 406
64+
assert_equal 'Not acceptable', json_response['errors'][0]['title']
65+
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']
66+
end
67+
1868
def test_exception_class_whitelist
1969
original_config = JSONAPI.configuration.dup
2070
JSONAPI.configuration.operations_processor = :error_raising

test/integration/requests/namespaced_model_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def setup
66
end
77

88
def test_get_flat_posts
9-
get '/flat_posts'
9+
get '/flat_posts', headers: { 'Accept' => JSONAPI::MEDIA_TYPE }
1010
assert_equal 200, status
1111
assert_equal "flat_posts", json_response["data"].first["type"]
1212
end

0 commit comments

Comments
 (0)