functional-light-service 6.0.0 → 6.2.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +130 -11
  5. data/functional-light-service.gemspec +3 -1
  6. data/lib/functional-light-service/action.rb +48 -0
  7. data/lib/functional-light-service/configuration.rb +12 -2
  8. data/lib/functional-light-service/context/key_verifier.rb +1 -1
  9. data/lib/functional-light-service/context.rb +15 -2
  10. data/lib/functional-light-service/errors.rb +1 -0
  11. data/lib/functional-light-service/functional/sequencer.rb +144 -0
  12. data/lib/functional-light-service/i18n/localization_adapter.rb +50 -0
  13. data/lib/functional-light-service/localization_adapter.rb +19 -28
  14. data/lib/functional-light-service/localization_map.rb +9 -0
  15. data/lib/functional-light-service/organizer/reduce_case.rb +50 -0
  16. data/lib/functional-light-service/organizer/reduce_if_else.rb +23 -0
  17. data/lib/functional-light-service/organizer/reduce_until.rb +1 -1
  18. data/lib/functional-light-service/organizer/reduce_while.rb +31 -0
  19. data/lib/functional-light-service/organizer/scoped_reducable.rb +2 -2
  20. data/lib/functional-light-service/organizer/with_reducer_log_decorator.rb +2 -1
  21. data/lib/functional-light-service/organizer.rb +18 -3
  22. data/lib/functional-light-service/version.rb +1 -1
  23. data/lib/functional-light-service.rb +6 -0
  24. data/spec/acceptance/organizer/add_to_context_spec.rb +24 -0
  25. data/spec/acceptance/organizer/context_failure_and_skipping_spec.rb +22 -0
  26. data/spec/acceptance/organizer/execute_spec.rb +21 -0
  27. data/spec/acceptance/organizer/reduce_case_spec.rb +65 -0
  28. data/spec/acceptance/organizer/reduce_if_else_spec.rb +60 -0
  29. data/spec/acceptance/organizer/reduce_while_spec.rb +96 -0
  30. data/spec/acceptance/skip_all_remaining_spec.rb +139 -0
  31. data/spec/action_optional_expected_keys_spec.rb +107 -0
  32. data/spec/context/inspect_spec.rb +13 -3
  33. data/spec/context_spec.rb +12 -0
  34. data/spec/i18n_localization_adapter_spec.rb +83 -0
  35. data/spec/lib/deterministic/sequencer_spec.rb +506 -0
  36. data/spec/localization_adapter_spec.rb +66 -83
  37. data/spec/spec_helper.rb +3 -0
  38. data/spec/test_doubles.rb +28 -0
  39. metadata +27 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a02972ac6e701ec1ed5ceafcea5880e961ccbaf23ff886fe8f490c5bb514e6e1
4
- data.tar.gz: 0a35af1df1d0820de886bae015cc84846dc0809d4602d12528c7f716b537a2f9
3
+ metadata.gz: 53e332561acc819e0d2d6488a38adeb3600aadcf31c100e474e1d1b0a6397157
4
+ data.tar.gz: 6500b3cdbf6895ce0e165eb36cc3dc7634ed4fc341cd42ceea3a5553eaf6033d
5
5
  SHA512:
6
- metadata.gz: 1ec467e5b62e74629391dfaf9b2e0ede5534338b3ce1411f9c0f7c4a9c38b7aa880701ddd98e4fd5f8a61fa7e8dc741633c626a6b422c544084c825e883b1453
7
- data.tar.gz: e526d9bd6c3736ea555db60add374336dd6a9e957f7fae265bee44282ffe0dbb2fb43bba03eaafd210aab7bcf1f9d1f7af96391fa34b680dacfe4cd148fff4ca
6
+ metadata.gz: 7b64b265d6d63225baca32509169796242f4003c4000e429f4ab9758a310ae4b1a4668553512b40bc9d070a61cd2566ef0eb1efc8cdad3e06cda7b5b478a1854
7
+ data.tar.gz: 959de9564732c9acd2108c746b36be9da3445ea969a7940943c5c88543044d25fd5e3c5df5f6f6b749aa54f950a9a9a511320d992d0536d901eff116f70d2431
data/.gitignore CHANGED
@@ -19,4 +19,6 @@ vendor/bundle
19
19
  bin
20
20
  gems.tags
21
21
  tags
22
+ .claude/
23
+ .ruby-lsp/
22
24
  !*.gitkeep
data/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## 6.2.0 (2026-07-18)
2
+
3
+ Port selettivo delle feature di light-service upstream 0.14-0.21 (niente Rails
4
+ support; solo aggiunte retro-compatibili).
5
+
6
+ ### Added
7
+ - skip_all_remaining!: salta tutto il resto dell'organizer, scope annidati compresi; non viene resettato da reduce_if/iterate/ecc. (upstream 0.21) ( 2026-07-18 ) [ sphynx79]
8
+ - reduce_while: macro dell'organizer che controlla la condizione prima di ogni step (upstream 0.21) ( 2026-07-18 ) [ sphynx79]
9
+ - reduce_if_else: come reduce_if ma con ramo else (upstream 0.17) ( 2026-07-18 ) [ sphynx79]
10
+ - reduce_case: dispatch sugli step in base a un valore del context, kwargs :value/:when/:else (upstream 0.18) ( 2026-07-18 ) [ sphynx79]
11
+ - expects :key, default: valore-o-lambda per chiavi attese opzionali; kwarg diverso da :default solleva UnusableExpectKeyDefaultError (upstream 0.17) ( 2026-07-18 ) [ sphynx79]
12
+ - execute accetta un blocco oltre alla lambda (upstream master) ( 2026-07-18 ) [ sphynx79]
13
+ - :organized_by tra le chiavi riservate del KeyVerifier: errore piu chiaro in caso di collisione (upstream 0.14) ( 2026-07-18 ) [ sphynx79]
14
+
15
+ ### Changed
16
+ - add_to_context registra anche gli accessor delle chiavi aggiunte (ctx.mia_chiave); chiavi che collidono con metodi del Context ora sollevano ReservedKeysInContextError invece di passare in silenzio (upstream 0.16) ( 2026-07-18 ) [ sphynx79]
17
+ - i18n non e' piu una dipendenza runtime: di default i simboli si risolvono col nuovo adapter built-in (LocalizationMap + Configuration.locale); l'adapter I18n trasloca in FunctionalLightService::I18n::LocalizationAdapter e viene selezionato solo se l'applicazione carica la gem i18n (upstream 0.19/0.20) ( 2026-07-18 ) [ sphynx79]
18
+
19
+
20
+ ## 6.1.0 (2026-07-03)
21
+
22
+ ### Added
23
+ - Sequencer: do-notation per Result con in_sequence/get/let/and_then/observe/and_yield, portato dalla gem deterministic (MIT) e incluso nel Prelude ( 2026-07-03 ) [ sphynx79]
24
+
25
+
26
+
1
27
  ## 6.0.0 (2026-07-03)
2
28
 
3
29
  Release maggiore guidata da un audit tecnico completo (vedi `AUDIT-functional-light-service.md`
data/README.md CHANGED
@@ -28,6 +28,7 @@
28
28
  * [Usage](#functional-usage)
29
29
  * [Result: Success & Failure](#functional-usage-success-failure)
30
30
  * [Result Chaining](#functional-usage-chaining)
31
+ * [Sequencing (do-notation)](#functional-usage-sequencing)
31
32
  * [Complex Example in a Builder Action](#functional-usage-complex-action)
32
33
  * [Pattern matching](#functional-usage-pattern-matching)
33
34
  * [Option](#functional-usage-option)
@@ -418,6 +419,28 @@ end
418
419
  In the example above, the organizer invokes four actions.
419
420
  The first two run successfully; the third calls skip_remaining!, so the fourth is never executed, yet the overall context stays successful.
420
421
 
422
+ ### Skipping everything, including nested scopes
423
+
424
+ `skip_remaining!` is scoped: constructs like `reduce_if` or `iterate` reset it
425
+ at their boundary, so it only exits the **current** scope. When you need to stop
426
+ the whole organizer from inside a nested construct, use
427
+ `context.skip_all_remaining!` — it is never reset, so every remaining step (in
428
+ the current scope and in the outer ones) is skipped while the context stays
429
+ successful:
430
+
431
+ ```ruby
432
+ class StopsEverythingAction
433
+ extend FunctionalLightService::Action
434
+ expects :item
435
+
436
+ executed do |context|
437
+ if context.item.poison_pill?
438
+ context.skip_all_remaining!("Poison pill found, stopping the pipeline")
439
+ end
440
+ end
441
+ end
442
+ ```
443
+
421
444
  ## Benchmarking Actions with Around Advice
422
445
 
423
446
  When you need to profile a pipeline, adding timing code inside every single
@@ -619,6 +642,33 @@ end
619
642
 
620
643
  Want to see it in practice? Check out [this spec](spec/action_expects_and_promises_spec.rb) test file.
621
644
 
645
+ ### Default values for expected keys
646
+
647
+ An expected key can declare a `default`, used when the key is missing from the
648
+ context (also when the action runs inside an organizer). The default can be a
649
+ static value or a lambda receiving the context:
650
+
651
+ ```ruby
652
+ class GreetsSomeoneAction
653
+ extend FunctionalLightService::Action
654
+
655
+ expects :name
656
+ expects :greeting, :default => "Hello"
657
+ expects :message, :default => ->(ctx) { "#{ctx[:greeting]}, #{ctx[:name]}!" }
658
+
659
+ executed do |context|
660
+ puts context.message
661
+ end
662
+ end
663
+
664
+ GreetsSomeoneAction.execute(:name => "Rick") # ⇒ "Hello, Rick!"
665
+ ```
666
+
667
+ Note that `expects` accepts a single key when a default is given, and any
668
+ keyword other than `default` raises `UnusableExpectKeyDefaultError` at class
669
+ definition time. Keys already reachable through an alias are considered
670
+ present, so their default is not applied.
671
+
622
672
  ## Key Aliases
623
673
 
624
674
  Need to wire together actions that use different key names?
@@ -829,10 +879,30 @@ For a full example, see [this acceptance test](spec/acceptance/rollback_spec.rb)
829
879
 
830
880
  ## Localizing Messages
831
881
 
832
- FunctionalLightService integrates with **I18n** out of the box, so you can translate
833
- success or failure messages without extra plumbing.
834
- If your app needs something more advanced, you can swap in a custom localization
835
- adapter.
882
+ Symbols passed to `fail!`/`succeed!` are looked up through a localization
883
+ adapter. Two adapters ship with the gem:
884
+
885
+ - **Built-in adapter** (default): resolves messages from
886
+ `FunctionalLightService::LocalizationMap.instance`, a plain hash keyed by
887
+ `Configuration.locale` (default `:en`) — no extra dependency needed:
888
+
889
+ ```ruby
890
+ FunctionalLightService::LocalizationMap.instance[:en] = {
891
+ :foo_action => {
892
+ :light_service => {
893
+ :failures => { :exceeded_api_limit => "Exceeded API limit" },
894
+ :successes => { :api_call_ok => "All good" }
895
+ }
896
+ }
897
+ }
898
+ ```
899
+
900
+ - **I18n adapter**: selected automatically when your application loads the
901
+ `i18n` gem (it is no longer a runtime dependency of this gem — add it to
902
+ your own Gemfile if you want I18n-backed lookups).
903
+
904
+ If your app needs something more advanced, you can swap in a custom
905
+ localization adapter.
836
906
 
837
907
  ```ruby
838
908
  class FooAction
@@ -905,7 +975,7 @@ configuration:
905
975
  FunctionalLightService::Configuration.localization_adapter = MyLocalizer.new
906
976
 
907
977
  # lib/my_localizer.rb
908
- class MyLocalizer < FunctionalLightService::LocalizationAdapter
978
+ class MyLocalizer < FunctionalLightService::I18n::LocalizationAdapter
909
979
  # change default scope to: "light_service.failures.<class_path>"
910
980
  def i18n_scope_from_class(action_class, type)
911
981
  "light_service.#{type.pluralize}.#{action_class.name.underscore}"
@@ -983,15 +1053,18 @@ your actions.
983
1053
 
984
1054
  | Construct | Declarative “equivalent” | What it does (in one line) |
985
1055
  | ------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------- |
986
- | [reduce_until](spec/acceptance/organizer/reduce_until_spec.rb) | `while` loop | Keeps reducing the listed steps **until** the lambda returns `true`. |
987
- | [reduce_if](spec/acceptance/organizer/reduce_if_spec.rb) | `if/else` | Reduces its sub‑steps **only if** the lambda returns `true`. |
1056
+ | [reduce_until](spec/acceptance/organizer/reduce_until_spec.rb) | `until` loop | Keeps reducing the listed steps **until** the lambda returns `true`. |
1057
+ | [reduce_while](spec/acceptance/organizer/reduce_while_spec.rb) | `while` guard | Checks the lambda **before each step** and stops as soon as it returns `false`. |
1058
+ | [reduce_if](spec/acceptance/organizer/reduce_if_spec.rb) | `if` | Reduces its sub‑steps **only if** the lambda returns `true`. |
1059
+ | [reduce_if_else](spec/acceptance/organizer/reduce_if_else_spec.rb) | `if/else` | Reduces the first list of steps when the lambda is `true`, the second one otherwise. |
1060
+ | [reduce_case](spec/acceptance/organizer/reduce_case_spec.rb) | `case/when` | Dispatches to the steps matching a context value (`:value`, `:when`, `:else` kwargs). |
988
1061
  | [iterate](spec/acceptance/organizer/iterate_spec.rb) | `each` loop | Loops over a collection key; each element is exposed under the **singular** name. |
989
- | [execute](spec/acceptance/organizer/execute_spec.rb) | one‑off lambda | Runs an inline lambda for quick context tweaks (add keys, transform values, etc.). |
1062
+ | [execute](spec/acceptance/organizer/execute_spec.rb) | one‑off lambda | Runs an inline lambda or block for quick context tweaks (add keys, transform values, etc.). |
990
1063
  | [with_callback](spec/acceptance/organizer/with_callback_spec.rb) | streaming callback | Defers execution like a SAX parser—great for huge inputs without loading everything in RAM. |
991
- | [add_to_context](spec/acceptance/organizer/add_to_context_spec.rb) | N/A (context inject) | Injects key–value pairs into the context just before the following steps run. |
1064
+ | [add_to_context](spec/acceptance/organizer/add_to_context_spec.rb) | N/A (context inject) | Injects key–value pairs into the context (defining accessors) before the next steps run. |
992
1065
  | [add_aliases](spec/acceptance/organizer/add_aliases_spec.rb) | key aliasing | Creates an alias so actions can read/write the same value under different names. |
993
1066
 
994
- All seven are covered by acceptance tests in spec/acceptance/organizer/*_spec.rb.
1067
+ All ten are covered by acceptance tests in spec/acceptance/organizer/*_spec.rb.
995
1068
 
996
1069
  **Tip**: When iterating, the collection must already be in the context.
997
1070
  iterate(:items) expects context[:items]; it then places each element under
@@ -1002,10 +1075,23 @@ iterate(:items, [ProcessItem])
1002
1075
  # Inside ProcessItem → context.item
1003
1076
  ```
1004
1077
 
1005
- Need a quick context mutation?Use execute:
1078
+ Need a quick context mutation? Use execute, with a lambda or a block:
1006
1079
 
1007
1080
  ```ruby
1008
1081
  execute(->(c) { c[:some_values] = c.some_hash.values })
1082
+ # or
1083
+ execute { |c| c[:some_values] = c.some_hash.values }
1084
+ ```
1085
+
1086
+ Need to branch on a context value? Use reduce_case:
1087
+
1088
+ ```ruby
1089
+ reduce_case :value => :status,
1090
+ :when => {
1091
+ :active => [NotifiesUserAction],
1092
+ :archived => [ArchivesRecordAction]
1093
+ },
1094
+ :else => [RaisesUnknownStatusAction]
1009
1095
  ```
1010
1096
 
1011
1097
  ## ContextFactory for Faster Action Testing
@@ -1150,6 +1236,39 @@ Success(params) >>
1150
1236
  build_response
1151
1237
  ```
1152
1238
 
1239
+ ### Sequencing (do-notation) – `in_sequence` <a name="functional-usage-sequencing"></a>
1240
+
1241
+ When a pipeline needs the intermediate values of earlier steps, chaining alone
1242
+ gets awkward. `in_sequence` (ported from the [deterministic](https://github.com/pzol/deterministic)
1243
+ gem, MIT License) gives you a do-notation style block: each step returns a
1244
+ `Result`, the sequence short-circuits on the first `Failure`, and values bound
1245
+ with `get`/`let` are available to all subsequent steps by name.
1246
+
1247
+ ```ruby
1248
+ class DownloadRemit
1249
+ include FunctionalLightService::Prelude
1250
+
1251
+ def call(row)
1252
+ in_sequence do
1253
+ get(:url) { extract_url(row) } # binds the Success value to :url
1254
+ get(:file) { fetch(url) } # :url is available here
1255
+ let(:name) { File.basename(url) } # binds a plain (non-Result) value
1256
+ and_then { validate(file) } # step without binding
1257
+ observe { logger.info("got #{name}") } # side effect, return value ignored
1258
+ and_yield { Success(name) } # final result of the sequence
1259
+ end
1260
+ end
1261
+ end
1262
+ ```
1263
+
1264
+ * `get(:name) { ... }` – runs a step returning a `Result`; on `Success` binds the
1265
+ unwrapped value to `name`, on `Failure` stops the sequence and returns it.
1266
+ * `let(:name) { ... }` – binds the block's plain return value (no `Result` involved).
1267
+ * `and_then { ... }` – runs a step returning a `Result` without binding its value.
1268
+ * `observe { ... }` – runs a side effect; its return value is ignored.
1269
+ * `and_yield { ... }` – mandatory final step; its `Result` is the value of the
1270
+ whole `in_sequence` block.
1271
+
1153
1272
  #### Complex Example in a Builder Action <a name="functional-usage-complex-action"></a>
1154
1273
 
1155
1274
  ```ruby
@@ -16,11 +16,13 @@ Gem::Specification.new do |gem|
16
16
  gem.required_ruby_version = '>= 3.1.0'
17
17
 
18
18
  gem.add_runtime_dependency("dry-inflector", ">= 0.2.1", "< 2")
19
- gem.add_runtime_dependency("i18n", "~> 1.8", ">= 1.8.11")
20
19
  # logger non e' piu una default gem da Ruby 3.5/4.0: senza questa
21
20
  # dichiarazione `require 'logger'` fallisce sotto Bundler
22
21
  gem.add_runtime_dependency("logger", "~> 1.5")
23
22
 
23
+ # i18n e' opzionale a runtime: l'adapter I18n viene usato solo se
24
+ # l'applicazione ospite carica la gem (vedi Configuration)
25
+ gem.add_development_dependency("i18n", "~> 1.8", ">= 1.8.11")
24
26
  gem.add_development_dependency("rake", "~> 13.0")
25
27
  gem.add_development_dependency("rspec", "~> 3.13")
26
28
  gem.add_development_dependency("simplecov", "~> 0.22")
@@ -17,6 +17,12 @@ module FunctionalLightService
17
17
 
18
18
  module Macros
19
19
  def expects(*args)
20
+ if expect_key_having_default?(args)
21
+ available_defaults[args.first] = args.last[:default]
22
+
23
+ args = [args.first]
24
+ end
25
+
20
26
  expected_keys.concat(args)
21
27
  end
22
28
 
@@ -66,11 +72,53 @@ module FunctionalLightService
66
72
  private
67
73
 
68
74
  def create_action_context(context)
75
+ # I default vanno applicati anche quando l'action gira dentro un
76
+ # organizer (il context è già un Context): prima dell'early return.
77
+ # Il guard sull'ivar evita lavoro (e scritture lazy) nel percorso caldo
78
+ apply_expects_defaults(context) if @available_defaults
79
+
69
80
  return context if context.is_a? FunctionalLightService::Context
70
81
 
71
82
  FunctionalLightService::Context.make(context)
72
83
  end
73
84
 
85
+ def apply_expects_defaults(context)
86
+ usable_defaults(context).each do |ctx_key, default|
87
+ context[ctx_key] = extract_default(default, context)
88
+ end
89
+ end
90
+
91
+ def available_defaults
92
+ @available_defaults ||= {}
93
+ end
94
+
95
+ def expect_key_having_default?(key)
96
+ return false unless key.size == 2 && key.last.is_a?(Hash)
97
+ return true if key.last.key?(:default)
98
+
99
+ bad_key = key.last.keys.first
100
+ err_msg = "Specify defaults with a `default` key. You have #{bad_key}."
101
+ raise UnusableExpectKeyDefaultError, err_msg
102
+ end
103
+
104
+ def missing_expected_keys(context)
105
+ # context.key? risolve anche gli alias: `expected_keys - context.keys`
106
+ # (upstream) darebbe falsi mancanti sulle chiavi aliasate
107
+ expected_keys.reject { |key| context.key?(key) }
108
+ end
109
+
110
+ def usable_defaults(context)
111
+ available_defaults.slice(
112
+ *(missing_expected_keys(context) & available_defaults.keys)
113
+ )
114
+ end
115
+
116
+ def extract_default(default, context)
117
+ return default unless default.respond_to?(:call)
118
+
119
+ default.call(context)
120
+ end
121
+
74
122
  def all_keys
75
123
  expected_keys + promised_keys
76
124
  end
@@ -3,7 +3,7 @@
3
3
  module FunctionalLightService
4
4
  class Configuration
5
5
  class << self
6
- attr_writer :logger, :localization_adapter
6
+ attr_writer :logger, :localization_adapter, :locale
7
7
 
8
8
  def logger
9
9
  @logger = _default_logger unless instance_variable_defined?("@logger")
@@ -11,7 +11,17 @@ module FunctionalLightService
11
11
  end
12
12
 
13
13
  def localization_adapter
14
- @localization_adapter ||= LocalizationAdapter.new
14
+ # La gem i18n non è una dipendenza: l'adapter I18n viene scelto solo
15
+ # se la costante è già stata caricata dall'applicazione ospite
16
+ @localization_adapter ||= if Module.const_defined?('I18n')
17
+ FunctionalLightService::I18n::LocalizationAdapter.new
18
+ else
19
+ LocalizationAdapter.new
20
+ end
21
+ end
22
+
23
+ def locale
24
+ @locale ||= :en
15
25
  end
16
26
 
17
27
  private
@@ -116,7 +116,7 @@ module FunctionalLightService
116
116
  def reserved_keys
117
117
  # _aliases/_before_actions/_after_actions sono chiavi infrastrutturali
118
118
  # scritte da Organizer.with nel context
119
- %i[message error_code current_action
119
+ %i[message error_code current_action organized_by
120
120
  _aliases _before_actions _after_actions].freeze
121
121
  end
122
122
  end
@@ -14,6 +14,7 @@ module FunctionalLightService
14
14
  outcome = Success(:message => '', :error => nil))
15
15
  @outcome = outcome
16
16
  @skip_remaining = false
17
+ @skip_all_remaining = false
17
18
  context.to_hash.each { |k, v| self[k] = v }
18
19
  end
19
20
  # rubocop:enable Lint/MissingSuper
@@ -46,6 +47,10 @@ module FunctionalLightService
46
47
  @skip_remaining
47
48
  end
48
49
 
50
+ def skip_all_remaining?
51
+ @skip_all_remaining
52
+ end
53
+
49
54
  def reset_skip_remaining!
50
55
  # Resetta soltanto il flag: l'esito (e il suo messaggio) non vanno persi
51
56
  @skip_remaining = false
@@ -100,8 +105,15 @@ module FunctionalLightService
100
105
  @skip_remaining = true
101
106
  end
102
107
 
108
+ # Variante non azzerabile di skip_remaining!: salta tutto il resto
109
+ # dell'organizer, scope annidati compresi (scoped_reduce non la resetta)
110
+ def skip_all_remaining!(message = nil)
111
+ @outcome = Success(:message => message)
112
+ @skip_all_remaining = true
113
+ end
114
+
103
115
  def stop_processing?
104
- failure? || skip_remaining?
116
+ failure? || skip_remaining? || skip_all_remaining?
105
117
  end
106
118
 
107
119
  # Registra le chiavi come accessor consentiti: la lettura/scrittura passa
@@ -174,7 +186,8 @@ module FunctionalLightService
174
186
 
175
187
  def inspect
176
188
  "#{self.class}(#{self}, success: #{success?}, message: #{check_nil(message)}, error_code: " \
177
- "#{check_nil(error_code)}, skip_remaining: #{@skip_remaining}, aliases: #{aliases})"
189
+ "#{check_nil(error_code)}, skip_remaining: #{@skip_remaining}, " \
190
+ "skip_all_remaining: #{@skip_all_remaining}, aliases: #{aliases})"
178
191
  end
179
192
 
180
193
  private
@@ -5,4 +5,5 @@ module FunctionalLightService
5
5
  class ExpectedKeysNotInContextError < StandardError; end
6
6
  class PromisedKeysNotInContextError < StandardError; end
7
7
  class ReservedKeysInContextError < StandardError; end
8
+ class UnusableExpectKeyDefaultError < StandardError; end
8
9
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module FunctionalLightService
6
+ # Do-notation for Result: chains steps that return Success/Failure,
7
+ # short-circuiting on the first Failure. Ported from the deterministic
8
+ # gem (MIT License, Copyright (c) Piotr Zolnierek and contributors,
9
+ # https://github.com/pzol/deterministic).
10
+ #
11
+ # in_sequence do
12
+ # get(:user) { fetch_user(id) } # binds the Success value to :user
13
+ # let(:name) { user.fetch(:name) } # binds a plain (non-Result) value
14
+ # and_then { validate(user) } # step without binding
15
+ # observe { log(name) } # side effect, return value ignored
16
+ # and_yield { Success(name) } # final result of the sequence
17
+ # end
18
+ module Sequencer
19
+ class InvalidSequenceError < StandardError; end
20
+
21
+ module Operation
22
+ Get = Struct.new(:block, :name)
23
+ Let = Struct.new(:block, :name)
24
+ AndThen = Struct.new(:block)
25
+ Observe = Struct.new(:block)
26
+ AndYield = Struct.new(:block)
27
+ end
28
+
29
+ def in_sequence(&)
30
+ sequencer = Sequencer.new(self)
31
+ sequencer.instance_eval(&)
32
+ sequencer.yield
33
+ end
34
+
35
+ class Sequencer
36
+ def initialize(instance)
37
+ @operations = []
38
+ @operation_wrapper = OperationWrapper.new(instance)
39
+ end
40
+
41
+ def get(name, &block)
42
+ raise ArgumentError, 'no block given' unless block_given?
43
+ raise InvalidSequenceError, 'and_yield already called' if @sequenced_operations
44
+
45
+ @operations << Operation::Get.new(block, name)
46
+ end
47
+
48
+ def let(name, &block)
49
+ raise ArgumentError, 'no block given' unless block_given?
50
+ raise InvalidSequenceError, 'and_yield already called' if @sequenced_operations
51
+
52
+ @operations << Operation::Let.new(block, name)
53
+ end
54
+
55
+ def and_then(&block)
56
+ raise ArgumentError, 'no block given' unless block_given?
57
+ raise InvalidSequenceError, 'and_yield already called' if @sequenced_operations
58
+
59
+ @operations << Operation::AndThen.new(block)
60
+ end
61
+
62
+ def observe(&block)
63
+ raise ArgumentError, 'no block given' unless block_given?
64
+ raise InvalidSequenceError, 'and_yield already called' if @sequenced_operations
65
+
66
+ @operations << Operation::Observe.new(block)
67
+ end
68
+
69
+ def and_yield(&block)
70
+ raise ArgumentError, 'no block given' unless block_given?
71
+ raise InvalidSequenceError, 'and_yield already called' if @sequenced_operations
72
+
73
+ @operations << Operation::AndYield.new(block)
74
+
75
+ prepare_sequenced_operations
76
+ end
77
+
78
+ def yield
79
+ raise InvalidSequenceError, 'and_yield not called' unless @sequenced_operations
80
+
81
+ @operation_wrapper.instance_eval(&@sequenced_operations)
82
+ end
83
+
84
+ private
85
+
86
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
87
+ def prepare_sequenced_operations
88
+ operations = @operations
89
+
90
+ @sequenced_operations = ->(_) do
91
+ operations.reduce(Result::Success.new(nil)) do |last_result, operation|
92
+ last_result.map do
93
+ case operation
94
+ when Operation::Get
95
+ result = instance_eval(&operation.block)
96
+ result.map do |output|
97
+ # Runs in the context of the OperationWrapper, so the
98
+ # bound value is stored within the wrapper itself.
99
+ @gotten_results[operation.name] = output
100
+ result
101
+ end
102
+ when Operation::Let
103
+ @gotten_results[operation.name] = instance_eval(&operation.block)
104
+ last_result
105
+ when Operation::Observe
106
+ instance_eval(&operation.block)
107
+ last_result
108
+ when Operation::AndThen, Operation::AndYield
109
+ instance_eval(&operation.block)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
116
+ end
117
+
118
+ # OperationWrapper proxies all method calls to the wrapped instance, but
119
+ # first checks if the name of the called method matches a value stored
120
+ # within @gotten_results and returns the value if it does.
121
+ class OperationWrapper < SimpleDelegator
122
+ def initialize(*args)
123
+ super
124
+ @gotten_results = {}
125
+ end
126
+
127
+ def method_missing(name, *args, **kwargs, &)
128
+ if @gotten_results.key?(name)
129
+ @gotten_results[name]
130
+ else
131
+ super
132
+ end
133
+ end
134
+
135
+ def respond_to_missing?(name, include_private = false)
136
+ @gotten_results.key?(name) || super
137
+ end
138
+ end
139
+ end
140
+
141
+ module Prelude
142
+ include Sequencer
143
+ end
144
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/inflector'
4
+
5
+ module FunctionalLightService
6
+ module I18n
7
+ class LocalizationAdapter
8
+ def failure(message_or_key, action_class, i18n_options = {})
9
+ find_translated_message(message_or_key,
10
+ action_class,
11
+ i18n_options,
12
+ :type => :failure)
13
+ end
14
+
15
+ def success(message_or_key, action_class, i18n_options = {})
16
+ find_translated_message(message_or_key,
17
+ action_class,
18
+ i18n_options,
19
+ :type => :success)
20
+ end
21
+
22
+ private
23
+
24
+ def find_translated_message(message_or_key,
25
+ action_class,
26
+ i18n_options,
27
+ type)
28
+ if message_or_key.is_a?(Symbol)
29
+ translate(message_or_key, action_class, i18n_options.merge(type))
30
+ else
31
+ message_or_key
32
+ end
33
+ end
34
+
35
+ def translate(key, action_class, options = {})
36
+ type = options.delete(:type)
37
+
38
+ scope = i18n_scope_from_class(action_class, type)
39
+ options[:scope] = scope
40
+
41
+ ::I18n.t(key, **options)
42
+ end
43
+
44
+ def i18n_scope_from_class(action_class, type)
45
+ inflector = Dry::Inflector.new
46
+ "#{inflector.underscore(action_class.name)}.light_service.#{inflector.pluralize(type)}"
47
+ end
48
+ end
49
+ end
50
+ end