haveapi 0.28.4 → 0.29.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +139 -0
  3. data/Rakefile +1 -0
  4. data/haveapi.gemspec +2 -1
  5. data/lib/haveapi/action.rb +26 -9
  6. data/lib/haveapi/actions/default.rb +5 -2
  7. data/lib/haveapi/actions/paginable.rb +8 -4
  8. data/lib/haveapi/authentication/oauth2/provider.rb +1 -1
  9. data/lib/haveapi/authentication/token/config.rb +10 -3
  10. data/lib/haveapi/authentication/token/provider.rb +22 -21
  11. data/lib/haveapi/context.rb +4 -1
  12. data/lib/haveapi/i18n.rb +125 -0
  13. data/lib/haveapi/locales/cs.yml +167 -0
  14. data/lib/haveapi/locales/en.yml +168 -0
  15. data/lib/haveapi/metadata.rb +25 -3
  16. data/lib/haveapi/model_adapters/active_record.rb +40 -26
  17. data/lib/haveapi/output_formatter.rb +2 -2
  18. data/lib/haveapi/parameters/metadata_i18n.rb +179 -0
  19. data/lib/haveapi/parameters/resource.rb +18 -7
  20. data/lib/haveapi/parameters/typed.rb +27 -20
  21. data/lib/haveapi/params.rb +76 -7
  22. data/lib/haveapi/resource.rb +1 -1
  23. data/lib/haveapi/resources/action_state.rb +47 -27
  24. data/lib/haveapi/server.rb +156 -16
  25. data/lib/haveapi/spec/api_builder.rb +25 -0
  26. data/lib/haveapi/spec/spec_methods.rb +10 -0
  27. data/lib/haveapi/tasks/i18n.rb +198 -0
  28. data/lib/haveapi/validator_chain.rb +1 -1
  29. data/lib/haveapi/validators/acceptance.rb +5 -2
  30. data/lib/haveapi/validators/confirmation.rb +5 -2
  31. data/lib/haveapi/validators/exclusion.rb +2 -2
  32. data/lib/haveapi/validators/format.rb +5 -2
  33. data/lib/haveapi/validators/inclusion.rb +2 -2
  34. data/lib/haveapi/validators/length.rb +5 -5
  35. data/lib/haveapi/validators/numericality.rb +20 -22
  36. data/lib/haveapi/validators/presence.rb +4 -2
  37. data/lib/haveapi/version.rb +1 -1
  38. data/lib/haveapi.rb +1 -0
  39. data/spec/authentication/oauth2_spec.rb +10 -0
  40. data/spec/i18n_spec.rb +520 -0
  41. data/spec/params_spec.rb +183 -0
  42. metadata +29 -3
@@ -32,49 +32,47 @@ module HaveAPI
32
32
  @even = take(:even)
33
33
  @odd = take(:odd)
34
34
 
35
- msg = if @min && !@max
36
- "has to be minimally #{@min}"
37
-
38
- elsif !@min && @max
39
- "has to be maximally #{@max}"
40
-
41
- elsif @min && @max
42
- "has to be in range <#{@min}, #{@max}>"
43
-
44
- else
45
- 'has to be a number'
46
- end
35
+ requirements = []
36
+
37
+ requirements << if @min && !@max
38
+ HaveAPI.message('haveapi.validators.numericality.min', min: @min)
39
+ elsif !@min && @max
40
+ HaveAPI.message('haveapi.validators.numericality.max', max: @max)
41
+ elsif @min && @max
42
+ HaveAPI.message('haveapi.validators.numericality.range', min: @min, max: @max)
43
+ else
44
+ HaveAPI.message('haveapi.validators.numericality.number')
45
+ end
47
46
 
48
47
  if @step
49
- msg += '; ' unless msg.empty?
50
- msg += "in steps of #{@step}"
48
+ requirements << HaveAPI.message('haveapi.validators.numericality.step', step: @step)
51
49
  end
52
50
 
53
51
  if @mod
54
- msg += '; ' unless msg.empty?
55
- msg += "mod #{@step} must equal zero"
52
+ requirements << HaveAPI.message('haveapi.validators.numericality.mod', mod: @mod)
56
53
  end
57
54
 
58
55
  if @odd
59
- msg += '; ' unless msg.empty?
60
- msg += 'odd'
56
+ requirements << HaveAPI.message('haveapi.validators.numericality.odd')
61
57
  end
62
58
 
63
59
  if @even
64
- msg += '; ' unless msg.empty?
65
- msg += 'even'
60
+ requirements << HaveAPI.message('haveapi.validators.numericality.even')
66
61
  end
67
62
 
68
63
  if @odd && @even
69
64
  raise 'cannot be both odd and even at the same time'
70
65
  end
71
66
 
72
- @message = take(:message, msg)
67
+ @message = take(
68
+ :message,
69
+ HaveAPI.message('haveapi.validators.numericality.composite', requirements:)
70
+ )
73
71
  end
74
72
 
75
73
  def describe
76
74
  ret = {
77
- message: @message
75
+ message: HaveAPI.localize(@message)
78
76
  }
79
77
 
80
78
  ret[:min] = @min if @min
@@ -21,14 +21,16 @@ module HaveAPI
21
21
  @empty = take(:empty, false)
22
22
  @message = take(
23
23
  :message,
24
- @empty ? 'must be present' : 'must be present and non-empty'
24
+ HaveAPI.message(
25
+ @empty ? 'haveapi.validators.presence.present' : 'haveapi.validators.presence.non_empty'
26
+ )
25
27
  )
26
28
  end
27
29
 
28
30
  def describe
29
31
  {
30
32
  empty: @empty,
31
- message: @message
33
+ message: HaveAPI.localize(@message)
32
34
  }
33
35
  end
34
36
 
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.28.4'.freeze
3
+ VERSION = '0.29.0'.freeze
4
4
  end
data/lib/haveapi.rb CHANGED
@@ -13,6 +13,7 @@ module HaveAPI
13
13
  module Actions; end
14
14
  end
15
15
 
16
+ require_relative 'haveapi/i18n'
16
17
  require_relative 'haveapi/params'
17
18
  require_rel 'haveapi/parameters/'
18
19
  require_rel 'haveapi/*.rb'
@@ -218,6 +218,16 @@ describe HaveAPI::Authentication::OAuth2 do
218
218
  expect(api_response.message).to match(/too many|multiple/i)
219
219
  end
220
220
 
221
+ it 'localizes multiple token conflicts' do
222
+ header 'Accept-Language', 'cs'
223
+ header 'Authorization', 'Bearer abc'
224
+ call_api(:post, '/v1/secures/ping?access_token=abc', {})
225
+
226
+ expect(last_response.status).to eq(400)
227
+ expect(api_response).to be_failed
228
+ expect(api_response.message).to eq('Bylo poskytnuto více OAuth2 tokenů')
229
+ end
230
+
221
231
  it 'ignores structured access_token query values before backend lookup' do
222
232
  expect do
223
233
  call_api(:post, '/v1/secures/ping?access_token[]=abc', {})
data/spec/i18n_spec.rb ADDED
@@ -0,0 +1,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nSpec
4
+ User = Struct.new(:language)
5
+
6
+ class Provider < HaveAPI::Authentication::Basic::Provider
7
+ protected
8
+
9
+ def find_user(_request, username, password)
10
+ User.new(:cs) if username == 'user' && password == 'pass'
11
+ end
12
+ end
13
+ end
14
+
15
+ describe HaveAPI::I18n do
16
+ context 'with translated API responses' do
17
+ api do
18
+ define_resource(:Thing) do
19
+ version 1
20
+ auth false
21
+ desc HaveAPI.message('i18n_spec.resources.thing.desc')
22
+
23
+ define_action(:Create) do
24
+ route ''
25
+ http_method :post
26
+ desc HaveAPI.message('i18n_spec.resources.thing.actions.create.desc')
27
+
28
+ input do
29
+ string :name,
30
+ label: HaveAPI.message('i18n_spec.resources.thing.params.name.label'),
31
+ desc: HaveAPI.message('i18n_spec.resources.thing.params.name.desc'),
32
+ required: true,
33
+ length: { min: 3, max: 5 }
34
+ string :auto_label,
35
+ label: 'Automatic label fallback',
36
+ desc: 'Automatic description fallback'
37
+ string :explicit_key,
38
+ label: 'Explicit label fallback',
39
+ desc: 'Explicit description fallback',
40
+ label_key: 'i18n_spec.shared.explicit_key.label',
41
+ desc_key: 'i18n_spec.shared.explicit_key.description'
42
+ string :shared_label,
43
+ label: 'Shared label fallback',
44
+ desc: 'Shared description fallback'
45
+ string :resource_attr_label,
46
+ label: 'Resource attribute fallback',
47
+ desc: 'Resource attribute description fallback'
48
+ string :global_attr_label,
49
+ label: 'Global attribute fallback',
50
+ desc: 'Global attribute description fallback'
51
+ string :code, length: {
52
+ min: 3,
53
+ message: HaveAPI.message('haveapi.validators.length.min', min: 3)
54
+ }
55
+ integer :count
56
+ end
57
+
58
+ output do
59
+ bool :ok,
60
+ label: 'OK fallback',
61
+ desc: 'Result description fallback'
62
+ end
63
+
64
+ meta(:global) do
65
+ input do
66
+ bool :confirm,
67
+ label: 'Confirm fallback',
68
+ desc: 'Confirm description fallback'
69
+ end
70
+
71
+ output do
72
+ bool :audited,
73
+ label: 'Audited fallback',
74
+ desc: 'Audited description fallback'
75
+ bool :global_audited,
76
+ label: 'Global audited fallback',
77
+ desc: 'Global audited description fallback'
78
+ end
79
+ end
80
+
81
+ authorize { allow }
82
+
83
+ def exec
84
+ { ok: true }
85
+ end
86
+ end
87
+
88
+ define_action(:CustomError) do
89
+ route 'custom_error'
90
+ http_method :get
91
+
92
+ authorize { allow }
93
+
94
+ def exec
95
+ error!('plain custom error')
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ parameter_i18n_scope 'i18n_spec'
102
+ default_version 1
103
+
104
+ it 'keeps default English responses unchanged' do
105
+ header 'Accept', 'application/json'
106
+ call_api(:post, '/v1/things', thing: {})
107
+
108
+ expect(api_response.message).to eq('input parameters not valid')
109
+ expect(api_response.errors[:name]).to include('required parameter missing')
110
+ expect(last_response.headers['Vary']).to include('Accept-Language')
111
+ end
112
+
113
+ it 'localizes validation messages using Accept-Language' do
114
+ header 'Accept', 'application/json'
115
+ header 'Accept-Language', 'cs'
116
+ call_api(:post, '/v1/things', thing: { count: 'nope' })
117
+
118
+ expect(api_response.message).to eq('vstupní parametry nejsou platné')
119
+ expect(api_response.errors[:name]).to include('povinný parametr chybí')
120
+ expect(api_response.errors[:count].first).to include('neplatné celé číslo')
121
+ expect(last_response.headers['Vary']).to include('Accept-Language')
122
+ end
123
+
124
+ it 'normalizes regional language tags' do
125
+ header 'Accept', 'application/json'
126
+ header 'Accept-Language', 'cs-CZ,cs;q=0.9,en;q=0.5'
127
+ get '/unknown_resource'
128
+
129
+ expect(api_response.message).to eq('Akce nebyla nalezena')
130
+ end
131
+
132
+ it 'falls back to English for unsupported languages' do
133
+ header 'Accept', 'application/json'
134
+ header 'Accept-Language', 'de'
135
+ get '/unknown_resource'
136
+
137
+ expect(api_response.message).to eq('Action not found')
138
+ end
139
+
140
+ it 'localizes validator descriptions in OPTIONS responses' do
141
+ header 'Accept', 'application/json'
142
+ header 'Accept-Language', 'cs'
143
+ options '/v1/things', method: 'POST'
144
+
145
+ length = api_response[:input][:parameters][:name][:validators][:length]
146
+ expect(length[:message]).to eq('délka musí být v rozsahu <3, 5>')
147
+ end
148
+
149
+ it 'localizes API metadata in OPTIONS responses' do
150
+ previous_available = ::I18n.available_locales
151
+ ::I18n.available_locales = (previous_available + %i[en cs]).uniq
152
+ ::I18n.backend.store_translations(
153
+ :en,
154
+ i18n_spec: {
155
+ resources: {
156
+ thing: {
157
+ desc: 'Manage things',
158
+ actions: { create: { desc: 'Create a thing' } },
159
+ params: {
160
+ name: {
161
+ label: 'Name',
162
+ desc: 'Thing name'
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ )
169
+ ::I18n.backend.store_translations(
170
+ :cs,
171
+ i18n_spec: {
172
+ resources: {
173
+ thing: {
174
+ desc: 'Spravovat věci',
175
+ actions: { create: { desc: 'Vytvořit věc' } },
176
+ params: {
177
+ name: {
178
+ label: 'Název',
179
+ desc: 'Název věci'
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ )
186
+
187
+ header 'Accept', 'application/json'
188
+ header 'Accept-Language', 'cs'
189
+ call_api(:options, '/?describe=default')
190
+
191
+ resource = api_response[:resources][:thing]
192
+ action = resource[:actions][:create]
193
+ param = action[:input][:parameters][:name]
194
+
195
+ expect(resource[:description]).to eq('Spravovat věci')
196
+ expect(action[:description]).to eq('Vytvořit věc')
197
+ expect(param[:label]).to eq('Název')
198
+ expect(param[:description]).to eq('Název věci')
199
+ ensure
200
+ ::I18n.available_locales = previous_available
201
+ end
202
+
203
+ it 'localizes action parameter metadata from the server parameter scope' do
204
+ previous_available = ::I18n.available_locales
205
+ ::I18n.available_locales = (previous_available + %i[en cs]).uniq
206
+ ::I18n.backend.store_translations(
207
+ :cs,
208
+ i18n_spec: {
209
+ resources: {
210
+ thing: {
211
+ actions: {
212
+ create: {
213
+ input: {
214
+ auto_label: {
215
+ label: 'Automatický popisek',
216
+ description: 'Automatický popis'
217
+ },
218
+ global_attr_label: {
219
+ label: 'Přesný globální popisek',
220
+ description: 'Přesný globální popis'
221
+ }
222
+ },
223
+ output: {
224
+ ok: {
225
+ label: 'V pořádku',
226
+ description: 'Popis výsledku'
227
+ }
228
+ },
229
+ meta: {
230
+ global: {
231
+ input: {
232
+ confirm: {
233
+ label: 'Potvrdit',
234
+ description: 'Popis potvrzení'
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ },
241
+ input: {
242
+ shared_label: {
243
+ label: 'Sdílený popisek',
244
+ description: 'Sdílený popis'
245
+ }
246
+ },
247
+ attributes: {
248
+ resource_attr_label: {
249
+ label: 'Atribut zdroje',
250
+ description: 'Popis atributu zdroje'
251
+ }
252
+ },
253
+ meta: {
254
+ global: {
255
+ output: {
256
+ audited: {
257
+ label: 'Auditováno',
258
+ description: 'Popis auditu'
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ },
265
+ attributes: {
266
+ auto_label: {
267
+ label: 'Sdílený automatický popisek',
268
+ description: 'Sdílený automatický popis'
269
+ },
270
+ global_attr_label: {
271
+ label: 'Globální atribut',
272
+ description: 'Popis globálního atributu'
273
+ },
274
+ shared_label: {
275
+ label: 'Sdílený popisek atributu',
276
+ description: 'Sdílený popis atributu'
277
+ }
278
+ },
279
+ meta: {
280
+ global: {
281
+ output: {
282
+ global_audited: {
283
+ label: 'Globálně auditováno',
284
+ description: 'Globální popis auditu'
285
+ }
286
+ }
287
+ }
288
+ },
289
+ shared: {
290
+ explicit_key: {
291
+ label: 'Explicitní popisek',
292
+ description: 'Explicitní popis'
293
+ }
294
+ }
295
+ }
296
+ )
297
+
298
+ header 'Accept', 'application/json'
299
+ header 'Accept-Language', 'cs'
300
+ call_api(:options, '/?describe=default')
301
+
302
+ create = api_response[:resources][:thing][:actions][:create]
303
+ input_params = create[:input][:parameters]
304
+ output_params = create[:output][:parameters]
305
+ meta_input_params = create[:meta][:global][:input][:parameters]
306
+ meta_output_params = create[:meta][:global][:output][:parameters]
307
+
308
+ expect(input_params[:auto_label]).to include(
309
+ label: 'Automatický popisek',
310
+ description: 'Automatický popis'
311
+ )
312
+ expect(input_params[:explicit_key]).to include(
313
+ label: 'Explicitní popisek',
314
+ description: 'Explicitní popis'
315
+ )
316
+ expect(input_params[:shared_label]).to include(
317
+ label: 'Sdílený popisek',
318
+ description: 'Sdílený popis'
319
+ )
320
+ expect(input_params[:resource_attr_label]).to include(
321
+ label: 'Atribut zdroje',
322
+ description: 'Popis atributu zdroje'
323
+ )
324
+ expect(input_params[:global_attr_label]).to include(
325
+ label: 'Přesný globální popisek',
326
+ description: 'Přesný globální popis'
327
+ )
328
+ expect(input_params[:count]).to include(
329
+ label: 'Count',
330
+ description: nil
331
+ )
332
+ expect(output_params[:ok]).to include(
333
+ label: 'V pořádku',
334
+ description: 'Popis výsledku'
335
+ )
336
+ expect(meta_input_params[:confirm]).to include(
337
+ label: 'Potvrdit',
338
+ description: 'Popis potvrzení'
339
+ )
340
+ expect(meta_output_params[:audited]).to include(
341
+ label: 'Auditováno',
342
+ description: 'Popis auditu'
343
+ )
344
+ expect(meta_output_params[:global_audited]).to include(
345
+ label: 'Globálně auditováno',
346
+ description: 'Globální popis auditu'
347
+ )
348
+ ensure
349
+ ::I18n.available_locales = previous_available
350
+ end
351
+
352
+ it 'localizes framework action parameter metadata in OPTIONS responses' do
353
+ header 'Accept', 'application/json'
354
+ header 'Accept-Language', 'cs'
355
+ call_api(:options, '/?describe=default')
356
+
357
+ create = api_response[:resources][:thing][:actions][:create]
358
+ no_meta = create[:meta][:global][:input][:parameters][:no]
359
+
360
+ expect(no_meta[:label]).to eq('Zakázat metadata')
361
+ end
362
+
363
+ it 'localizes application-supplied lazy validator messages' do
364
+ header 'Accept', 'application/json'
365
+ header 'Accept-Language', 'cs'
366
+ call_api(:post, '/v1/things', thing: { name: 'abc', code: 'x' })
367
+
368
+ expect(api_response.errors[:code]).to include('délka musí být alespoň 3')
369
+ end
370
+
371
+ it 'leaves custom string errors unchanged' do
372
+ header 'Accept', 'application/json'
373
+ header 'Accept-Language', 'cs'
374
+ get '/v1/things/custom_error'
375
+
376
+ expect(api_response.message).to eq('plain custom error')
377
+ end
378
+
379
+ it 'works when host applications constrain global I18n locales' do
380
+ previous_available = ::I18n.available_locales
381
+ previous_locale = ::I18n.locale
382
+ ::I18n.available_locales = [:en]
383
+
384
+ header 'Accept', 'application/json'
385
+ header 'Accept-Language', 'cs'
386
+ get '/unknown_resource'
387
+
388
+ expect(api_response.message).to eq('Akce nebyla nalezena')
389
+ ensure
390
+ ::I18n.available_locales = (Array(previous_available) + [previous_locale]).uniq
391
+ ::I18n.locale = previous_locale
392
+ ::I18n.available_locales = previous_available
393
+ end
394
+
395
+ it 'restores the ambient I18n locale after each request' do
396
+ previous_available = ::I18n.available_locales
397
+ previous_locale = ::I18n.locale
398
+ ::I18n.available_locales = (previous_available + [:cs]).uniq
399
+ ::I18n.locale = :cs
400
+
401
+ header 'Accept', 'application/json'
402
+ get '/unknown_resource'
403
+
404
+ expect(api_response.message).to eq('Action not found')
405
+ expect(::I18n.locale).to eq(:cs)
406
+ ensure
407
+ ::I18n.available_locales = (Array(previous_available) + [previous_locale]).uniq
408
+ ::I18n.locale = previous_locale
409
+ ::I18n.available_locales = previous_available
410
+ end
411
+
412
+ it 'localizes nested message values without changing surrounding structure' do
413
+ previous_locale = ::I18n.locale
414
+ ::I18n.locale = :cs
415
+
416
+ data = {
417
+ message: HaveAPI.message('haveapi.errors.action_not_found'),
418
+ errors: {
419
+ name: [HaveAPI.message('haveapi.validation.required_parameter_missing')]
420
+ }
421
+ }
422
+
423
+ expect(HaveAPI.localize(data)).to eq({
424
+ message: 'Akce nebyla nalezena',
425
+ errors: {
426
+ name: ['povinný parametr chybí']
427
+ }
428
+ })
429
+ ensure
430
+ ::I18n.locale = previous_locale
431
+ end
432
+ end
433
+
434
+ context 'with a locale resolver' do
435
+ empty_api
436
+
437
+ locale do |**_|
438
+ :cs
439
+ end
440
+
441
+ it 'uses the resolver when no language is requested explicitly' do
442
+ header 'Accept', 'application/json'
443
+ get '/unknown_resource'
444
+
445
+ expect(api_response.message).to eq('Akce nebyla nalezena')
446
+ end
447
+
448
+ it 'does not use the resolver when an unsupported language is requested explicitly' do
449
+ ['de', '%%%'].each do |language|
450
+ header 'Accept', 'application/json'
451
+ header 'Accept-Language', language
452
+ get '/unknown_resource'
453
+
454
+ expect(api_response.message).to eq('Action not found')
455
+ end
456
+ end
457
+ end
458
+
459
+ context 'with an authenticated locale resolver' do
460
+ api do
461
+ define_resource(:Thing) do
462
+ version 1
463
+ auth false
464
+
465
+ define_action(:Create) do
466
+ route ''
467
+ http_method :post
468
+
469
+ input do
470
+ string :name, required: true, length: { min: 3, max: 5 }
471
+ end
472
+
473
+ authorize { allow }
474
+ end
475
+ end
476
+ end
477
+
478
+ default_version 1
479
+ auth_chain I18nSpec::Provider
480
+
481
+ locale do |current_user:, default_locale:, **_|
482
+ current_user&.language || default_locale
483
+ end
484
+
485
+ it 'uses the authenticated user for root self-description locale fallback' do
486
+ login('user', 'pass')
487
+ header 'Accept', 'application/json'
488
+ call_api(:options, '/?describe=default')
489
+
490
+ action = api_response[:resources][:thing][:actions][:create]
491
+ length = action.dig(:input, :parameters, :name, :validators, :length)
492
+ expect(length[:message]).to eq('délka musí být v rozsahu <3, 5>')
493
+ end
494
+ end
495
+
496
+ context 'with a custom locale header' do
497
+ empty_api
498
+ locale_header 'X-Accept-Language'
499
+
500
+ it 'uses the configured header for locale negotiation and Vary' do
501
+ header 'Accept', 'application/json'
502
+ header 'X-Accept-Language', 'cs'
503
+ get '/unknown_resource'
504
+
505
+ expect(api_response.message).to eq('Akce nebyla nalezena')
506
+ expect(last_response.headers['Vary']).to eq('X-Accept-Language')
507
+ end
508
+
509
+ it 'allows the configured header in CORS preflight responses' do
510
+ header 'Accept', 'application/json'
511
+ header 'Origin', 'https://example.com'
512
+ header 'Access-Control-Request-Method', 'GET'
513
+ header 'Access-Control-Request-Headers', 'X-Accept-Language'
514
+ options '/'
515
+
516
+ allowed_headers = last_response.headers['access-control-allow-headers'].split(',')
517
+ expect(allowed_headers).to include('X-Accept-Language')
518
+ end
519
+ end
520
+ end