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,222 @@
1
+ # Service Rendering
2
+
3
+ This recipe provides a clean way to render service results and errors in your Rails controllers, reducing boilerplate and ensuring consistent API responses.
4
+
5
+ ## The Problem
6
+
7
+ Without a helper, controller actions become repetitive:
8
+
9
+ ```ruby
10
+ class PostsController < ApplicationController
11
+ def create
12
+ service = Post::Create.run(service_args(attributes: params[:post]))
13
+
14
+ if service.success?
15
+ render json: service.post, status: :created
16
+ else
17
+ render json: { errors: service.errors.to_h }, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ def update
22
+ service = Post::Update.run(service_args(record: @post, attributes: params[:post]))
23
+
24
+ if service.success?
25
+ render json: service.post
26
+ else
27
+ render json: { errors: service.errors.to_h }, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ # ... same pattern repeated for every action
32
+ end
33
+ ```
34
+
35
+ ## The Solution
36
+
37
+ Create a `render_service` helper that handles success and failure automatically.
38
+
39
+ ## Implementation
40
+
41
+ ### Basic Helper
42
+
43
+ Add this to your `ApplicationController`:
44
+
45
+ ```ruby
46
+ class ApplicationController < ActionController::API
47
+ private
48
+
49
+ def render_service(service, success_status: :ok, error_status: :unprocessable_entity)
50
+ if service.success?
51
+ yield(service) if block_given?
52
+ render json: service_response(service), status: success_status
53
+ else
54
+ render json: { errors: service.errors.to_h }, status: error_status
55
+ end
56
+ end
57
+
58
+ def service_response(service)
59
+ # Returns the first output that is set
60
+ service.class.outputs.each do |name, _|
61
+ value = service.public_send(name)
62
+ return value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
63
+ end
64
+
65
+ {}
66
+ end
67
+ end
68
+ ```
69
+
70
+ ### Usage
71
+
72
+ ```ruby
73
+ class PostsController < ApplicationController
74
+ def create
75
+ render_service Post::Create.run(service_args(attributes: params[:post])),
76
+ success_status: :created
77
+ end
78
+
79
+ def update
80
+ render_service Post::Update.run(service_args(record: @post))
81
+ end
82
+
83
+ def destroy
84
+ render_service Post::Destroy.run(service_args(record: @post))
85
+ end
86
+ end
87
+ ```
88
+
89
+ ## Advanced Implementation
90
+
91
+ ### With Custom Response Building
92
+
93
+ ```ruby
94
+ class ApplicationController < ActionController::API
95
+ private
96
+
97
+ def render_service(service, **options)
98
+ if service.success?
99
+ render_service_success(service, options)
100
+ else
101
+ render_service_failure(service, options)
102
+ end
103
+ end
104
+
105
+ def render_service_success(service, options)
106
+ status = options[:success_status] || :ok
107
+
108
+ response = if options[:response]
109
+ options[:response]
110
+ elsif options[:output]
111
+ service.public_send(options[:output])
112
+ else
113
+ auto_detect_response(service)
114
+ end
115
+
116
+ render json: response, status: status
117
+ end
118
+
119
+ def render_service_failure(service, options)
120
+ status = options[:error_status] || :unprocessable_entity
121
+
122
+ render json: {
123
+ errors: service.errors.to_h,
124
+ warnings: service.warnings.to_h
125
+ }.compact, status: status
126
+ end
127
+
128
+ def auto_detect_response(service)
129
+ service.class.outputs.each do |name, _|
130
+ value = service.public_send(name)
131
+ return value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
132
+ end
133
+
134
+ { success: true }
135
+ end
136
+ end
137
+ ```
138
+
139
+ ### Usage with Options
140
+
141
+ ```ruby
142
+ class PostsController < ApplicationController
143
+ def create
144
+ service = Post::Create.run(service_args(attributes: params[:post]))
145
+
146
+ render_service service,
147
+ success_status: :created,
148
+ output: :post
149
+ end
150
+
151
+ def bulk_create
152
+ service = Post::BulkCreate.run(service_args(items: params[:posts]))
153
+
154
+ render_service service,
155
+ success_status: :created,
156
+ response: { posts: service.posts, count: service.posts.count }
157
+ end
158
+ end
159
+ ```
160
+
161
+ ## With Serializers
162
+
163
+ If you're using a serializer library (like Alba, Blueprinter, or ActiveModel::Serializers):
164
+
165
+ ```ruby
166
+ class ApplicationController < ActionController::API
167
+ private
168
+
169
+ def render_service(service, serializer: nil, **options)
170
+ if service.success?
171
+ response = auto_detect_response(service)
172
+ response = serializer.new(response).to_h if serializer && response
173
+
174
+ render json: response, status: options[:success_status] || :ok
175
+ else
176
+ render json: { errors: service.errors.to_h },
177
+ status: options[:error_status] || :unprocessable_entity
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ ```ruby
184
+ class PostsController < ApplicationController
185
+ def show
186
+ service = Post::Find.run(service_args(id: params[:id]))
187
+ render_service service, serializer: PostSerializer
188
+ end
189
+ end
190
+ ```
191
+
192
+ ## Handling Different Error Types
193
+
194
+ ```ruby
195
+ def render_service(service, **options)
196
+ if service.success?
197
+ render_service_success(service, options)
198
+ else
199
+ status = determine_error_status(service, options)
200
+ render json: { errors: service.errors.to_h }, status: status
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def determine_error_status(service, options)
207
+ return options[:error_status] if options[:error_status]
208
+
209
+ # Map specific error keys to HTTP statuses
210
+ return :not_found if service.errors[:record]&.any?
211
+ return :forbidden if service.errors[:authorization]&.any?
212
+ return :unauthorized if service.errors[:authentication]&.any?
213
+
214
+ :unprocessable_entity
215
+ end
216
+ ```
217
+
218
+ ## What's Next?
219
+
220
+ Learn how to integrate Pundit authorization with Operandi:
221
+
222
+ [Next: Pundit Authorization](pundit-authorization.md)
@@ -0,0 +1,283 @@
1
+ # Sorbet Runtime Types
2
+
3
+ Operandi supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation of arguments and outputs. This provides runtime type checking using Sorbet's type system.
4
+
5
+ {% hint style="info" %}
6
+ This page covers **runtime type checking** with `sorbet-runtime`. For **static type analysis** with Sorbet and RBI file generation, see [Tapioca / Sorbet Integration](tapioca.md).
7
+ {% endhint %}
8
+
9
+ ## Installation
10
+
11
+ Add `sorbet-runtime` to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "sorbet-runtime"
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Basic Usage
24
+
25
+ When `sorbet-runtime` is loaded, plain Ruby classes are automatically validated using Sorbet's type system:
26
+
27
+ ```ruby
28
+ require "sorbet-runtime"
29
+
30
+ class User::Create < ApplicationService
31
+ # Basic types - plain Ruby classes work directly!
32
+ arg :name, type: String
33
+ arg :age, type: Integer
34
+
35
+ # Nilable types (allows nil)
36
+ arg :email, type: T.nilable(String), optional: true
37
+
38
+ # Union types (multiple allowed types)
39
+ arg :status, type: T.any(String, Symbol), default: "pending"
40
+
41
+ # Typed arrays
42
+ arg :tags, type: T::Array[String], optional: true
43
+
44
+ # Boolean type
45
+ arg :active, type: T::Boolean, default: true
46
+
47
+ # Outputs with Sorbet types - plain classes work here too
48
+ output :user, type: User
49
+ output :metadata, type: Hash
50
+
51
+ step :create_user
52
+ step :build_metadata
53
+
54
+ private
55
+
56
+ def create_user
57
+ self.user = User.create!(
58
+ name: name,
59
+ age: age,
60
+ email: email,
61
+ status: status,
62
+ tags: tags || [],
63
+ active: active
64
+ )
65
+ end
66
+
67
+ def build_metadata
68
+ self.metadata = { created_at: Time.current }
69
+ end
70
+ end
71
+ ```
72
+
73
+ ## Type Reference
74
+
75
+ ### Basic Types
76
+
77
+ When `sorbet-runtime` is loaded, plain Ruby classes are automatically coerced to Sorbet types:
78
+
79
+ ```ruby
80
+ arg :name, type: String
81
+ arg :count, type: Integer
82
+ arg :price, type: Float
83
+ arg :data, type: Hash
84
+ arg :items, type: Array
85
+ ```
86
+
87
+ {% hint style="info" %}
88
+ You can also use `T::Utils.coerce(String)` explicitly, but it's not required - Operandi handles the coercion automatically.
89
+ {% endhint %}
90
+
91
+ ### Nilable Types
92
+
93
+ Allow `nil` values with `T.nilable`:
94
+
95
+ ```ruby
96
+ arg :nickname, type: T.nilable(String), optional: true
97
+ ```
98
+
99
+ ### Union Types
100
+
101
+ Allow multiple types with `T.any`:
102
+
103
+ ```ruby
104
+ arg :identifier, type: T.any(String, Integer)
105
+ arg :status, type: T.any(String, Symbol)
106
+ ```
107
+
108
+ ### Typed Arrays
109
+
110
+ Validate array element types with `T::Array`:
111
+
112
+ ```ruby
113
+ arg :tags, type: T::Array[String]
114
+ arg :numbers, type: T::Array[Integer]
115
+ arg :users, type: T::Array[User]
116
+ ```
117
+
118
+ {% hint style="info" %}
119
+ **Generic Type Erasure:** Sorbet's `T::Array[String]` only validates that the value is an `Array` at runtime. The type parameter (`String`) is **erased** and not checked at runtime. For strict element validation, implement custom validation in your service steps.
120
+ {% endhint %}
121
+
122
+ ### Boolean Type
123
+
124
+ Use `T::Boolean` for true/false values:
125
+
126
+ ```ruby
127
+ arg :active, type: T::Boolean
128
+ arg :verified, type: T::Boolean, default: false
129
+ ```
130
+
131
+ ### Complex Types
132
+
133
+ Combine types for more complex validations:
134
+
135
+ ```ruby
136
+ # Nilable array
137
+ arg :tags, type: T.nilable(T::Array[String]), optional: true
138
+
139
+ # Array of union types
140
+ arg :identifiers, type: T::Array[T.any(String, Integer)]
141
+ ```
142
+
143
+ ## Important: Sorbet Types Validate Only
144
+
145
+ {% hint style="warning" %}
146
+ **Sorbet types do NOT coerce values.** Sorbet runtime types only validate that values match the expected type. They will not automatically convert values (e.g., `"123"` will not become `123`).
147
+ {% endhint %}
148
+
149
+ ### Validation Behavior
150
+
151
+ ```ruby
152
+ # Sorbet runtime type
153
+ arg :age, type: Integer
154
+
155
+ # Valid - passes validation
156
+ service = MyService.run(age: 25)
157
+ service.age # => 25 (Integer)
158
+
159
+ # Invalid - raises error
160
+ service = MyService.run(age: "25")
161
+ # => Raises ArgTypeError: expected Integer, got String
162
+ ```
163
+
164
+ If you need coercion, handle it explicitly in your service:
165
+
166
+ ```ruby
167
+ class MyService < ApplicationService
168
+ arg :age, type: Integer
169
+
170
+ step :coerce_inputs
171
+ step :process
172
+
173
+ private
174
+
175
+ def coerce_inputs
176
+ self.age = age.to_i if age.is_a?(String)
177
+ end
178
+ end
179
+ ```
180
+
181
+ ## Combining with Tapioca
182
+
183
+ For full Sorbet support, you can use both:
184
+
185
+ 1. **Runtime types** (`sorbet-runtime`) - Validates types at runtime
186
+ 2. **Static types** (Tapioca) - Generates RBI files for static analysis
187
+
188
+ See [Tapioca / Sorbet Integration](tapioca.md) for setting up static type analysis.
189
+
190
+ ```ruby
191
+ # typed: strict
192
+
193
+ class User::Create < ApplicationService
194
+ # Runtime validation with Sorbet types
195
+ arg :name, type: String
196
+ arg :age, type: Integer
197
+
198
+ output :user, type: User
199
+
200
+ # ...
201
+ end
202
+ ```
203
+
204
+ With Tapioca configured, you get:
205
+ - **Runtime validation** from `sorbet-runtime`
206
+ - **IDE autocompletion** from generated RBI files
207
+ - **Static type checking** from `srb tc`
208
+
209
+ ## Error Messages
210
+
211
+ When type validation fails, Operandi raises `ArgTypeError` with a descriptive message
212
+ and the associated service class:
213
+
214
+ ```ruby
215
+ begin
216
+ User::Create.run(name: 123, age: 25)
217
+ rescue Operandi::ArgTypeError => error
218
+ error.service_class # => User::Create
219
+ error.message
220
+ # => "User::Create argument `name` expected String, but got Integer with value: 123"
221
+ end
222
+ ```
223
+
224
+ ## Full Example
225
+
226
+ ```ruby
227
+ require "sorbet-runtime"
228
+
229
+ class Order::Create < ApplicationService
230
+ # Required arguments - plain classes work!
231
+ arg :customer, type: Customer
232
+ arg :items, type: T::Array[OrderItem]
233
+ arg :total, type: T.any(Integer, Float)
234
+
235
+ # Optional arguments
236
+ arg :notes, type: T.nilable(String), optional: true
237
+ arg :priority, type: T::Boolean, default: false
238
+ arg :tags, type: T::Array[String], optional: true
239
+
240
+ # Outputs
241
+ output :order, type: Order
242
+ output :confirmation_number, type: String
243
+
244
+ step :validate_items
245
+ step :create_order
246
+ step :generate_confirmation
247
+
248
+ private
249
+
250
+ def validate_items
251
+ fail!("Order must have at least one item") if items.empty?
252
+ end
253
+
254
+ def create_order
255
+ self.order = Order.create!(
256
+ customer: customer,
257
+ items: items,
258
+ total: total,
259
+ notes: notes,
260
+ priority: priority,
261
+ tags: tags || []
262
+ )
263
+ end
264
+
265
+ def generate_confirmation
266
+ self.confirmation_number = "ORD-#{order.id}-#{SecureRandom.hex(4).upcase}"
267
+ end
268
+ end
269
+
270
+ # Usage
271
+ result = Order::Create.run(
272
+ customer: current_user,
273
+ items: [item1, item2],
274
+ total: 99.99,
275
+ priority: true
276
+ )
277
+
278
+ if result.success?
279
+ puts "Order created: #{result.confirmation_number}"
280
+ else
281
+ puts "Failed: #{result.errors.full_messages}"
282
+ end
283
+ ```