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.
- checksums.yaml +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- 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
|
+
```
|