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
data/docs/arguments.md ADDED
@@ -0,0 +1,275 @@
1
+ # Arguments
2
+
3
+ Arguments are the inputs to a service. They are passed to the service when it is invoked.
4
+
5
+ ## TL;DR
6
+
7
+ - Define arguments with the `arg` keyword in the service class
8
+ - Validate arguments by type
9
+ - Specify arguments as required or optional
10
+ - Set default values for arguments
11
+ - Access arguments like instance variables
12
+ - Use predicate methods for arguments
13
+
14
+ ```ruby
15
+ class User::Charge < ApplicationService
16
+ arg :user, type: User
17
+ arg :amount, type: Float
18
+ arg :send_receipt, type: [TrueClass, FalseClass], default: true
19
+ # In Rails you might prefer `Date.current`.
20
+ arg :invoice_date, type: Date, default: -> { Date.today }
21
+
22
+ step :send_email_receipt, if: :send_receipt?
23
+
24
+ # ...
25
+ end
26
+ ```
27
+
28
+ ## Define Arguments
29
+
30
+ Arguments are defined using the `arg` keyword in the service class.
31
+
32
+ ```ruby
33
+ class HappyBirthdayService < ApplicationService
34
+ arg :name
35
+ arg :age
36
+ end
37
+ ```
38
+
39
+ ## Type Validation
40
+
41
+ Arguments can be validated by type.
42
+
43
+ ```ruby
44
+ class HappyBirthdayService < ApplicationService
45
+ arg :name, type: String
46
+ arg :age, type: Integer
47
+ end
48
+ ```
49
+
50
+ You can specify multiple allowed types using an array.
51
+
52
+ ```ruby
53
+ class HappyBirthdayService < ApplicationService
54
+ arg :name, type: [String, Symbol]
55
+ end
56
+ ```
57
+
58
+ ### Type Enforcement (Enabled by Default)
59
+
60
+ By default, all arguments must have a `type` option. This helps catch type-related bugs early and makes your services self-documenting.
61
+
62
+ ```ruby
63
+ class MyService < ApplicationService
64
+ arg :name, type: String # ✓ Valid
65
+ arg :age # ✗ Raises MissingTypeError
66
+ end
67
+ ```
68
+
69
+ To disable type enforcement for arguments in a specific service:
70
+
71
+ ```ruby
72
+ class LegacyService < ApplicationService
73
+ config require_arg_type: false
74
+
75
+ arg :name # Allowed when require_arg_type is disabled
76
+ end
77
+ ```
78
+
79
+ See the [Configuration documentation](configuration.md) for more details.
80
+
81
+ ### Sorbet Runtime Types
82
+
83
+ Operandi supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation. Sorbet types **only validate** and do not coerce values.
84
+
85
+ ```ruby
86
+ require "sorbet-runtime"
87
+
88
+ class User::Create < ApplicationService
89
+ # Basic types using T::Utils.coerce
90
+ arg :name, type: T::Utils.coerce(String)
91
+ arg :age, type: T::Utils.coerce(Integer)
92
+
93
+ # Nilable types
94
+ arg :email, type: T.nilable(String), optional: true
95
+
96
+ # Union types
97
+ arg :status, type: T.any(String, Symbol)
98
+
99
+ # Typed arrays
100
+ arg :tags, type: T::Array[String]
101
+
102
+ # Boolean type
103
+ arg :active, type: T::Boolean, default: true
104
+ end
105
+ ```
106
+
107
+ {% hint style="warning" %}
108
+ **Sorbet types do NOT coerce values.** If you pass `"25"` where an `Integer` is expected, it will raise an error instead of converting the string to an integer.
109
+ {% endhint %}
110
+
111
+ See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
112
+
113
+ ## Required Arguments
114
+
115
+ By default, arguments are required. You can make them optional by setting `optional` to `true`.
116
+
117
+ ```ruby
118
+ class HappyBirthdayService < ApplicationService
119
+ arg :name, type: String
120
+ arg :age, type: Integer, optional: true
121
+ end
122
+ ```
123
+
124
+ ## Default Values
125
+
126
+ Set a default value for an argument to make it optional.
127
+
128
+ ```ruby
129
+ class HappyBirthdayService < ApplicationService
130
+ arg :name, type: String
131
+ arg :age, type: Integer, default: 18
132
+ end
133
+ ```
134
+
135
+ ### Complex Default Values
136
+
137
+ Default values are deep duplicated when the service is invoked, making it safe to use mutable objects.
138
+
139
+ ```ruby
140
+ arg :options, type: Hash, default: { a: 1, b: 2 }
141
+ ```
142
+
143
+ ### Procs as Default Values
144
+
145
+ Use procs for dynamic default values.
146
+
147
+ ```ruby
148
+ arg :current_date, type: Date, default: -> { Date.current }
149
+ ```
150
+
151
+ ## Inheritance
152
+
153
+ Arguments are inherited from parent classes.
154
+
155
+ ```ruby
156
+ # UpdateRecordService
157
+ class UpdateRecordService < ApplicationService
158
+ # Arguments
159
+ arg :record, type: ApplicationRecord
160
+ arg :attributes, type: Hash
161
+
162
+ # Steps
163
+ step :authorize
164
+ step :update_record
165
+ end
166
+ ```
167
+
168
+ ```ruby
169
+ # User::Update inherited from UpdateRecordService
170
+ class User::Update < UpdateRecordService
171
+ # Nothing to do here
172
+ # Arguments and steps are inherited from UpdateRecordService
173
+ end
174
+ ```
175
+
176
+ ### Removing Inherited Arguments
177
+
178
+ To remove an inherited argument, use `remove_arg`:
179
+
180
+ ```ruby
181
+ class BaseService < ApplicationService
182
+ arg :current_user, type: User
183
+ arg :audit_log, type: [TrueClass, FalseClass], default: true
184
+ end
185
+
186
+ class SystemTaskService < BaseService
187
+ # System tasks don't need a current_user
188
+ remove_arg :current_user
189
+ end
190
+ ```
191
+
192
+ ## Context Arguments
193
+
194
+ Context arguments are automatically passed to all child services in the same context. Define them using the `context` option. This is useful for passing objects like `current_user`.
195
+
196
+ Learn more about context in the [Context documentation](context.md).
197
+
198
+ ```ruby
199
+ class ApplicationService < Operandi::Base
200
+ arg :current_user, type: User, optional: true, context: true
201
+ end
202
+ ```
203
+
204
+ ## Accessing Arguments
205
+
206
+ Arguments are accessible like instance variables, similar to `attr_accessor`.
207
+
208
+ ```ruby
209
+ class HappyBirthdayService < ApplicationService
210
+ # Arguments
211
+ arg :name, type: String
212
+ arg :age, type: Integer
213
+
214
+ # Steps
215
+ step :greet
216
+
217
+ private
218
+
219
+ def greet
220
+ puts "Happy birthday, #{name}! You are #{age} years old."
221
+ end
222
+ end
223
+ ```
224
+
225
+ ## Accessing Arguments Using `arg`
226
+
227
+ For dynamic access or to avoid conflicts, use the `arg` method.
228
+
229
+ ```ruby
230
+ class HappyBirthdayService < ApplicationService
231
+ # Arguments
232
+ arg :name, type: String
233
+ arg :age, type: Integer
234
+
235
+ # Steps
236
+ step :greet
237
+
238
+ private
239
+
240
+ def greet
241
+ name = arg[:name] # or arg.get(:name)
242
+ age = arg[:age] # or arg.get(:age)
243
+
244
+ puts "Happy birthday, #{name}! You are #{age} years old."
245
+ end
246
+ end
247
+ ```
248
+
249
+ ## Argument Predicate Methods
250
+
251
+ Predicate methods are automatically generated for each argument, allowing you to check if an argument is `true` or `false`.
252
+
253
+ ```ruby
254
+ class User::GenerateInvoice < ApplicationService
255
+ # Arguments
256
+ arg :user, type: User
257
+ arg :charge, type: [TrueClass, FalseClass], default: false
258
+
259
+ # Steps
260
+ step :generate_invoice
261
+ step :charge_user, if: :charge?
262
+
263
+ # ...
264
+ end
265
+ ```
266
+
267
+ {% hint style="info" %}
268
+ The predicate methods return `true` or `false` based on Ruby's convention: `nil` and `false` are `false`, everything else is `true`.
269
+ {% endhint %}
270
+
271
+ ## What's Next?
272
+
273
+ Next step is `steps` (I love this pun). Steps are the building blocks of a service, the methods that do the actual work.
274
+
275
+ [Next: Steps](steps.md)
@@ -0,0 +1,153 @@
1
+ # Best Practices
2
+
3
+ This guide explores best practices for building applications with Operandi, keeping things simple and effective.
4
+
5
+ ## Create Top-Level Services
6
+
7
+ Creating top-level services for your application is highly recommended. This approach helps keep your services small and focused on a single task.
8
+
9
+ ### Application Service
10
+
11
+ `ApplicationService` serves as the base class for all services in your application. Use it to place common methods, helpers, context arguments, etc. Remember, it should not contain any business logic.
12
+
13
+ ### Create, Update, and Destroy Services
14
+
15
+ Since create, update, and destroy are fundamental operations in any application, having dedicated services for them is a good idea. This keeps important tasks like authorization, data sanitization, and WebSocket broadcasts close to the core of your application.
16
+
17
+ - `CreateRecordService` - for creating records
18
+ - `UpdateRecordService` - for updating records
19
+ - `DestroyRecordService` - for destroying records
20
+
21
+ Think of these services as wrappers around the `ActiveRecord::Base#create`, `#update`, and `#destroy` methods.
22
+
23
+ ### Read Services
24
+
25
+ Similar to the above services but focused on finding records. Use these for generic authorization, filtering, sorting, pagination, etc.
26
+
27
+ - `FindRecordService` - for finding a single record
28
+ - `FindAllRecordsService` - for finding multiple records
29
+
30
+ ## Avoid Defining Context Arguments Outside Top-Level Services
31
+
32
+ Using context arguments outside of top-level services can make your services less modular and more unpredictable. Keep them within the core services for better modularity.
33
+
34
+ ## Keep Services Small
35
+
36
+ Aim to keep your services small and focused on a single task. Ideally, a service should have no more than 3-5 steps. If a service has more steps, consider splitting it into multiple services.
37
+
38
+ ## Passing Arguments from Controllers
39
+
40
+ It's a good practice to create a wrapper method to extend arguments passed to the service from the controller.
41
+
42
+ Consider this example controller:
43
+
44
+ ```ruby
45
+ class PostsController < ApplicationController
46
+ def index
47
+ service = Post::FindAll.run(current_user:, current_organization:)
48
+ render json: service.posts
49
+ end
50
+
51
+ def create
52
+ service = Post::Create.run(attributes: params[:post], current_user:, current_organization:)
53
+
54
+ if service.success?
55
+ render json: service.post
56
+ else
57
+ render json: { errors: service.errors }, status: :unprocessable_entity
58
+ end
59
+ end
60
+
61
+ def unpublish
62
+ service = Post::Unpublish.run(id: params[:id], current_user:, current_organization:)
63
+
64
+ if service.success?
65
+ render json: service.post
66
+ else
67
+ render json: { errors: service.errors }, status: :unprocessable_entity
68
+ end
69
+ end
70
+
71
+ # ...
72
+ end
73
+ ```
74
+
75
+ Manually passing `current_user` and `current_organization` each time can be cumbersome. Let's simplify it with a helper method in our `ApplicationController`:
76
+
77
+ ```ruby
78
+ class ApplicationController < ActionController::API
79
+ private
80
+
81
+ def service_args(hash = {})
82
+ hash.reverse_merge(
83
+ current_user:,
84
+ current_organization:,
85
+ )
86
+ end
87
+ end
88
+ ```
89
+
90
+ Now we can refactor our controller:
91
+
92
+ ```ruby
93
+ class PostsController < ApplicationController
94
+ def index
95
+ service = Post::FindAll.run(service_args)
96
+ render json: service.posts
97
+ end
98
+
99
+ def create
100
+ service = Post::Create.run(service_args(attributes: params[:post]))
101
+
102
+ if service.success?
103
+ render json: service.post
104
+ else
105
+ render json: { errors: service.errors }, status: :unprocessable_entity
106
+ end
107
+ end
108
+
109
+ def unpublish
110
+ service = Post::Unpublish.run(service_args(id: params[:id]))
111
+
112
+ if service.success?
113
+ render json: service.post
114
+ else
115
+ render json: { errors: service.errors }, status: :unprocessable_entity
116
+ end
117
+ end
118
+
119
+ # ...
120
+ end
121
+ ```
122
+
123
+ With this setup, adding a new top-level context argument only requires a change to the `service_args` method in `ApplicationController`.
124
+
125
+ ## Use Concerns
126
+
127
+ If you have common logic that you want to share between services, use concerns. Avoid putting too much logic into your `ApplicationService` class; it's better to split it into concerns.
128
+
129
+ For example, create an `AuthorizeUser` concern for authorization logic.
130
+
131
+ ```ruby
132
+ # app/services/concerns/authorize_user.rb
133
+ module AuthorizeUser
134
+ extend ActiveSupport::Concern
135
+
136
+ included do
137
+ # ...
138
+ end
139
+ end
140
+ ```
141
+
142
+ ```ruby
143
+ # app/services/application_service.rb
144
+ class ApplicationService < Operandi::Base
145
+ include AuthorizeUser
146
+ end
147
+ ```
148
+
149
+ ## What's Next?
150
+
151
+ Explore practical recipes for common patterns:
152
+
153
+ [Next: Recipes](recipes.md)