infinum_json_api_setup 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +45 -0
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +1 -1
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +50 -12
  7. data/Gemfile +1 -0
  8. data/Gemfile.lock +1 -0
  9. data/README.md +8 -0
  10. data/Rakefile +3 -0
  11. data/base.gemfile +14 -0
  12. data/infinum_json_api_setup.gemspec +11 -16
  13. data/lib/generators/infinum_json_api_setup/templates/config/locales/json_api.en.yml +1 -0
  14. data/lib/infinum_json_api_setup/json_api/content_negotiation.rb +2 -2
  15. data/lib/infinum_json_api_setup/json_api/locale_negotiation.rb +45 -0
  16. data/lib/infinum_json_api_setup/json_api/serializer_options.rb +1 -1
  17. data/lib/infinum_json_api_setup/rspec/helpers/response_helper.rb +4 -4
  18. data/lib/infinum_json_api_setup/rspec/matchers/have_empty_data.rb +1 -1
  19. data/lib/infinum_json_api_setup/rspec/matchers/have_error_pointer.rb +1 -1
  20. data/lib/infinum_json_api_setup/rspec/matchers/have_resource_count_of.rb +1 -1
  21. data/lib/infinum_json_api_setup/version.rb +1 -1
  22. data/lib/infinum_json_api_setup.rb +3 -0
  23. data/rails.7.1.gemfile +8 -0
  24. data/rails.7.1.gemfile.lock +361 -0
  25. data/rails.8.0.gemfile +7 -0
  26. data/rails.8.0.gemfile.lock +380 -0
  27. data/spec/dummy/app/controllers/api/v1/base_controller.rb +1 -0
  28. data/spec/dummy/app/controllers/api/v1/hello_controller.rb +11 -0
  29. data/spec/dummy/app/controllers/api/v1/locations_controller.rb +7 -13
  30. data/spec/dummy/app/models/location.rb +1 -1
  31. data/spec/dummy/config/database.yml +3 -0
  32. data/spec/dummy/config/environments/production.rb +1 -1
  33. data/spec/dummy/config/initializers/i18n.rb +3 -0
  34. data/spec/dummy/config/locales/de.yml +2 -0
  35. data/spec/dummy/config/locales/json_api.de.yml +22 -0
  36. data/spec/dummy/config/locales/json_api.en.yml +1 -0
  37. data/spec/dummy/config/routes.rb +1 -0
  38. data/spec/infinum_json_api_setup/rspec/helpers/response_helper_spec.rb +234 -0
  39. data/spec/infinum_json_api_setup/rspec/matchers/have_empty_data_spec.rb +3 -2
  40. data/spec/rails_helper.rb +2 -6
  41. data/spec/requests/api/v1/content_negotiation_spec.rb +10 -2
  42. data/spec/requests/api/v1/error_handling_spec.rb +37 -19
  43. data/spec/requests/api/v1/locale_negotiation_spec.rb +66 -0
  44. data/spec/requests/api/v1/responder_spec.rb +1 -1
  45. data/spec/requests/api/v1/serializer_options_spec.rb +1 -1
  46. metadata +46 -99
  47. data/Gemfile +0 -10
  48. data/Gemfile.lock +0 -268
@@ -0,0 +1,234 @@
1
+ describe InfinumJsonApiSetup::Rspec::Helpers::ResponseHelper do
2
+ include described_class
3
+
4
+ let(:response) { instance_double('Response', body: JSON.dump(payload)) } # rubocop:disable RSpec/RSpec/VerifiedDoubleReference
5
+
6
+ describe '#json_response' do
7
+ let(:payload) do
8
+ {
9
+ data: {
10
+ id: '1',
11
+ type: 'locations',
12
+ attributes: { name: 'Downtown HQ' }
13
+ }
14
+ }
15
+ end
16
+
17
+ it 'parses the response body with symbolized keys' do
18
+ expect(json_response).to eq(payload)
19
+ end
20
+ end
21
+
22
+ describe '#response_item' do
23
+ context 'when the response contains a single resource' do
24
+ let(:payload) do
25
+ {
26
+ data: {
27
+ id: '1',
28
+ type: 'locations',
29
+ attributes: { name: 'Downtown HQ', city: 'Zagreb' }
30
+ }
31
+ }
32
+ end
33
+
34
+ it 'returns an OpenStruct built from the resource attributes' do
35
+ expect(response_item).to have_attributes(name: 'Downtown HQ', city: 'Zagreb')
36
+ end
37
+ end
38
+
39
+ context 'when the response contains a collection' do
40
+ let(:payload) do
41
+ {
42
+ data: [
43
+ { id: '1', type: 'locations', attributes: { name: 'Downtown HQ' } }
44
+ ]
45
+ }
46
+ end
47
+
48
+ it 'raises an informative error' do
49
+ expect { response_item }.to raise_error('json response is not an item')
50
+ end
51
+ end
52
+ end
53
+
54
+ describe '#response_collection' do
55
+ context 'when the response contains a collection' do
56
+ let(:payload) do
57
+ {
58
+ data: [
59
+ { id: '1', type: 'locations', attributes: { name: 'Downtown HQ' } },
60
+ { id: '2', type: 'locations', attributes: { name: 'Suburb Office' } }
61
+ ]
62
+ }
63
+ end
64
+
65
+ it 'wraps each resource in an OpenStruct including id and type' do
66
+ expect(response_collection).to contain_exactly(
67
+ have_attributes(id: '1', type: 'locations', name: 'Downtown HQ'),
68
+ have_attributes(id: '2', type: 'locations', name: 'Suburb Office')
69
+ )
70
+ end
71
+ end
72
+
73
+ context 'when the response contains a single resource' do
74
+ let(:payload) do
75
+ {
76
+ data: {
77
+ id: '1',
78
+ type: 'locations',
79
+ attributes: { name: 'Downtown HQ' }
80
+ }
81
+ }
82
+ end
83
+
84
+ it 'raises an informative error' do
85
+ expect { response_collection }.to raise_error('json response is not a collection')
86
+ end
87
+ end
88
+ end
89
+
90
+ describe '#response_relationships' do
91
+ context 'when the response contains a single resource' do
92
+ let(:payload) do
93
+ {
94
+ data: {
95
+ relationships: {
96
+ label: { data: { id: '2', type: 'location_labels' } }
97
+ }
98
+ }
99
+ }
100
+ end
101
+
102
+ it 'returns the relationships hash for the primary resource' do
103
+ expect(response_relationships).to eq(payload[:data][:relationships])
104
+ end
105
+ end
106
+
107
+ context 'when the response contains a collection' do
108
+ let(:payload) do
109
+ {
110
+ data: [
111
+ { relationships: { label: { data: { id: '2', type: 'location_labels' } } } },
112
+ { relationships: { label: { data: { id: '3', type: 'location_labels' } } } }
113
+ ]
114
+ }
115
+ end
116
+
117
+ it 'returns the relationships for each resource' do
118
+ expect(response_relationships(response_type: :collection)).to eq(
119
+ payload[:data].pluck(:relationships)
120
+ )
121
+ end
122
+ end
123
+
124
+ context 'when an unsupported response type is requested' do
125
+ let(:payload) do
126
+ {
127
+ data: {
128
+ relationships: {
129
+ label: { data: { id: '2', type: 'location_labels' } }
130
+ }
131
+ }
132
+ }
133
+ end
134
+
135
+ it 'raises an argument error' do
136
+ expect { response_relationships(response_type: :unknown) }
137
+ .to raise_error(ArgumentError, ':response_type must be one of [:item, :collection]')
138
+ end
139
+ end
140
+ end
141
+
142
+ describe '#response_meta' do
143
+ let(:payload) do
144
+ {
145
+ data: {},
146
+ meta: { current_page: 2, total_pages: 5 }
147
+ }
148
+ end
149
+
150
+ it 'returns the meta section of the response' do
151
+ expect(response_meta).to eq(payload[:meta])
152
+ end
153
+ end
154
+
155
+ describe '#response_included' do
156
+ let(:payload) do
157
+ {
158
+ data: {},
159
+ included: [
160
+ {
161
+ id: '2',
162
+ type: 'location_labels',
163
+ attributes: { name: 'HQ Label' }
164
+ }
165
+ ]
166
+ }
167
+ end
168
+
169
+ it 'returns included resources as OpenStruct instances' do
170
+ expect(response_included.first).to have_attributes(id: '2', type: 'location_labels', name: 'HQ Label')
171
+ end
172
+ end
173
+
174
+ describe '#response_included_relationship' do
175
+ context 'when the relationship exists in the included section' do
176
+ let(:payload) do
177
+ {
178
+ data: {
179
+ relationships: {
180
+ label: { data: { id: '2', type: 'location_labels' } }
181
+ }
182
+ },
183
+ included: [
184
+ {
185
+ id: '2',
186
+ type: 'location_labels',
187
+ attributes: { name: 'HQ Label' }
188
+ }
189
+ ]
190
+ }
191
+ end
192
+
193
+ it 'returns the matching included resource' do
194
+ result = response_included_relationship(:label)
195
+
196
+ expect(result).to have_attributes(id: '2', type: 'location_labels', name: 'HQ Label')
197
+ end
198
+ end
199
+
200
+ context 'when the relationship data is nil' do
201
+ let(:payload) do
202
+ {
203
+ data: {
204
+ relationships: {
205
+ label: { data: nil }
206
+ }
207
+ },
208
+ included: []
209
+ }
210
+ end
211
+
212
+ it 'returns nil' do
213
+ expect(response_included_relationship(:label)).to be_nil
214
+ end
215
+ end
216
+
217
+ context 'when no matching included resource is present' do
218
+ let(:payload) do
219
+ {
220
+ data: {
221
+ relationships: {
222
+ label: { data: { id: '99', type: 'location_labels' } }
223
+ }
224
+ },
225
+ included: []
226
+ }
227
+ end
228
+
229
+ it 'returns nil' do
230
+ expect(response_included_relationship(:label)).to be_nil
231
+ end
232
+ end
233
+ end
234
+ end
@@ -12,11 +12,12 @@ describe InfinumJsonApiSetup::RSpec::Matchers::HaveEmptyData do
12
12
 
13
13
  context "when data isn't empty" do
14
14
  it 'fails and describes failure reason' do
15
- response = response_with_body(JSON.dump(data: { a: 1 }))
15
+ data = { 'a' => 1 }
16
+ response = response_with_body(JSON.dump(data:))
16
17
 
17
18
  expect do
18
19
  expect(response).to have_empty_data
19
- end.to fail_with("Expected response data({\"a\"=>1}) to be empty, but isn't")
20
+ end.to fail_with("Expected response data(#{data}) to be empty, but isn't")
20
21
  end
21
22
  end
22
23
 
data/spec/rails_helper.rb CHANGED
@@ -27,20 +27,16 @@ require 'pundit'
27
27
  # directory. Alternatively, in the individual `*_spec.rb` files, manually
28
28
  # require only the support files necessary.
29
29
  #
30
- Dir[File.expand_path('support/**/*.rb', __dir__)].sort.each { |f| require f }
30
+ Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f }
31
31
 
32
32
  # Checks for pending migrations and applies them before tests are run.
33
33
  # If you are not using ActiveRecord, you can remove these lines.
34
34
  begin
35
35
  ActiveRecord::Migration.maintain_test_schema!
36
36
  rescue ActiveRecord::PendingMigrationError => e
37
- puts e.to_s.strip
38
- exit 1
37
+ abort e.to_s.strip
39
38
  end
40
39
  RSpec.configure do |config|
41
- # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
42
- config.fixture_path = "#{::Rails.root}/spec/fixtures"
43
-
44
40
  # If you're not using ActiveRecord, or you'd prefer not to run each of your
45
41
  # examples within a transaction, remove the following line or assign false
46
42
  # instead of true.
@@ -1,10 +1,18 @@
1
- describe 'Content negotiation', type: :request do
1
+ describe 'Content negotiation' do
2
2
  it 'passes through requests demanding JSON:API compliant response' do
3
3
  get '/api/v1/locations', headers: { accept: 'application/vnd.api+json' }
4
4
 
5
5
  expect(response).to have_http_status(:ok)
6
6
  end
7
7
 
8
+ it 'passes through requests containing JSON:API compliant body' do
9
+ post '/api/v1/locations', params: { location: { latitude: 1, longitude: 1 } }.to_json,
10
+ headers: { accept: 'application/vnd.api+json',
11
+ 'content-type': 'application/vnd.api+json; charset=utf-8' }
12
+
13
+ expect(response).to have_http_status(:created)
14
+ end
15
+
8
16
  it 'responds with 406 NotAcceptable to requests demanding non JSON:API compliant reponse' do
9
17
  get '/api/v1/locations', headers: { accept: 'application/json' }
10
18
 
@@ -12,7 +20,7 @@ describe 'Content negotiation', type: :request do
12
20
  end
13
21
 
14
22
  it 'responds with 415 UnsupportedMediaType to requests containing non JSON:API compliant body' do
15
- post '/api/v1/locations', params: { user: { name: 'Harry' } },
23
+ post '/api/v1/locations', params: { location: { latitude: 1, longitude: 1 } },
16
24
  headers: { accept: 'application/vnd.api+json',
17
25
  'content-type': 'application/x-www-form-urlencoded' }
18
26
 
@@ -1,12 +1,11 @@
1
- describe 'Error handling', type: :request do
1
+ describe 'Error handling' do
2
2
  context "when request doesn't contain required parameters" do
3
3
  it 'responds with 400 BadRequest' do
4
4
  post '/api/v1/locations', params: {}, headers: default_headers
5
5
 
6
6
  expect(response).to have_http_status(:bad_request)
7
7
  error = json_response['errors'].first
8
- expect(error['title']).to eq('Bad Request')
9
- expect(error['detail']).to match('param is missing or the value is empty: location')
8
+ expect(error).to include('title' => 'Bad Request', 'detail' => match(/param is missing or the value is empty/))
10
9
  end
11
10
  end
12
11
 
@@ -17,7 +16,7 @@ describe 'Error handling', type: :request do
17
16
  post '/api/v1/locations', params: { location: params }.to_json, headers: default_headers
18
17
 
19
18
  expect(response).to have_http_status(:unprocessable_entity)
20
- expect(json_response['errors'].map { |details| details['source'] }).to contain_exactly(
19
+ expect(json_response['errors'].pluck('source')).to contain_exactly(
21
20
  { 'parameter' => 'latitude', 'pointer' => 'data/attributes/latitude' },
22
21
  { 'parameter' => 'longitude', 'pointer' => 'data/attributes/longitude' }
23
22
  )
@@ -29,25 +28,44 @@ describe 'Error handling', type: :request do
29
28
  get '/api/v1/locations/0', headers: default_headers
30
29
 
31
30
  expect(response).to have_http_status(:not_found)
32
- expect(json_response['errors'].first['title']).to eq('Not found')
33
- expect(json_response['errors'].first['detail']).to eq('Resource not found')
31
+ expect(json_response['errors'].first).to include('title' => 'Not found', 'detail' => 'Resource not found')
32
+ end
33
+
34
+ context 'with another locale' do
35
+ it 'responds with localized error message' do
36
+ get '/api/v1/locations/0', headers: default_headers.merge('Accept-Language' => 'de')
37
+
38
+ expect(response).to have_http_status(:not_found)
39
+ expect(json_response['errors'].first).to include('title' => 'Nicht gefunden',
40
+ 'detail' => 'Ressource nicht gefunden')
41
+ end
34
42
  end
35
43
  end
36
44
 
37
45
  context 'when client is not authorized to perform requested action' do
46
+ let(:loc) { create(:location, :fourth_quadrant) }
47
+
38
48
  it 'responds with 403 Forbidden' do
39
- loc = create(:location, :fourth_quadrant)
40
49
  get "/api/v1/locations/#{loc.id}", headers: default_headers
41
50
 
42
51
  expect(response).to have_http_status(:forbidden)
43
- expect(json_response['errors'].first['title']).to eq('Forbidden')
44
- expect(json_response['errors'].first['detail'])
45
- .to eq('You are not allowed to perform this action')
52
+ expect(json_response['errors'].first).to include('title' => 'Forbidden',
53
+ 'detail' => 'You are not allowed to perform this action')
54
+ end
55
+
56
+ context 'with another locale' do
57
+ it 'responds with localized error message' do
58
+ get "/api/v1/locations/#{loc.id}", headers: default_headers.merge('Accept-Language' => 'de')
59
+
60
+ expect(response).to have_http_status(:forbidden)
61
+ expect(json_response['errors'].first).to include('title' => 'Verboten',
62
+ 'detail' => 'Sie dürfen diese Aktion nicht ausführen')
63
+ end
46
64
  end
47
65
  end
48
66
 
49
67
  context 'when request contains unpermitted sort params' do
50
- let(:bugsnag) { class_double('Bugsnag', notify: nil) }
68
+ let(:bugsnag) { class_double('Bugsnag', notify: nil) } # rubocop:disable RSpec/VerifiedDoubleReference
51
69
 
52
70
  before do
53
71
  stub_const('Bugsnag', bugsnag)
@@ -64,14 +82,14 @@ describe 'Error handling', type: :request do
64
82
 
65
83
  expect(response).to have_http_status(:bad_request)
66
84
  error = json_response['errors'].first
67
- expect(error['title']).to eq('Bad Request')
68
- expect(error['detail']).to eq('title is not a permitted sort attribute')
85
+ expect(error).to include('title' => 'Bad Request',
86
+ 'detail' => 'title is not a permitted sort attribute')
69
87
  end
70
88
  end
71
89
 
72
90
  context 'when action processing causes PG::Error' do
73
- let(:location_model) { class_double('Location') }
74
- let(:bugsnag) { class_double('Bugsnag', notify: nil) }
91
+ let(:location_model) { class_double(Location) }
92
+ let(:bugsnag) { class_double('Bugsnag', notify: nil) } # rubocop:disable RSpec/VerifiedDoubleReference
75
93
 
76
94
  before do
77
95
  stub_const('Bugsnag', bugsnag)
@@ -89,14 +107,14 @@ describe 'Error handling', type: :request do
89
107
  get '/api/v1/locations/0', headers: default_headers
90
108
 
91
109
  expect(response).to have_http_status(:internal_server_error)
92
- expect(json_response['errors'].first['title']).to eq('Internal Server Error')
93
- expect(json_response['errors'].first['detail']).to eq('Something went wrong')
110
+ expect(json_response['errors'].first).to include('title' => 'Internal Server Error',
111
+ 'detail' => 'Something went wrong')
94
112
  end
95
113
  end
96
114
 
97
115
  context 'when client requests invalid locale' do
98
- it 'responds with 500 InternalServerError' do
99
- get '/api/v1/locations?locale=--', headers: default_headers
116
+ it 'responds with 400 BadRequest' do
117
+ get '/api/v1/locations', headers: default_headers.merge('Accept-Language' => 'fr')
100
118
 
101
119
  expect(response).to have_http_status(:bad_request)
102
120
  expect(json_response['errors'].first['title']).to eq('Bad Request')
@@ -0,0 +1,66 @@
1
+ describe 'Locale negotiation' do
2
+ describe 'with localized content' do
3
+ it 'returns English hello message when Accept-Language is en' do
4
+ get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'en')
5
+
6
+ expect(response).to have_http_status(:ok)
7
+
8
+ expect(json_response['data']['attributes']['message']).to eq('Hello world')
9
+ end
10
+
11
+ it 'returns German hello message when Accept-Language is de' do
12
+ get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'de')
13
+
14
+ expect(response).to have_http_status(:ok)
15
+
16
+ expect(json_response['data']['attributes']['message']).to eq('Hallo Welt')
17
+ end
18
+
19
+ it 'returns default English message when no Accept-Language header provided' do
20
+ get '/api/v1/hello', headers: default_headers
21
+
22
+ expect(response).to have_http_status(:ok)
23
+
24
+ expect(json_response['data']['attributes']['message']).to eq('Hello world')
25
+ end
26
+
27
+ it 'prioritizes first valid locale from complex Accept-Language header' do
28
+ get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'de-DE,de;q=0.9,en;q=0.8')
29
+
30
+ expect(response).to have_http_status(:ok)
31
+
32
+ expect(json_response['data']['attributes']['message']).to eq('Hallo Welt')
33
+ end
34
+ end
35
+
36
+ describe 'error handling' do
37
+ it 'responds with 400 Bad Request when Accept-Language header is malformed' do
38
+ get '/api/v1/hello', headers: default_headers.merge('Accept-Language': '123-invalid')
39
+
40
+ expect(response).to have_http_status(:bad_request)
41
+ expect(response).to include_error_detail('Invalid locale')
42
+ end
43
+
44
+ it 'responds with 400 Bad Request and error message for invalid locale' do
45
+ get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'fr')
46
+
47
+ expect(response).to have_http_status(:bad_request)
48
+ expect(response).to include_error_detail('Invalid locale')
49
+ end
50
+
51
+ context 'when fallback to default is enabled' do
52
+ around do |example|
53
+ Api::V1::HelloController.fallback_to_default_locale_if_invalid = true
54
+ example.run
55
+ Api::V1::HelloController.fallback_to_default_locale_if_invalid = false
56
+ end
57
+
58
+ it 'responds with 200 OK and default locale body' do
59
+ get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'fr')
60
+
61
+ expect(response).to have_http_status(:ok)
62
+ expect(json_response['data']['attributes']['message']).to eq('Hello world')
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,4 +1,4 @@
1
- describe 'Responder', type: :request do
1
+ describe 'Responder' do
2
2
  context 'when GET request' do
3
3
  it 'responds with 200 OK' do
4
4
  get '/api/v1/locations', headers: default_headers
@@ -1,7 +1,7 @@
1
1
  require 'cgi'
2
2
  require 'uri'
3
3
 
4
- describe 'Serializer options', type: :request do
4
+ describe 'Serializer options' do
5
5
  it 'adds meta with pagination information' do
6
6
  get '/api/v1/locations', params: {}, headers: default_headers
7
7