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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12a166f4b2cfae2e1046011ec072c2d3cfe2bae2c6e22aa7ccbb8a3a772fc4cd
4
- data.tar.gz: 919fcc67646a1c52b1410500a74e27b3628403e97f97c095523d78bef4046210
3
+ metadata.gz: b5fea26ee9dde153cf44881ba46fe548ebe63077029d787e9b8e82ac19295d27
4
+ data.tar.gz: 9314173d105633354963d996f0d8f4ceb70d5ec30c22684ced53052d694d9463
5
5
  SHA512:
6
- metadata.gz: 8f818bba5c50921fa6dc188345aabed20988fa499af2de594ad3f44af0614bd0ffe94aec7a13558bc72492f46b2fb1ce6c92b24d7a53af5be964e056d0a1a2fa
7
- data.tar.gz: 38095fce8f5b1abfa3d630f5188bbed4d1203ea5ce70ec2f8a2f59faab16c1edfb863441a77a87cd1a5155e623331e32a45e434f163d3ed03cb4c3ac3cb77a79
6
+ metadata.gz: 6b1fb3f7ea1140e86091161f740399c258540036c5dccd04e2c9c6c632d0ef8f5e9cd4ab3cf9ecfec7f2952f109b50716fd255bb22fd385efea4c2ea47df6999
7
+ data.tar.gz: e68a162dc87376dccfc8b5db1cc190aae4e1a47ff18029f21325b7dc2dc063aa522af594cedf0bec2a2f7d0c00d1df4f75151405a83e32821f7289ad4a14775b
data/README.md CHANGED
@@ -224,6 +224,145 @@ This should start the application using WEBrick. Check
224
224
 
225
225
  and more.
226
226
 
227
+ ## Localization
228
+
229
+ HaveAPI can translate framework-owned response messages, validation errors and
230
+ validator descriptions using Ruby `i18n`. The JSON envelope shape does not
231
+ change; `message`, `errors` and self-description validator messages are still
232
+ plain strings. Existing application-supplied strings passed to `error!` or
233
+ custom validator `message:` options are returned unchanged.
234
+
235
+ English is the default locale. Czech translations are bundled and can be
236
+ selected with `Accept-Language: cs` or a regional tag such as
237
+ `Accept-Language: cs-CZ`.
238
+
239
+ ```ruby
240
+ api = HaveAPI::Server.new(MyAPI)
241
+
242
+ api.default_locale = :en
243
+ api.available_locales = %i[en cs]
244
+ api.locale_header = 'Accept-Language'
245
+
246
+ api.locale do |request:, current_user:, default_locale:|
247
+ current_user&.language&.code || default_locale
248
+ end
249
+ ```
250
+
251
+ The explicit request header has precedence over the resolver. The resolver is
252
+ called again after authentication, so applications can use authenticated user
253
+ preferences when the client did not request a locale. If `locale_header` is set
254
+ to a custom header name, HaveAPI also allows that header in CORS preflight
255
+ responses. The header value uses the same syntax as `Accept-Language`.
256
+
257
+ Actions can opt into application translations by passing lazy messages to
258
+ `error!` or validator options:
259
+
260
+ ```ruby
261
+ error!(api_message('my_api.errors.quota_exceeded', limit: max_limit))
262
+
263
+ input do
264
+ string :name, required: {
265
+ message: HaveAPI.message('my_api.validation.name_required')
266
+ }
267
+ end
268
+ ```
269
+
270
+ Use `api_t(key, **values)` when an immediate string translation is needed in an
271
+ action. Use `HaveAPI.message(key, **values)` in class-level DSL blocks such as
272
+ `input`.
273
+
274
+ Action parameter labels and descriptions can be translated from the
275
+ self-description context. Set `parameter_i18n_scope` to an application locale
276
+ namespace and keep the existing English labels/descriptions as fallbacks:
277
+
278
+ ```ruby
279
+ api.parameter_i18n_scope = 'my_api'
280
+
281
+ input do
282
+ string :hostname,
283
+ label: 'Hostname',
284
+ desc: 'VPS hostname'
285
+ end
286
+ ```
287
+
288
+ HaveAPI first looks up the exact action parameter key:
289
+
290
+ ```yaml
291
+ cs:
292
+ my_api:
293
+ resources:
294
+ vps:
295
+ actions:
296
+ create:
297
+ input:
298
+ hostname:
299
+ label: "Hostname"
300
+ description: "Nazev VPS"
301
+ ```
302
+
303
+ The generated path is
304
+ `resources.<resource_path>.actions.<action>.<input|output>.<name>`.
305
+ Metadata parameters include the metadata type and direction, for example
306
+ `resources.vps.actions.create.meta.global.output.action_state_id.label`.
307
+
308
+ If the exact key is missing, HaveAPI falls back to resource input/output keys,
309
+ resource attributes and then shared attributes:
310
+
311
+ ```yaml
312
+ cs:
313
+ my_api:
314
+ resources:
315
+ vps:
316
+ input:
317
+ hostname:
318
+ label: "Hostname"
319
+ description: "Nazev VPS"
320
+ output:
321
+ hostname:
322
+ label: "Hostname"
323
+ attributes:
324
+ hostname:
325
+ label: "Hostname"
326
+ attributes:
327
+ hostname:
328
+ label: "Hostname"
329
+ ```
330
+
331
+ Metadata parameters fall back through resource and global metadata keys:
332
+
333
+ ```yaml
334
+ cs:
335
+ my_api:
336
+ resources:
337
+ vps:
338
+ meta:
339
+ global:
340
+ output:
341
+ count:
342
+ label: "Return item count"
343
+ meta:
344
+ global:
345
+ output:
346
+ count:
347
+ label: "Return item count"
348
+ ```
349
+
350
+ Framework-owned HaveAPI metadata, such as pagination and built-in meta
351
+ parameters, is translated by HaveAPI itself and is not looked up in the
352
+ application parameter scope. Use `label_key` and `desc_key` when a parameter
353
+ needs an explicit key that is not derived from its action location:
354
+
355
+ ```ruby
356
+ string :hostname,
357
+ label: 'Hostname',
358
+ desc: 'VPS hostname',
359
+ label_key: 'my_api.attributes.hostname.label',
360
+ desc_key: 'my_api.attributes.hostname.description'
361
+ ```
362
+
363
+ HaveAPI also exposes `api.parameter_metadata_i18n_items` for maintenance tools
364
+ that generate application locale catalogs from declared parameters.
365
+
227
366
  ### Run with rackup
228
367
  Use the same code as above, only the last line would be
229
368
 
data/Rakefile CHANGED
@@ -3,6 +3,7 @@ require 'rspec/core'
3
3
  require 'rspec/core/rake_task'
4
4
  require 'active_support/core_ext/string/inflections'
5
5
  require 'haveapi'
6
+ require 'haveapi/tasks/i18n'
6
7
  require 'haveapi/tasks/yard'
7
8
 
8
9
  RSpec::Core::RakeTask.new(:spec) do |spec|
data/haveapi.gemspec CHANGED
@@ -15,7 +15,8 @@ Gem::Specification.new do |s|
15
15
  s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
16
16
 
17
17
  s.add_dependency 'activesupport', '>= 7.1'
18
- s.add_dependency 'haveapi-client', '~> 0.28.4'
18
+ s.add_dependency 'haveapi-client', '~> 0.29.0'
19
+ s.add_dependency 'i18n', '>= 1.6', '< 2'
19
20
  s.add_dependency 'json'
20
21
  s.add_dependency 'mail'
21
22
  s.add_dependency 'nesty', '~> 1.0'
@@ -109,9 +109,8 @@ module HaveAPI
109
109
  meta(:global) do
110
110
  output do
111
111
  integer :action_state_id,
112
- label: 'Action state ID',
113
- desc: 'ID of ActionState object for state querying. When null, the action ' \
114
- 'is not blocking for the current invocation.'
112
+ label: HaveAPI.message('haveapi.parameters.action_state_id.label'),
113
+ desc: HaveAPI.message('haveapi.parameters.action_state_id.description')
115
114
  end
116
115
  end
117
116
  end
@@ -231,12 +230,12 @@ module HaveAPI
231
230
 
232
231
  {
233
232
  auth: @auth,
234
- description: @desc,
233
+ description: HaveAPI.localize(@desc),
235
234
  aliases: @aliases,
236
235
  blocking: @blocking ? true : false,
237
236
  input: @input ? @input.describe(context) : { parameters: {} },
238
237
  output: @output ? @output.describe(context) : { parameters: {} },
239
- meta: @meta ? @meta.merge(@meta) { |_, v| v && v.describe(context) } : nil,
238
+ meta: @meta ? @meta.to_h { |type, v| [type, v && v.describe(context, type:)] } : nil,
240
239
  examples: @examples ? @examples.describe(context) : [],
241
240
  scope: context.action_scope,
242
241
  path: context.resolved_path,
@@ -245,6 +244,16 @@ module HaveAPI
245
244
  }
246
245
  end
247
246
 
247
+ def parameter_metadata_i18n_items(context)
248
+ [
249
+ *@input&.parameter_metadata_i18n_items(context),
250
+ *@output&.parameter_metadata_i18n_items(context),
251
+ *(@meta&.flat_map do |type, metadata|
252
+ metadata&.parameter_metadata_i18n_items(context, type:)
253
+ end)
254
+ ].compact
255
+ end
256
+
248
257
  # Inherit attributes from resource action is defined in.
249
258
  def inherit_attrs_from_resource(action, r, attrs)
250
259
  begin
@@ -339,7 +348,7 @@ module HaveAPI
339
348
  status = @context.server.validation_error_http_status
340
349
  opts[:http_status] = status if status
341
350
 
342
- error!(e.message, e.to_hash, opts)
351
+ error!(e.message_value, e.to_hash, opts)
343
352
  end
344
353
 
345
354
  def authorized?(user)
@@ -397,7 +406,7 @@ module HaveAPI
397
406
  if tmp.empty?
398
407
  p e.message
399
408
  puts e.backtrace
400
- error!('Server error occurred', {}, http_status: 500)
409
+ error!(HaveAPI.message('haveapi.errors.server_error'), {}, http_status: 500)
401
410
  end
402
411
 
403
412
  unless tmp[:status]
@@ -415,7 +424,7 @@ module HaveAPI
415
424
 
416
425
  return [
417
426
  tmp[:status] || false,
418
- tmp[:message] || 'Server error occurred',
427
+ tmp[:message] || HaveAPI.message('haveapi.errors.server_error'),
419
428
  {},
420
429
  tmp[:http_status] || 500
421
430
  ]
@@ -507,7 +516,7 @@ module HaveAPI
507
516
  output {}
508
517
  meta(:global) do
509
518
  input do
510
- bool :no, label: 'Disable metadata'
519
+ bool :no, label: HaveAPI.message('haveapi.parameters.metadata.no.label')
511
520
  end
512
521
  end
513
522
 
@@ -592,6 +601,14 @@ module HaveAPI
592
601
  throw(:return, false)
593
602
  end
594
603
 
604
+ def api_message(...)
605
+ HaveAPI.message(...)
606
+ end
607
+
608
+ def api_t(...)
609
+ HaveAPI.t(...)
610
+ end
611
+
595
612
  private
596
613
 
597
614
  def validate
@@ -11,11 +11,14 @@ module HaveAPI
11
11
 
12
12
  meta(:global) do
13
13
  input do
14
- bool :count, label: 'Return the count of all items', default: false
14
+ bool :count,
15
+ label: HaveAPI.message('haveapi.parameters.default.count.label'),
16
+ default: false
15
17
  end
16
18
 
17
19
  output do
18
- integer :total_count, label: 'Total count of all items'
20
+ integer :total_count,
21
+ label: HaveAPI.message('haveapi.parameters.default.total_count.label')
19
22
  end
20
23
  end
21
24
 
@@ -4,10 +4,14 @@ module HaveAPI::Actions
4
4
 
5
5
  def self.included(action)
6
6
  action.input do
7
- integer :from_id, label: 'From ID', desc: 'List objects with greater/lesser ID',
8
- number: { min: 0 }
9
- integer :limit, label: 'Limit', desc: 'Number of objects to retrieve',
10
- number: { min: 0, max: MAX_LIMIT }
7
+ integer :from_id,
8
+ label: HaveAPI.message('haveapi.parameters.paginable.from_id.label'),
9
+ desc: HaveAPI.message('haveapi.parameters.paginable.from_id.description'),
10
+ number: { min: 0 }
11
+ integer :limit,
12
+ label: HaveAPI.message('haveapi.parameters.paginable.limit.label'),
13
+ desc: HaveAPI.message('haveapi.parameters.paginable.limit.description'),
14
+ number: { min: 0, max: MAX_LIMIT }
11
15
  end
12
16
  end
13
17
  end
@@ -127,7 +127,7 @@ module HaveAPI::Authentication
127
127
  tokens.first
128
128
  else
129
129
  raise HaveAPI::Authentication::TokenConflict,
130
- 'Multiple OAuth2 tokens provided'
130
+ HaveAPI.t('haveapi.authentication.multiple_oauth2_tokens')
131
131
  end
132
132
 
133
133
  token && config.find_user_by_access_token(request, token)
@@ -75,9 +75,16 @@ module HaveAPI::Authentication
75
75
  # Default request
76
76
  subclass.request do
77
77
  input do
78
- string :user, label: 'User', required: true
79
- password :password, label: 'Password', required: true
80
- string :scope, label: 'Scope', default: 'all', fill: true
78
+ string :user,
79
+ label: HaveAPI.message('haveapi.parameters.authentication.token.user.label'),
80
+ required: true
81
+ password :password,
82
+ label: HaveAPI.message('haveapi.parameters.authentication.token.password.label'),
83
+ required: true
84
+ string :scope,
85
+ label: HaveAPI.message('haveapi.parameters.authentication.token.scope.label'),
86
+ default: 'all',
87
+ fill: true
81
88
  end
82
89
 
83
90
  handle do
@@ -177,7 +177,7 @@ module HaveAPI::Authentication
177
177
  tokens.first
178
178
  else
179
179
  raise HaveAPI::Authentication::TokenConflict,
180
- 'Multiple authentication tokens provided'
180
+ HaveAPI.t('haveapi.authentication.multiple_tokens')
181
181
  end
182
182
  end
183
183
 
@@ -217,18 +217,17 @@ module HaveAPI::Authentication
217
217
  instance_exec(&block)
218
218
  end
219
219
 
220
- string :lifetime, label: 'Lifetime', required: true,
221
- choices: %i[fixed renewable_manual renewable_auto permanent],
222
- desc: <<~END
223
- fixed - the token has a fixed validity period, it cannot be renewed
224
- renewable_manual - the token can be renewed, but it must be done manually via renew action
225
- renewable_auto - the token is renewed automatically to now+interval every time it is used
226
- permanent - the token will be valid forever, unless deleted
227
- END
228
- integer :interval, label: 'Interval',
229
- desc: 'How long will requested token be valid, in seconds.',
230
- default: 60 * 5, fill: true,
231
- number: { min: 1, max: 86_400 }
220
+ string :lifetime,
221
+ label: HaveAPI.message('haveapi.parameters.authentication.token.lifetime.label'),
222
+ required: true,
223
+ choices: %i[fixed renewable_manual renewable_auto permanent],
224
+ desc: HaveAPI.message('haveapi.parameters.authentication.token.lifetime.description')
225
+ integer :interval,
226
+ label: HaveAPI.message('haveapi.parameters.authentication.token.interval.label'),
227
+ desc: HaveAPI.message('haveapi.parameters.authentication.token.interval.description'),
228
+ default: 60 * 5,
229
+ fill: true,
230
+ number: { min: 1, max: 86_400 }
232
231
  end
233
232
 
234
233
  output(:hash) do
@@ -245,7 +244,7 @@ module HaveAPI::Authentication
245
244
  def validate!
246
245
  validate
247
246
  rescue HaveAPI::ValidationError => e
248
- error!(e.message, e.to_hash, http_status: 400)
247
+ error!(e.message_value, e.to_hash, http_status: 400)
249
248
  end
250
249
 
251
250
  def exec
@@ -261,7 +260,9 @@ module HaveAPI::Authentication
261
260
  end
262
261
 
263
262
  unless result.ok?
264
- error!(result.error || 'invalid authentication credentials')
263
+ error!(
264
+ result.error || HaveAPI.message('haveapi.authentication.invalid_credentials')
265
+ )
265
266
  end
266
267
 
267
268
  {
@@ -292,7 +293,7 @@ module HaveAPI::Authentication
292
293
 
293
294
  unless user
294
295
  error!(
295
- 'Action requires user to authenticate with a token',
296
+ HaveAPI.message('haveapi.authentication.token_required'),
296
297
  {},
297
298
  http_status: 401
298
299
  )
@@ -311,7 +312,7 @@ module HaveAPI::Authentication
311
312
  if result.ok?
312
313
  ok!
313
314
  else
314
- error!(result.error || 'revoke failed')
315
+ error!(result.error || HaveAPI.message('haveapi.authentication.revoke_failed'))
315
316
  end
316
317
  end
317
318
  end
@@ -339,7 +340,7 @@ module HaveAPI::Authentication
339
340
 
340
341
  unless user
341
342
  error!(
342
- 'Action requires user to authenticate with a token',
343
+ HaveAPI.message('haveapi.authentication.token_required'),
343
344
  {},
344
345
  http_status: 401
345
346
  )
@@ -358,7 +359,7 @@ module HaveAPI::Authentication
358
359
  if result.ok?
359
360
  { valid_to: result.valid_to }
360
361
  else
361
- error!(result.error || 'renew failed')
362
+ error!(result.error || HaveAPI.message('haveapi.authentication.renew_failed'))
362
363
  end
363
364
  end
364
365
  end
@@ -387,7 +388,7 @@ module HaveAPI::Authentication
387
388
  def validate!
388
389
  validate
389
390
  rescue HaveAPI::ValidationError => e
390
- error!(e.message, e.to_hash, http_status: 400)
391
+ error!(e.message_value, e.to_hash, http_status: 400)
391
392
  end
392
393
 
393
394
  define_method(:exec) do
@@ -402,7 +403,7 @@ module HaveAPI::Authentication
402
403
  end
403
404
 
404
405
  unless result.ok?
405
- error!(result.error || 'authentication failed')
406
+ error!(result.error || HaveAPI.message('haveapi.authentication.failed'))
406
407
  end
407
408
 
408
409
  {
@@ -113,7 +113,10 @@ module HaveAPI
113
113
 
114
114
  def resolve_arg!(path, arg)
115
115
  value = arg.to_s
116
- raise HaveAPI::ValidationError, 'invalid path parameter encoding' unless value.valid_encoding?
116
+ unless value.valid_encoding?
117
+ raise HaveAPI::ValidationError,
118
+ HaveAPI.message('haveapi.validation.invalid_path_parameter_encoding')
119
+ end
117
120
 
118
121
  path.sub!(/\{[a-zA-Z0-9\-_]+\}/, value)
119
122
  end
@@ -0,0 +1,125 @@
1
+ require 'i18n'
2
+
3
+ module HaveAPI
4
+ class LocalizedMessage
5
+ attr_reader :key, :values, :default
6
+
7
+ def initialize(key, default: nil, scope: nil, **values)
8
+ @key = normalize_key(key, scope)
9
+ @default = default
10
+ @values = values
11
+ end
12
+
13
+ def translate(extra_values = {})
14
+ values = localized_values(@values.merge(extra_values))
15
+ opts = values
16
+ opts[:default] = @default if @default
17
+
18
+ ::I18n.t(@key, **opts)
19
+ end
20
+
21
+ def to_s
22
+ translate
23
+ end
24
+
25
+ private
26
+
27
+ def normalize_key(key, scope)
28
+ return key.to_s unless scope
29
+
30
+ "#{scope}.#{key}"
31
+ end
32
+
33
+ def localized_values(values)
34
+ values.transform_values do |value|
35
+ case value
36
+ when Array
37
+ value.map { |v| HaveAPI.localize(v) }.join('; ')
38
+ else
39
+ HaveAPI.localize(value)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ module I18n
46
+ class << self
47
+ def setup
48
+ locale_dir = File.expand_path('locales', __dir__)
49
+ ::I18n.load_path |= Dir[File.join(locale_dir, '*.yml')]
50
+ end
51
+
52
+ def available_locales
53
+ locale_dir = File.expand_path('locales', __dir__)
54
+
55
+ Dir[File.join(locale_dir, '*.yml')].map do |path|
56
+ File.basename(path, '.yml').to_sym
57
+ end.sort
58
+ end
59
+
60
+ def accept_language(header, available_locales)
61
+ header.to_s.split(',').each_with_index.filter_map do |raw, index|
62
+ tag, *params = raw.strip.split(';')
63
+ next if tag.nil? || tag.empty?
64
+
65
+ q = params.map(&:strip).grep(/\Aq=/).first
66
+ weight = q ? q.split('=', 2).last.to_f : 1.0
67
+ next if weight <= 0.0
68
+
69
+ [tag, weight, index]
70
+ end.sort_by { |(_, weight, index)| [-weight, index] }.each do |tag, _, _|
71
+ locale = normalize_locale(tag, available_locales)
72
+ return locale if locale
73
+ end
74
+
75
+ nil
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ def normalize_locale(locale, available_locales)
81
+ return if locale.nil?
82
+
83
+ tag = locale.to_s.strip.tr('_', '-')
84
+ return if tag.empty? || tag == '*'
85
+
86
+ available = Array(available_locales).map(&:to_s)
87
+ candidates = [tag, tag.split('-').first].compact.map(&:downcase).uniq
88
+
89
+ candidates.each do |candidate|
90
+ match = available.detect { |v| v.downcase == candidate }
91
+ return match.to_sym if match
92
+ end
93
+
94
+ nil
95
+ end
96
+ end
97
+ end
98
+
99
+ class << self
100
+ def message(key, default: nil, scope: nil, **values)
101
+ LocalizedMessage.new(key, default:, scope:, **values)
102
+ end
103
+
104
+ def t(key, default: nil, scope: nil, **values)
105
+ message(key, default:, scope:, **values).translate
106
+ end
107
+
108
+ def localize(value, **extra_values)
109
+ case value
110
+ when LocalizedMessage
111
+ value.translate(extra_values)
112
+ when Array
113
+ value.map { |v| localize(v, **extra_values) }
114
+ when Hash
115
+ value.transform_values { |v| localize(v, **extra_values) }
116
+ when String
117
+ extra_values.empty? ? value : format(value, extra_values)
118
+ else
119
+ value
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ HaveAPI::I18n.setup