operandi 5.0.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,218 @@
1
+ # Configuration
2
+
3
+ Operandi provides a flexible configuration system that allows you to customize behavior at three levels: global, per-service, and per-call.
4
+
5
+ ## Global Configuration
6
+
7
+ Configure Operandi globally using an initializer. For Rails applications, create `config/initializers/operandi.rb`:
8
+
9
+ ```ruby
10
+ Operandi.configure do |config|
11
+ # Type enforcement
12
+ config.require_arg_type = true # Require type option for all arguments
13
+ config.require_output_type = true # Require type option for all outputs
14
+
15
+ # Transaction settings
16
+ config.use_transactions = true # Wrap each service in a database transaction
17
+
18
+ # Error behavior
19
+ config.load_errors = true # Copy errors to parent service in context chain
20
+ config.break_on_error = true # Stop step execution when an error is added
21
+ config.raise_on_error = false # Raise an exception when an error is added
22
+ config.rollback_on_error = true # Rollback transaction when an error is added
23
+
24
+ # Warning behavior
25
+ config.load_warnings = true # Copy warnings to parent service in context chain
26
+ config.break_on_warning = false # Stop step execution when a warning is added
27
+ config.raise_on_warning = false # Raise an exception when a warning is added
28
+ config.rollback_on_warning = false # Rollback transaction when a warning is added
29
+ end
30
+ ```
31
+
32
+ ## Default Values
33
+
34
+ | Option | Default | Description |
35
+ |--------|---------|-------------|
36
+ | `require_arg_type` | `true` | Raises `Operandi::MissingTypeError` when defining arguments without a `type` option |
37
+ | `require_output_type` | `true` | Raises `Operandi::MissingTypeError` when defining outputs without a `type` option |
38
+ | `use_transactions` | `true` | Wraps service execution in `ActiveRecord::Base.transaction` |
39
+ | `load_errors` | `true` | Propagates errors to parent service when using `.with(self)` |
40
+ | `break_on_error` | `true` | Stops executing remaining steps when an error is added |
41
+ | `raise_on_error` | `false` | Raises `Operandi::RuntimeError` when an error is added |
42
+ | `rollback_on_error` | `true` | Rolls back the transaction when an error is added |
43
+ | `load_warnings` | `true` | Propagates warnings to parent service when using `.with(self)` |
44
+ | `break_on_warning` | `false` | Stops executing remaining steps when a warning is added |
45
+ | `raise_on_warning` | `false` | Raises `Operandi::RuntimeError` when a warning is added |
46
+ | `rollback_on_warning` | `false` | Rolls back the transaction when a warning is added |
47
+
48
+ ## Per-Service Configuration
49
+
50
+ Override global configuration for a specific service class using the `config` class method:
51
+
52
+ ```ruby
53
+ class CriticalPaymentService < ApplicationService
54
+ # This service will raise exceptions instead of collecting errors
55
+ config raise_on_error: true
56
+
57
+ step :process_payment
58
+ step :send_receipt
59
+
60
+ # ...
61
+ end
62
+ ```
63
+
64
+ ```ruby
65
+ class NonCriticalNotificationService < ApplicationService
66
+ # This service doesn't need transactions and shouldn't stop on errors
67
+ config use_transactions: false, break_on_error: false
68
+
69
+ step :send_push_notification
70
+ step :send_email_notification
71
+
72
+ # ...
73
+ end
74
+ ```
75
+
76
+ ## Per-Call Configuration
77
+
78
+ Override configuration for a single service call:
79
+
80
+ ```ruby
81
+ # Pass config as second argument to run
82
+ MyService.run({ name: "John" }, { raise_on_error: true })
83
+
84
+ # Or use with() for context-based calls
85
+ MyService.with({ raise_on_error: true }).run(name: "John")
86
+
87
+ # Combine with parent service context
88
+ ChildService
89
+ .with(self, { use_transactions: false })
90
+ .run(data: some_data)
91
+ ```
92
+
93
+ ## Configuration Precedence
94
+
95
+ Configuration is merged in this order (later overrides earlier):
96
+
97
+ 1. Global configuration (from initializer)
98
+ 2. Per-service configuration (from `config` class method)
99
+ 3. Per-call configuration (from `run` or `with` arguments)
100
+
101
+ ```ruby
102
+ # Global: raise_on_error = false
103
+ Operandi.configure do |config|
104
+ config.raise_on_error = false
105
+ end
106
+
107
+ # Per-service: raise_on_error = true (overrides global)
108
+ class MyService < ApplicationService
109
+ config raise_on_error: true
110
+ end
111
+
112
+ # Per-call: raise_on_error = false (overrides per-service)
113
+ MyService.run(args, { raise_on_error: false })
114
+ ```
115
+
116
+ ## Common Configuration Patterns
117
+
118
+ ### Strict Mode for Critical Services
119
+
120
+ ```ruby
121
+ class Payment::Process < ApplicationService
122
+ config raise_on_error: true, rollback_on_error: true
123
+
124
+ # Any error will raise an exception and rollback the transaction
125
+ end
126
+ ```
127
+
128
+ ### Fire-and-Forget Services
129
+
130
+ ```ruby
131
+ class Analytics::Track < ApplicationService
132
+ config use_transactions: false, break_on_error: false, load_errors: false
133
+
134
+ # Errors won't stop execution or propagate to parent services
135
+ end
136
+ ```
137
+
138
+ ### Background Job Services
139
+
140
+ ```ruby
141
+ class BackgroundTaskService < ApplicationService
142
+ # Background jobs typically handle their own transactions
143
+ config use_transactions: false
144
+ end
145
+ ```
146
+
147
+ ### Type Enforcement (Enabled by Default)
148
+
149
+ By default, all arguments and outputs must have a `type` option. This helps catch type-related bugs early and makes your services self-documenting.
150
+
151
+ ```ruby
152
+ class User::Create < ApplicationService
153
+ arg :name, type: String # ✓ Valid
154
+ arg :email # ✗ Raises MissingTypeError
155
+ output :user, type: User # ✓ Valid
156
+ output :token # ✗ Raises MissingTypeError
157
+ end
158
+ ```
159
+
160
+ To disable type enforcement globally (not recommended):
161
+
162
+ ```ruby
163
+ Operandi.configure do |config|
164
+ config.require_arg_type = false # Disable for arguments
165
+ config.require_output_type = false # Disable for outputs
166
+ end
167
+ ```
168
+
169
+ Or disable for specific services:
170
+
171
+ ```ruby
172
+ class LegacyService < ApplicationService
173
+ config require_arg_type: false, require_output_type: false
174
+
175
+ arg :data # Allowed when require_arg_type is disabled
176
+ output :result # Allowed when require_output_type is disabled
177
+ end
178
+ ```
179
+
180
+ You can also control them independently:
181
+
182
+ ```ruby
183
+ class StrictInputService < ApplicationService
184
+ # Require types for arguments but not outputs
185
+ config require_arg_type: true, require_output_type: false
186
+
187
+ arg :data, type: Hash # Type required
188
+ output :result # Type not required
189
+ end
190
+ ```
191
+
192
+ ## Disabling Transactions
193
+
194
+ If you're not using ActiveRecord or want to manage transactions yourself:
195
+
196
+ ```ruby
197
+ Operandi.configure do |config|
198
+ config.use_transactions = false
199
+ end
200
+ ```
201
+
202
+ Or disable for specific services:
203
+
204
+ ```ruby
205
+ class MyService < ApplicationService
206
+ config use_transactions: false
207
+ end
208
+ ```
209
+
210
+ {% hint style="info" %}
211
+ When `use_transactions` is `true`, Operandi uses `ActiveRecord::Base.transaction(requires_new: true)` to create savepoints, allowing nested services to rollback independently.
212
+ {% endhint %}
213
+
214
+ ## What's Next?
215
+
216
+ Now that you understand configuration, learn about the core concepts:
217
+
218
+ [Next: Concepts](concepts.md)
data/docs/context.md ADDED
@@ -0,0 +1,128 @@
1
+ # Context
2
+
3
+ Context allows services to be run within the same execution scope, enabling shared state and coordinated transactions.
4
+
5
+ ## Key Features
6
+
7
+ - Services share arguments marked as `context: true`
8
+ - If any service fails, the entire context fails and rolls back database changes
9
+
10
+ ## How to Run Services in the Same Context
11
+
12
+ To run a service in the same context, call `with(self)` before the `#run` method.
13
+
14
+ ## Context Rollback
15
+
16
+ ### Example:
17
+
18
+ Let's say we have two services: `User::Create` and `Profile::Create`. We want to ensure that if either service fails, all database changes are rolled back.
19
+
20
+ ```ruby
21
+ class User::Create < ApplicationService
22
+ # Arguments
23
+ arg :attributes, type: Hash
24
+
25
+ # Steps
26
+ step :create_user
27
+ step :create_profile
28
+ step :send_welcome_email
29
+
30
+ # Outputs
31
+ output :user, type: User
32
+ output :profile, type: Profile
33
+
34
+ def create_user
35
+ self.user = User.create!(attributes)
36
+ end
37
+
38
+ def create_profile
39
+ service = Profile::Create
40
+ .with(self) # This runs the service in the same context
41
+ .run(user:)
42
+
43
+ self.profile = service.profile
44
+ end
45
+
46
+ # If the Profile::Create service fails, this step and any following steps won't execute
47
+ # And all database changes will be rolled back
48
+ def send_welcome_email
49
+ # We don't run this service in the same context
50
+ # Because we don't care too much if it fails
51
+ service = Mailer::SendWelcomeEmail.run(user:)
52
+
53
+ # Handle the failure manually if needed
54
+ if service.failed?
55
+ # Handle the failure
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ ## Context Arguments
62
+
63
+ Context arguments are shared between services running in the same context. This can make them a bit less predictable and harder to test.
64
+
65
+ It's recommended to use context arguments only when necessary and keep them as close to the root service as possible. For example, you can use them to share `current_user` or `current_organization` between services.
66
+
67
+ ```ruby
68
+ class ApplicationService < Operandi::Base
69
+ arg :current_user, type: User, context: true
70
+ end
71
+ ```
72
+
73
+ ```ruby
74
+ class Comment::Create < ApplicationService
75
+ # Arguments
76
+ # We don't need to specify current_user here
77
+ # as it's automatically inherited from the ApplicationService
78
+ arg :post_id, type: Integer
79
+ arg :text, type: String
80
+ arg :subscribe, type: [TrueClass, FalseClass]
81
+
82
+ # Steps
83
+ step :create_comment
84
+ step :subscribe_to_post, if: :subscribe?
85
+
86
+ private
87
+
88
+ def create_comment
89
+ # ...
90
+ end
91
+
92
+ def subscribe_to_post
93
+ Post::Subscribe
94
+ .with(self) # Run service in the same context
95
+ .run(post_id:) # We omit current_user here as context will handle it for us
96
+
97
+ # If we run Post::Subscribe without `with(self)`
98
+ # It'll fail because it won't have information about the `current_user`
99
+ end
100
+ end
101
+ ```
102
+
103
+ ```ruby
104
+ class Post::Subscribe < ApplicationService
105
+ # Arguments
106
+ arg :post_id, type: Integer
107
+
108
+ # Steps
109
+ step :subscribe
110
+
111
+ private
112
+
113
+ def subscribe
114
+ # We have access to current_user here because we run it in the same context
115
+ #
116
+ # Even if we would run this service without context this won't be a problem
117
+ # because we specified this argument in top-level service (ApplicationService)
118
+ current_user.subscriptions.create!(post_id:)
119
+ end
120
+ end
121
+ ```
122
+
123
+ # What's Next?
124
+
125
+ The next step is to learn about error handling in Operandi.
126
+
127
+ [Next: Errors](errors.md)
128
+