Skip to content

Commit ad07559

Browse files
committed
Merge branch 'master' into operation_processing
# Conflicts: # lib/jsonapi/acts_as_resource_controller.rb # test/integration/requests/request_test.rb
2 parents 12ce429 + 5e4fd81 commit ad07559

15 files changed

Lines changed: 360 additions & 98 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,7 @@ module JSONAPI
13201320
SAVE_FAILED = '121'
13211321
FORBIDDEN = '403'
13221322
RECORD_NOT_FOUND = '404'
1323+
NOT_ACCEPTABLE = '406'
13231324
UNSUPPORTED_MEDIA_TYPE = '415'
13241325
LOCKED = '423'
13251326
end

jsonapi-resources.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Gem::Specification.new do |spec|
1313
spec.homepage = 'https://github.com/cerebris/jsonapi-resources'
1414
spec.license = 'MIT'
1515

16-
spec.files = `git ls-files -z`.split("\x0")
16+
spec.files = Dir.glob("{bin,lib}/**/*") + %w(LICENSE.txt README.md)
1717
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
1818
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
1919
spec.require_paths = ['lib']

lib/jsonapi/acts_as_resource_controller.rb

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

33
module JSONAPI
44
module ActsAsResourceController
5+
MEDIA_TYPE_MATCHER = /(.+".+"[^,]*|[^,]+)/
6+
ALL_MEDIA_TYPES = '*/*'
7+
58
def self.included(base)
69
base.extend ClassMethods
710
base.include Callbacks
811
base.before_action :ensure_correct_media_type, only: [:create, :update, :create_relationship, :update_relationship]
12+
base.before_action :ensure_valid_accept_media_type
913
base.cattr_reader :server_error_callbacks
1014
base.define_jsonapi_resources_callbacks :process_operations
1115
end
@@ -121,6 +125,36 @@ def ensure_correct_media_type
121125
handle_exceptions(e)
122126
end
123127

128+
def ensure_valid_accept_media_type
129+
if invalid_accept_media_type?
130+
fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept)
131+
end
132+
rescue => e
133+
handle_exceptions(e)
134+
end
135+
136+
def invalid_accept_media_type?
137+
media_types = media_types_for('Accept')
138+
139+
return false if media_types.blank? || media_types.include?(ALL_MEDIA_TYPES)
140+
141+
jsonapi_media_types = media_types.select do |media_type|
142+
media_type.include?(JSONAPI::MEDIA_TYPE)
143+
end
144+
145+
jsonapi_media_types.size.zero? ||
146+
jsonapi_media_types.none? do |media_type|
147+
media_type == JSONAPI::MEDIA_TYPE
148+
end
149+
end
150+
151+
def media_types_for(header)
152+
(request.headers[header] || '')
153+
.match(MEDIA_TYPE_MATCHER)
154+
.to_a
155+
.map(&:strip)
156+
end
157+
124158
# override to set context
125159
def context
126160
{}

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'))]

lib/jsonapi/link_builder.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def formatted_module_path_from_class(klass)
106106
scopes = module_scopes_from_class(klass)
107107

108108
unless scopes.empty?
109-
"/#{ scopes.map(&:underscore).join('/') }/"
109+
"/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.join('/') }/"
110110
else
111111
"/"
112112
end

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
$PostProcessorRaisesErrors = true

test/fixtures/active_record.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,6 +1540,13 @@ class PersonResource < JSONAPI::Resource
15401540
end
15411541
end
15421542

1543+
module DasherizedNamespace
1544+
module V1
1545+
class PersonResource < JSONAPI::Resource
1546+
end
1547+
end
1548+
end
1549+
15431550
module MyEngine
15441551
module Api
15451552
module V1
@@ -1554,6 +1561,13 @@ class PersonResource < JSONAPI::Resource
15541561
end
15551562
end
15561563
end
1564+
1565+
module DasherizedNamespace
1566+
module V1
1567+
class PersonResource < JSONAPI::Resource
1568+
end
1569+
end
1570+
end
15571571
end
15581572

15591573
module Legacy

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)