light-services 2.2.1 → 3.1.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 +4 -4
- data/.github/config/rubocop_linter_action.yml +4 -4
- data/.github/workflows/ci.yml +12 -12
- data/.gitignore +1 -0
- data/.rubocop.yml +83 -7
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +139 -0
- data/Gemfile +16 -11
- data/Gemfile.lock +53 -27
- data/README.md +84 -21
- data/docs/arguments.md +290 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +476 -0
- data/docs/concepts.md +80 -0
- data/docs/configuration.md +204 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +280 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +158 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +134 -0
- data/docs/readme.md +101 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +285 -0
- data/docs/ruby-lsp.md +133 -0
- data/docs/service-rendering.md +222 -0
- data/docs/steps.md +391 -0
- data/docs/summary.md +21 -0
- data/docs/testing.md +549 -0
- data/lib/generators/light_services/install/USAGE +15 -0
- data/lib/generators/light_services/install/install_generator.rb +41 -0
- data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/light_services/service/USAGE +21 -0
- data/lib/generators/light_services/service/service_generator.rb +68 -0
- data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
- data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
- data/lib/light/services/base.rb +134 -122
- data/lib/light/services/base_with_context.rb +23 -1
- data/lib/light/services/callbacks.rb +157 -0
- data/lib/light/services/collection.rb +145 -0
- data/lib/light/services/concerns/execution.rb +79 -0
- data/lib/light/services/concerns/parent_service.rb +34 -0
- data/lib/light/services/concerns/state_management.rb +30 -0
- data/lib/light/services/config.rb +82 -16
- data/lib/light/services/constants.rb +100 -0
- data/lib/light/services/dsl/arguments_dsl.rb +85 -0
- data/lib/light/services/dsl/outputs_dsl.rb +81 -0
- data/lib/light/services/dsl/steps_dsl.rb +205 -0
- data/lib/light/services/dsl/validation.rb +162 -0
- data/lib/light/services/exceptions.rb +25 -2
- data/lib/light/services/message.rb +28 -3
- data/lib/light/services/messages.rb +92 -32
- data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
- data/lib/light/services/rspec/matchers/define_output.rb +147 -0
- data/lib/light/services/rspec/matchers/define_step.rb +225 -0
- data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
- data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
- data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
- data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
- data/lib/light/services/rspec.rb +15 -0
- data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
- data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
- data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
- data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
- data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
- data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
- data/lib/light/services/rubocop.rb +12 -0
- data/lib/light/services/settings/field.rb +114 -0
- data/lib/light/services/settings/step.rb +53 -20
- data/lib/light/services/utils.rb +38 -0
- data/lib/light/services/version.rb +1 -1
- data/lib/light/services.rb +2 -0
- data/lib/ruby_lsp/light_services/addon.rb +36 -0
- data/lib/ruby_lsp/light_services/definition.rb +132 -0
- data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
- data/light-services.gemspec +6 -8
- metadata +68 -26
- data/lib/light/services/class_based_collection/base.rb +0 -86
- data/lib/light/services/class_based_collection/mount.rb +0 -33
- data/lib/light/services/collection/arguments.rb +0 -34
- data/lib/light/services/collection/base.rb +0 -59
- data/lib/light/services/collection/outputs.rb +0 -16
- data/lib/light/services/settings/argument.rb +0 -68
- data/lib/light/services/settings/output.rb +0 -34
data/docs/rubocop.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# RuboCop Integration
|
|
2
|
+
|
|
3
|
+
Light Services provides custom RuboCop cops to help enforce best practices in your service definitions.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Add this to your `.rubocop.yml`:
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
require:
|
|
11
|
+
- light/services/rubocop
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Available Cops
|
|
15
|
+
|
|
16
|
+
### LightServices/ArgumentTypeRequired
|
|
17
|
+
|
|
18
|
+
Ensures all `arg` declarations include a `type:` option.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# bad
|
|
22
|
+
arg :user_id
|
|
23
|
+
arg :params, default: {}
|
|
24
|
+
|
|
25
|
+
# good
|
|
26
|
+
arg :user_id, type: Integer
|
|
27
|
+
arg :params, type: Hash, default: {}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### LightServices/OutputTypeRequired
|
|
31
|
+
|
|
32
|
+
Ensures all `output` declarations include a `type:` option.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# bad
|
|
36
|
+
output :result
|
|
37
|
+
output :data, optional: true
|
|
38
|
+
|
|
39
|
+
# good
|
|
40
|
+
output :result, type: Hash
|
|
41
|
+
output :data, type: Hash, optional: true
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### LightServices/StepMethodExists
|
|
45
|
+
|
|
46
|
+
Ensures all `step` declarations have a corresponding method defined.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# bad
|
|
50
|
+
class MyService < ApplicationService
|
|
51
|
+
step :validate
|
|
52
|
+
step :process # missing method
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate; end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# good
|
|
60
|
+
class MyService < ApplicationService
|
|
61
|
+
step :validate
|
|
62
|
+
step :process
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate; end
|
|
67
|
+
def process; end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Configuration:** Use `ExcludedSteps` for inherited steps:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
LightServices/StepMethodExists:
|
|
75
|
+
ExcludedSteps:
|
|
76
|
+
- initialize_entity
|
|
77
|
+
- assign_attributes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### LightServices/ConditionMethodExists
|
|
81
|
+
|
|
82
|
+
Ensures symbol conditions (`:if`, `:unless`) have corresponding methods defined.
|
|
83
|
+
|
|
84
|
+
This cop automatically recognizes predicate methods generated by `arg` and `output` declarations (e.g., `arg :user` creates `user?`).
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# bad
|
|
88
|
+
class MyService < ApplicationService
|
|
89
|
+
step :notify, if: :should_notify? # missing method
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def notify; end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# good - explicit method
|
|
97
|
+
class MyService < ApplicationService
|
|
98
|
+
step :notify, if: :should_notify?
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def notify; end
|
|
103
|
+
def should_notify?; true; end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# good - predicate from arg/output
|
|
107
|
+
class MyService < ApplicationService
|
|
108
|
+
arg :user, type: User, optional: true
|
|
109
|
+
|
|
110
|
+
step :greet, if: :user? # user? is auto-generated
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def greet; end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Configuration:** Use `ExcludedMethods` for inherited condition methods:
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
LightServices/ConditionMethodExists:
|
|
122
|
+
ExcludedMethods:
|
|
123
|
+
- admin?
|
|
124
|
+
- guest?
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### LightServices/DslOrder
|
|
128
|
+
|
|
129
|
+
Enforces consistent ordering of DSL declarations: `config` → `arg` → `step` → `output`
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# bad
|
|
133
|
+
class MyService < ApplicationService
|
|
134
|
+
step :process
|
|
135
|
+
arg :name, type: String
|
|
136
|
+
config raise_on_error: true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# good
|
|
140
|
+
class MyService < ApplicationService
|
|
141
|
+
config raise_on_error: true
|
|
142
|
+
|
|
143
|
+
arg :name, type: String
|
|
144
|
+
|
|
145
|
+
step :process
|
|
146
|
+
|
|
147
|
+
output :result, type: Hash
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### LightServices/MissingPrivateKeyword
|
|
152
|
+
|
|
153
|
+
Ensures step methods are defined as private.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# bad
|
|
157
|
+
class MyService < ApplicationService
|
|
158
|
+
step :process
|
|
159
|
+
|
|
160
|
+
def process # should be private
|
|
161
|
+
# implementation
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# good
|
|
166
|
+
class MyService < ApplicationService
|
|
167
|
+
step :process
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def process
|
|
172
|
+
# implementation
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### LightServices/NoDirectInstantiation
|
|
178
|
+
|
|
179
|
+
Prevents direct instantiation of service classes with `.new`.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# bad
|
|
183
|
+
UserService.new(name: "John")
|
|
184
|
+
|
|
185
|
+
# good
|
|
186
|
+
UserService.run(name: "John")
|
|
187
|
+
UserService.run!(name: "John")
|
|
188
|
+
UserService.call(name: "John")
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Configuration:** Customize the pattern for service class detection:
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
LightServices/NoDirectInstantiation:
|
|
195
|
+
ServicePattern: 'Service$' # default: matches classes ending with "Service"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### LightServices/DeprecatedMethods
|
|
199
|
+
|
|
200
|
+
Detects deprecated `done!` and `done?` method calls and suggests using `stop!` and `stopped?` instead. Includes autocorrection.
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# bad
|
|
204
|
+
class MyService < ApplicationService
|
|
205
|
+
step :process
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def process
|
|
210
|
+
done! if condition_met?
|
|
211
|
+
return if done?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# good
|
|
216
|
+
class MyService < ApplicationService
|
|
217
|
+
step :process
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
def process
|
|
222
|
+
stop! if condition_met?
|
|
223
|
+
return if stopped?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Configuration:** Customize the pattern for service class detection:
|
|
229
|
+
|
|
230
|
+
```yaml
|
|
231
|
+
LightServices/DeprecatedMethods:
|
|
232
|
+
ServicePattern: 'Service$' # default: matches classes ending with "Service"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Configuration
|
|
236
|
+
|
|
237
|
+
Full configuration example:
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
require:
|
|
241
|
+
- light/services/rubocop
|
|
242
|
+
|
|
243
|
+
LightServices/ArgumentTypeRequired:
|
|
244
|
+
Enabled: true
|
|
245
|
+
|
|
246
|
+
LightServices/OutputTypeRequired:
|
|
247
|
+
Enabled: true
|
|
248
|
+
|
|
249
|
+
LightServices/StepMethodExists:
|
|
250
|
+
Enabled: true
|
|
251
|
+
ExcludedSteps: []
|
|
252
|
+
|
|
253
|
+
LightServices/ConditionMethodExists:
|
|
254
|
+
Enabled: true
|
|
255
|
+
ExcludedMethods: []
|
|
256
|
+
|
|
257
|
+
LightServices/DslOrder:
|
|
258
|
+
Enabled: true
|
|
259
|
+
|
|
260
|
+
LightServices/MissingPrivateKeyword:
|
|
261
|
+
Enabled: true
|
|
262
|
+
|
|
263
|
+
LightServices/NoDirectInstantiation:
|
|
264
|
+
Enabled: true
|
|
265
|
+
ServicePattern: 'Service$'
|
|
266
|
+
|
|
267
|
+
LightServices/DeprecatedMethods:
|
|
268
|
+
Enabled: true
|
|
269
|
+
ServicePattern: 'Service$'
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
To disable a cop for specific files:
|
|
273
|
+
|
|
274
|
+
```yaml
|
|
275
|
+
LightServices/ArgumentTypeRequired:
|
|
276
|
+
Exclude:
|
|
277
|
+
- 'spec/**/*'
|
|
278
|
+
- 'test/**/*'
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## What's Next?
|
|
282
|
+
|
|
283
|
+
Learn more about testing your services:
|
|
284
|
+
|
|
285
|
+
[Next: Testing](testing.md)
|
data/docs/ruby-lsp.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Ruby LSP Integration
|
|
2
|
+
|
|
3
|
+
Light Services provides a Ruby LSP add-on that enhances your editor experience by informing the language server about methods generated by the `arg` and `output` DSL keywords.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
When you use the `arg` or `output` keywords, Light Services dynamically generates methods at runtime:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class MyService < ApplicationService
|
|
11
|
+
arg :user, type: User
|
|
12
|
+
output :result, type: Hash
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This generates the following methods:
|
|
17
|
+
- `user` - getter method (returns `User`)
|
|
18
|
+
- `user?` - predicate method (returns boolean)
|
|
19
|
+
- `user=` - setter method (private, accepts `User`)
|
|
20
|
+
- `result` - getter method (returns `Hash`)
|
|
21
|
+
- `result?` - predicate method (returns boolean)
|
|
22
|
+
- `result=` - setter method (private, accepts `Hash`)
|
|
23
|
+
|
|
24
|
+
The Ruby LSP add-on teaches the language server about these generated methods, enabling:
|
|
25
|
+
|
|
26
|
+
- **Go to Definition** - Navigate to the `arg`/`output` declaration
|
|
27
|
+
- **Completion** - Autocomplete generated method names
|
|
28
|
+
- **Hover** - See information about generated methods, including return types
|
|
29
|
+
- **Signature Help** - Get parameter hints for setter methods
|
|
30
|
+
- **Workspace Symbol** - Find generated methods in symbol search
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
The add-on is automatically discovered by Ruby LSP when Light Services is in your project's dependencies. No additional configuration is required.
|
|
35
|
+
|
|
36
|
+
### Requirements
|
|
37
|
+
|
|
38
|
+
- Ruby LSP `~> 0.26` or later
|
|
39
|
+
- Light Services gem installed in your project
|
|
40
|
+
|
|
41
|
+
### Verification
|
|
42
|
+
|
|
43
|
+
To verify the add-on is loaded, check the Ruby LSP output in your editor. You should see "Ruby LSP Light Services" listed among the active add-ons.
|
|
44
|
+
|
|
45
|
+
## How It Works
|
|
46
|
+
|
|
47
|
+
The add-on uses Ruby LSP's **indexing enhancement** system to register generated methods during code indexing. When the indexer encounters an `arg` or `output` call with a symbol argument, it automatically registers the three generated methods (getter, predicate, setter) in the index.
|
|
48
|
+
|
|
49
|
+
This is a static analysis approach - the add-on analyzes your source code without executing it. This means:
|
|
50
|
+
|
|
51
|
+
- Methods are recognized immediately as you type
|
|
52
|
+
- No running application is required
|
|
53
|
+
- Works with any editor that supports Ruby LSP
|
|
54
|
+
|
|
55
|
+
## Type Inference
|
|
56
|
+
|
|
57
|
+
The add-on extracts type information from the `type:` option and includes it as YARD-style documentation comments. This enables hover information to display return types for generated methods.
|
|
58
|
+
|
|
59
|
+
### Simple Ruby Types
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
arg :user, type: User # → User
|
|
63
|
+
arg :items, type: Array # → Array
|
|
64
|
+
arg :name, type: String # → String
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Namespaced Types
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
arg :payment, type: Stripe::Charge # → Stripe::Charge
|
|
71
|
+
arg :config, type: MyApp::Configuration # → MyApp::Configuration
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Dry-Types
|
|
75
|
+
|
|
76
|
+
Common dry-types are mapped to their underlying Ruby types:
|
|
77
|
+
|
|
78
|
+
| Dry-Type | Ruby Type |
|
|
79
|
+
|----------|-----------|
|
|
80
|
+
| `Types::String`, `Types::Strict::String`, `Types::Coercible::String` | `String` |
|
|
81
|
+
| `Types::Integer`, `Types::Strict::Integer`, `Types::Coercible::Integer` | `Integer` |
|
|
82
|
+
| `Types::Float`, `Types::Strict::Float`, `Types::Coercible::Float` | `Float` |
|
|
83
|
+
| `Types::Bool`, `Types::Strict::Bool` | `TrueClass \| FalseClass` |
|
|
84
|
+
| `Types::Array`, `Types::Strict::Array` | `Array` |
|
|
85
|
+
| `Types::Hash`, `Types::Strict::Hash` | `Hash` |
|
|
86
|
+
| `Types::Symbol`, `Types::Strict::Symbol` | `Symbol` |
|
|
87
|
+
| `Types::Date`, `Types::DateTime`, `Types::Time` | `Date`, `DateTime`, `Time` |
|
|
88
|
+
|
|
89
|
+
Constrained and parameterized types extract their base type:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
arg :email, type: Types::String.constrained(format: /@/) # → String
|
|
93
|
+
arg :tags, type: Types::Array.of(Types::String) # → Array
|
|
94
|
+
arg :status, type: Types::String.enum("active", "pending") # → String
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Custom Type Mappings
|
|
98
|
+
|
|
99
|
+
You can add custom type mappings through the Light Services configuration:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# config/initializers/light_services.rb
|
|
103
|
+
Light::Services.configure do |config|
|
|
104
|
+
config.ruby_lsp_type_mappings = {
|
|
105
|
+
"Types::UUID" => "String",
|
|
106
|
+
"Types::Money" => "BigDecimal",
|
|
107
|
+
"Types::JSON" => "Hash",
|
|
108
|
+
"CustomTypes::Email" => "String",
|
|
109
|
+
"MyApp::Types::PhoneNumber" => "String",
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Custom mappings take precedence over the default dry-types mappings, allowing you to:
|
|
115
|
+
|
|
116
|
+
- Add mappings for your own custom types
|
|
117
|
+
- Override default mappings if needed
|
|
118
|
+
- Support domain-specific type modules
|
|
119
|
+
|
|
120
|
+
## Limitations
|
|
121
|
+
|
|
122
|
+
- Only `arg` and `output` declarations with a symbol as the first argument are recognized
|
|
123
|
+
- The add-on cannot detect dynamically computed argument names (e.g., `arg some_variable`)
|
|
124
|
+
- Inherited arguments/outputs from parent classes are not automatically discovered
|
|
125
|
+
- Parameterized dry-types like `Types::Array.of(Types::String)` resolve to the container type (`Array`), not the full generic type
|
|
126
|
+
- Custom dry-type definitions outside the standard `Types::` namespace are not mapped
|
|
127
|
+
|
|
128
|
+
## What's Next?
|
|
129
|
+
|
|
130
|
+
Learn more about other integrations:
|
|
131
|
+
|
|
132
|
+
- [RuboCop Integration](rubocop.md) - Static analysis cops for services
|
|
133
|
+
- [Testing](testing.md) - Testing your services with RSpec matchers
|
|
@@ -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 Light Services:
|
|
221
|
+
|
|
222
|
+
[Next: Pundit Authorization](pundit-authorization.md)
|