light-services 3.1.1 → 3.2.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/.cursor/rules/services/RULE.md +265 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/CHANGELOG.md +23 -1
- data/Gemfile.lock +2 -2
- data/docs/arguments.md +3 -3
- data/docs/configuration.md +22 -7
- data/docs/errors.md +25 -4
- data/docs/generators.md +1 -1
- data/docs/outputs.md +3 -3
- data/docs/pundit-authorization.md +1 -1
- data/docs/rubocop.md +46 -0
- data/docs/steps.md +52 -0
- data/docs/testing.md +8 -8
- data/lib/generators/light_services/service/templates/service_spec.rb.tt +1 -1
- data/lib/light/services/base.rb +20 -0
- data/lib/light/services/concerns/execution.rb +3 -0
- data/lib/light/services/config.rb +20 -5
- data/lib/light/services/dsl/validation.rb +16 -5
- data/lib/light/services/exceptions.rb +4 -0
- data/lib/light/services/rubocop/cop/light_services/prefer_fail_method.rb +113 -0
- data/lib/light/services/rubocop.rb +1 -0
- data/lib/light/services/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eaf377a62c2b05d82860527f64df11bb2239745d2e3b6ab1bc060cddc153effc
|
|
4
|
+
data.tar.gz: 86eb6a64e0744e6fe2f80b1b4fc06e9d38a740911f88c2b076da9e9a2aab082b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b11918fed0617837cd922a9c7c2181a855844d359bce30aa7f523a828e77e3605522cff64440cf200f90732cb17bbee901305a01920bde54a3cb4777b748a760
|
|
7
|
+
data.tar.gz: aa78896f8cf22b7e2cf17853700bbe347d0dd364409ee4033064d7c4c5fa57d9377620e5ab6b29473cf15c2894e29b189aa8634e7d71ffda9ad2ca6fa6fce792
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Rules for managing Light Services - Ruby service objects with arguments, steps, and outputs"
|
|
3
|
+
globs: "**/services/**/*.rb"
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Light Services Creation Rules
|
|
8
|
+
|
|
9
|
+
When creating or modifying services that inherit from `Light::Services::Base` or `ApplicationService`, follow these patterns.
|
|
10
|
+
|
|
11
|
+
## Service Structure
|
|
12
|
+
|
|
13
|
+
Always structure services in this order:
|
|
14
|
+
1. Configuration (`config`) if needed
|
|
15
|
+
2. Arguments (`arg`)
|
|
16
|
+
3. Steps (`step`)
|
|
17
|
+
4. Outputs (`output`)
|
|
18
|
+
5. Private methods implementing steps
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class ModelName::ActionName < ApplicationService
|
|
22
|
+
# Arguments
|
|
23
|
+
arg :required_param, type: String
|
|
24
|
+
arg :optional_param, type: Integer, optional: true, default: 0
|
|
25
|
+
|
|
26
|
+
# Steps
|
|
27
|
+
step :validate
|
|
28
|
+
step :perform
|
|
29
|
+
step :cleanup, always: true
|
|
30
|
+
|
|
31
|
+
# Outputs
|
|
32
|
+
output :result, type: Hash
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate
|
|
37
|
+
errors.add(:required_param, "can't be blank") if required_param.blank?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def perform
|
|
41
|
+
self.result = { success: true }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cleanup
|
|
45
|
+
# Always runs
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Naming Conventions
|
|
51
|
+
|
|
52
|
+
- **Class name**: `ModelName::ActionVerb` (e.g., `User::Create`, `Order::Process`)
|
|
53
|
+
- **File path**: `app/services/model_name/action_verb.rb`
|
|
54
|
+
- **Step methods**: Use descriptive verbs (`validate`, `authorize`, `build_record`, `save`)
|
|
55
|
+
|
|
56
|
+
## Arguments
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Required with type
|
|
60
|
+
arg :user_id, type: Integer
|
|
61
|
+
|
|
62
|
+
# Optional with default
|
|
63
|
+
arg :notify, type: [TrueClass, FalseClass], default: true
|
|
64
|
+
|
|
65
|
+
# Context argument (auto-passed to child services)
|
|
66
|
+
arg :current_user, type: User, optional: true, context: true
|
|
67
|
+
|
|
68
|
+
# Multiple allowed types
|
|
69
|
+
arg :id, type: [String, Integer]
|
|
70
|
+
|
|
71
|
+
# Proc default (evaluated at runtime)
|
|
72
|
+
arg :created_at, type: Time, default: -> { Time.current }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Steps
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Basic step
|
|
79
|
+
step :process
|
|
80
|
+
|
|
81
|
+
# Conditional execution
|
|
82
|
+
step :send_email, if: :should_notify?
|
|
83
|
+
step :skip_audit, unless: :production?
|
|
84
|
+
|
|
85
|
+
# Always run (even after errors, unless stop! or stop_immediately! are called)
|
|
86
|
+
step :cleanup, always: true
|
|
87
|
+
|
|
88
|
+
# Insertion points (for inheritance)
|
|
89
|
+
step :log_action, before: :save
|
|
90
|
+
step :broadcast, after: :save
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Outputs
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# Required output
|
|
97
|
+
output :user, type: User
|
|
98
|
+
|
|
99
|
+
# Optional output
|
|
100
|
+
output :metadata, type: Hash, optional: true
|
|
101
|
+
|
|
102
|
+
# With default
|
|
103
|
+
output :status, type: String, default: "completed"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Error Handling
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# Add error (stops execution of next steps by default)
|
|
110
|
+
errors.add(:email, "is invalid")
|
|
111
|
+
|
|
112
|
+
# Add to base
|
|
113
|
+
errors.add(:base, "Something went wrong")
|
|
114
|
+
fail!("Something went wrong") # Shorthand
|
|
115
|
+
|
|
116
|
+
# Copy from ActiveRecord model
|
|
117
|
+
errors.copy_from(user)
|
|
118
|
+
|
|
119
|
+
# Stop next steps without error (commits transaction)
|
|
120
|
+
stop!
|
|
121
|
+
|
|
122
|
+
# Stop with rollback
|
|
123
|
+
fail_immediately!("Critical error")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Service Chaining
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# Run in same context (shared arguments, errors propagate, rollback propagate)
|
|
130
|
+
ChildService.with(self).run(param: value)
|
|
131
|
+
|
|
132
|
+
# Independent service (separate transaction)
|
|
133
|
+
OtherService.run(param: value)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
config use_transactions: true # Wrap in DB transaction (default)
|
|
140
|
+
config raise_on_error: false # Raise exception on error
|
|
141
|
+
config break_on_error: true # Stop on error (default)
|
|
142
|
+
config rollback_on_error: true # Rollback on error (default)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Testing Pattern
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
RSpec.describe ModelName::ActionName do
|
|
149
|
+
describe ".run" do
|
|
150
|
+
subject(:service) { described_class.run(params) }
|
|
151
|
+
let(:params) { { required_param: "value" } }
|
|
152
|
+
|
|
153
|
+
context "when successful" do
|
|
154
|
+
it { is_expected.to be_successful }
|
|
155
|
+
it { expect(service.result).to eq(expected) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
context "when validation fails" do
|
|
159
|
+
let(:params) { { required_param: "" } }
|
|
160
|
+
|
|
161
|
+
it { is_expected.to be_failed }
|
|
162
|
+
it { is_expected.to have_error_on(:required_param) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Common Patterns
|
|
169
|
+
|
|
170
|
+
### CRUD Create
|
|
171
|
+
```ruby
|
|
172
|
+
class User::Create < CreateRecordService
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def entity_class
|
|
176
|
+
User
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def filtered_params
|
|
180
|
+
params.slice(:name, :email, :role)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### With Authorization
|
|
186
|
+
```ruby
|
|
187
|
+
step :authorize
|
|
188
|
+
step :perform
|
|
189
|
+
|
|
190
|
+
def authorize
|
|
191
|
+
fail!("Not authorized") unless current_user&.admin?
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### With Callbacks
|
|
196
|
+
```ruby
|
|
197
|
+
before_service_run :log_start
|
|
198
|
+
after_service_run :log_finish
|
|
199
|
+
on_service_failure :notify_error
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Using dry-types
|
|
203
|
+
|
|
204
|
+
Light Services supports [dry-types](https://dry-rb.org/gems/dry-types) for advanced type validation and coercion.
|
|
205
|
+
|
|
206
|
+
### Arguments with dry-types
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class User::Create < ApplicationService
|
|
210
|
+
# Strict types - must match exactly
|
|
211
|
+
arg :name, type: Types::Strict::String
|
|
212
|
+
|
|
213
|
+
# Coercible types - automatically convert values
|
|
214
|
+
arg :age, type: Types::Coercible::Integer
|
|
215
|
+
|
|
216
|
+
# Constrained types - add validation rules
|
|
217
|
+
arg :email, type: Types::String.constrained(format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i)
|
|
218
|
+
|
|
219
|
+
# Enum types - restrict to specific values
|
|
220
|
+
arg :status, type: Types::String.enum("active", "inactive", "pending"), default: "pending"
|
|
221
|
+
|
|
222
|
+
# Array types with element validation
|
|
223
|
+
arg :tags, type: Types::Array.of(Types::String), optional: true
|
|
224
|
+
|
|
225
|
+
# Hash schemas with key transformation
|
|
226
|
+
arg :metadata, type: Types::Hash.schema(key: Types::String).with_key_transform(&:to_sym), optional: true
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Outputs with dry-types
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class AI::Chat < ApplicationService
|
|
234
|
+
# Strict type validation
|
|
235
|
+
output :messages, type: Types::Strict::Array.of(Types::Hash)
|
|
236
|
+
|
|
237
|
+
# Coercible types
|
|
238
|
+
output :total_tokens, type: Types::Coercible::Integer
|
|
239
|
+
|
|
240
|
+
# Constrained types (must be >= 0)
|
|
241
|
+
output :cost, type: Types::Float.constrained(gteq: 0)
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Coercion Behavior
|
|
246
|
+
|
|
247
|
+
With coercible types, values are automatically converted:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# String "25" is automatically converted to integer 25
|
|
251
|
+
service = User::Create.run(name: "John", age: "25")
|
|
252
|
+
service.age # => 25 (Integer, not String)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Common dry-types Patterns
|
|
256
|
+
|
|
257
|
+
| Type | Description |
|
|
258
|
+
|------|-------------|
|
|
259
|
+
| `Types::Strict::String` | Must be a String, no coercion |
|
|
260
|
+
| `Types::Coercible::Integer` | Coerces to Integer (e.g., "25" → 25) |
|
|
261
|
+
| `Types::String.optional` | String or nil |
|
|
262
|
+
| `Types::String.enum("a", "b")` | Must be one of the listed values |
|
|
263
|
+
| `Types::Array.of(Types::String)` | Array where all elements are Strings |
|
|
264
|
+
| `Types::Hash.schema(key: Type)` | Hash with typed schema |
|
|
265
|
+
| `Types::Float.constrained(gteq: 0)` | Float >= 0 |
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Rules for writing RSpec tests for Light Services - testing arguments, steps, outputs, and behavior"
|
|
3
|
+
globs: "**/spec/services/*_spec.rb"
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Light Services RSpec Testing Rules
|
|
8
|
+
|
|
9
|
+
When writing RSpec tests for services that inherit from `Light::Services::Base` or `ApplicationService`, follow these patterns.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Add to `spec/spec_helper.rb` or `spec/rails_helper.rb`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "light/services/rspec"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Test Structure
|
|
20
|
+
|
|
21
|
+
Always structure test files in this order:
|
|
22
|
+
1. Describe block with service class
|
|
23
|
+
2. DSL definition tests (arguments, outputs, steps)
|
|
24
|
+
3. Behavior tests (`.run` scenarios)
|
|
25
|
+
4. Edge cases and error handling
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# spec/services/model_name/action_name_spec.rb
|
|
29
|
+
RSpec.describe ModelName::ActionName do
|
|
30
|
+
# DSL Definition Tests
|
|
31
|
+
describe "arguments" do
|
|
32
|
+
it { expect(described_class).to define_argument(:required_param).with_type(String) }
|
|
33
|
+
it { expect(described_class).to define_argument(:optional_param).with_type(Integer).optional.with_default(0) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "outputs" do
|
|
37
|
+
it { expect(described_class).to define_output(:result).with_type(Hash) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe "steps" do
|
|
41
|
+
it { expect(described_class).to define_steps_in_order(:validate, :perform, :cleanup) }
|
|
42
|
+
it { expect(described_class).to define_step(:cleanup).with_always(true) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Behavior Tests
|
|
46
|
+
describe ".run" do
|
|
47
|
+
subject(:service) { described_class.run(params) }
|
|
48
|
+
let(:params) { { required_param: "value" } }
|
|
49
|
+
|
|
50
|
+
context "when successful" do
|
|
51
|
+
it { is_expected.to be_successful }
|
|
52
|
+
it { expect(service.result).to eq(expected_result) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
context "when validation fails" do
|
|
56
|
+
let(:params) { { required_param: "" } }
|
|
57
|
+
it { is_expected.to be_failed }
|
|
58
|
+
it { is_expected.to have_error_on(:required_param) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Naming Conventions
|
|
65
|
+
|
|
66
|
+
- **File path**: `spec/services/model_name/action_name_spec.rb`
|
|
67
|
+
- **Describe block**: Use the service class name directly
|
|
68
|
+
- **Context blocks**: Start with "when" or "with" to describe conditions
|
|
69
|
+
- **It blocks**: Be specific about what is being tested
|
|
70
|
+
|
|
71
|
+
## DSL Matchers
|
|
72
|
+
|
|
73
|
+
### Arguments
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# Basic argument
|
|
77
|
+
it { expect(described_class).to define_argument(:id) }
|
|
78
|
+
|
|
79
|
+
# With type (single or multiple)
|
|
80
|
+
it { expect(described_class).to define_argument(:id).with_type([String, Integer]) }
|
|
81
|
+
|
|
82
|
+
# Optional with default
|
|
83
|
+
it { expect(described_class).to define_argument(:status).optional.with_default("pending") }
|
|
84
|
+
|
|
85
|
+
# Context argument
|
|
86
|
+
it { expect(described_class).to define_argument(:current_user).with_context }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Outputs
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# Basic output
|
|
93
|
+
it { expect(described_class).to define_output(:user) }
|
|
94
|
+
|
|
95
|
+
# With type
|
|
96
|
+
it { expect(described_class).to define_output(:user).with_type(User) }
|
|
97
|
+
|
|
98
|
+
# Optional with default
|
|
99
|
+
it { expect(described_class).to define_output(:count).optional.with_default(0) }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Steps
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# Basic step
|
|
106
|
+
it { expect(described_class).to define_step(:validate) }
|
|
107
|
+
|
|
108
|
+
# With always flag
|
|
109
|
+
it { expect(described_class).to define_step(:cleanup).with_always(true) }
|
|
110
|
+
|
|
111
|
+
# Conditional steps
|
|
112
|
+
it { expect(described_class).to define_step(:notify).with_if(:should_notify?) }
|
|
113
|
+
it { expect(described_class).to define_step(:skip_audit).with_unless(:production?) }
|
|
114
|
+
|
|
115
|
+
# Multiple steps in order
|
|
116
|
+
it { expect(described_class).to define_steps_in_order(:validate, :process, :save) }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Behavior Testing
|
|
120
|
+
|
|
121
|
+
### Success Cases
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
describe ".run" do
|
|
125
|
+
subject(:service) { described_class.run(params) }
|
|
126
|
+
let(:params) { { name: "John", email: "john@example.com" } }
|
|
127
|
+
|
|
128
|
+
it { is_expected.to be_successful }
|
|
129
|
+
it { expect(service.user).to be_persisted }
|
|
130
|
+
it { expect(service.user.name).to eq("John") }
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Failure Cases
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
context "when validation fails" do
|
|
138
|
+
let(:params) { { name: "", email: "" } }
|
|
139
|
+
|
|
140
|
+
it { is_expected.to be_failed }
|
|
141
|
+
it { is_expected.to have_error_on(:name) }
|
|
142
|
+
it { is_expected.to have_error_on(:email).with_message("can't be blank") }
|
|
143
|
+
it { is_expected.to have_errors_on(:name, :email) }
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Warnings
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
it { expect(service.warnings?).to be true }
|
|
151
|
+
it { is_expected.to have_warning_on(:format).with_message("format is deprecated") }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### run! vs run
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# .run returns failed service
|
|
158
|
+
it { expect(described_class.run(amount: -100)).to be_failed }
|
|
159
|
+
|
|
160
|
+
# .run! raises exception
|
|
161
|
+
it { expect { described_class.run!(amount: -100) }.to raise_error(Light::Services::Error, /must be positive/) }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Config Overrides
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# With raise_on_error
|
|
168
|
+
expect { described_class.run({ invalid: true }, { raise_on_error: true }) }
|
|
169
|
+
.to raise_error(Light::Services::Error)
|
|
170
|
+
|
|
171
|
+
# With use_transactions: false
|
|
172
|
+
service = described_class.with(use_transactions: false).run(params)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Database Testing
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
# Record creation
|
|
179
|
+
expect { described_class.run(params) }.to change(User, :count).by(1)
|
|
180
|
+
expect(service.user).to be_persisted
|
|
181
|
+
|
|
182
|
+
# Transaction rollback on failure
|
|
183
|
+
allow_any_instance_of(ChildService).to receive(:perform) do |svc|
|
|
184
|
+
svc.errors.add(:base, "Simulated failure")
|
|
185
|
+
end
|
|
186
|
+
expect { described_class.run(params) }.not_to change(Order, :count)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Service Chaining
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Verify context sharing
|
|
193
|
+
expect(ChildService).to receive(:with).and_call_original
|
|
194
|
+
described_class.run(current_user: current_user, data: data)
|
|
195
|
+
|
|
196
|
+
# Error propagation from child
|
|
197
|
+
allow_any_instance_of(ChildService).to receive(:validate) { |svc| svc.fail!("Child error") }
|
|
198
|
+
expect(described_class.run(current_user: current_user, data: data))
|
|
199
|
+
.to have_error_on(:base).with_message("Child error")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Conditional Steps
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# When condition is true
|
|
206
|
+
expect { described_class.run(send_notification: true, email: "x@example.com") }
|
|
207
|
+
.to have_enqueued_mail(UserMailer, :notification)
|
|
208
|
+
|
|
209
|
+
# When condition is false
|
|
210
|
+
expect { described_class.run(send_notification: false, email: "x@example.com") }
|
|
211
|
+
.not_to have_enqueued_mail(UserMailer, :notification)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Early Exit (stop!)
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
existing_user = create(:user, email: "exists@example.com")
|
|
218
|
+
service = described_class.run(email: existing_user.email)
|
|
219
|
+
|
|
220
|
+
expect { service }.not_to change(User, :count)
|
|
221
|
+
expect(service.user).to eq(existing_user)
|
|
222
|
+
expect(service).to be_successful
|
|
223
|
+
expect(service.stopped?).to be true
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Argument Validation
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
# Required argument nil
|
|
230
|
+
expect { described_class.run(name: nil) }.to raise_error(Light::Services::ArgTypeError)
|
|
231
|
+
|
|
232
|
+
# Wrong type
|
|
233
|
+
expect { described_class.run(name: 123) }.to raise_error(Light::Services::ArgTypeError, /must be a String/)
|
|
234
|
+
|
|
235
|
+
# Optional accepts nil
|
|
236
|
+
expect(described_class.run(name: "John", nickname: nil)).to be_successful
|
|
237
|
+
|
|
238
|
+
# Default values
|
|
239
|
+
expect(described_class.run(name: "John").status).to eq("pending")
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## External Services
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
let(:stripe_client) { instance_double(Stripe::PaymentIntent, id: "pi_123") }
|
|
246
|
+
|
|
247
|
+
before { allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_client) }
|
|
248
|
+
|
|
249
|
+
it "processes payment" do
|
|
250
|
+
service = described_class.run(amount: 1000, card_token: "tok_visa")
|
|
251
|
+
expect(service).to be_successful
|
|
252
|
+
expect(service.payment_intent_id).to eq("pi_123")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
context "when external fails" do
|
|
256
|
+
before { allow(Stripe::PaymentIntent).to receive(:create).and_raise(Stripe::CardError.new("Card declined", nil, nil)) }
|
|
257
|
+
|
|
258
|
+
it { expect(service).to be_failed }
|
|
259
|
+
it { expect(service).to have_error_on(:payment).with_message("Card declined") }
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Optional Tracking
|
|
264
|
+
|
|
265
|
+
### Step Execution
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
# app/services/application_service.rb
|
|
269
|
+
class ApplicationService < Light::Services::Base
|
|
270
|
+
output :executed_steps, type: Array, default: -> { [] }
|
|
271
|
+
after_step_run { |service, step| service.executed_steps << step }
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Callback Tracking
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
class ApplicationService < Light::Services::Base
|
|
279
|
+
output :callback_log, type: Array, default: -> { [] }
|
|
280
|
+
before_service_run { |s| s.callback_log << :before_service_run }
|
|
281
|
+
after_service_run { |s| s.callback_log << :after_service_run }
|
|
282
|
+
on_service_success { |s| s.callback_log << :on_service_success }
|
|
283
|
+
on_service_failure { |s| s.callback_log << :on_service_failure }
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Shared Examples
|
|
288
|
+
|
|
289
|
+
### Create Service
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
RSpec.shared_examples "a create service" do |model_class|
|
|
293
|
+
let(:valid_attributes) { attributes_for(model_class.name.underscore.to_sym) }
|
|
294
|
+
let(:current_user) { create(:user) }
|
|
295
|
+
|
|
296
|
+
it { expect { described_class.run(current_user: current_user, attributes: valid_attributes) }.to change(model_class, :count).by(1) }
|
|
297
|
+
it { expect(described_class.run(current_user: current_user, attributes: valid_attributes).record).to be_persisted }
|
|
298
|
+
it { expect(described_class.run(current_user: current_user, attributes: valid_attributes)).to be_successful }
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Authorized Service
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
RSpec.shared_examples "an authorized service" do
|
|
306
|
+
context "without current_user" do
|
|
307
|
+
let(:current_user) { nil }
|
|
308
|
+
it { is_expected.to be_failed }
|
|
309
|
+
it { is_expected.to have_error_on(:authorization) }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
context "with unauthorized user" do
|
|
313
|
+
let(:current_user) { create(:user, role: :guest) }
|
|
314
|
+
it { is_expected.to be_failed }
|
|
315
|
+
it { is_expected.to have_error_on(:authorization) }
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Test Helpers
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
module ServiceHelpers
|
|
324
|
+
def expect_service_success(service)
|
|
325
|
+
expect(service).to be_successful, -> { "Expected success but got errors: #{service.errors.to_h}" }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def expect_service_failure(service, key = nil)
|
|
329
|
+
expect(service).to be_failed
|
|
330
|
+
expect(service.errors[key]).to be_present if key
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
RSpec.configure { |config| config.include ServiceHelpers, type: :service }
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Common Matchers Reference
|
|
338
|
+
|
|
339
|
+
| Matcher | Description |
|
|
340
|
+
|---------|-------------|
|
|
341
|
+
| `be_successful` | Service completed without errors |
|
|
342
|
+
| `be_failed` | Service has errors |
|
|
343
|
+
| `have_error_on(:key)` | Has error on specific key |
|
|
344
|
+
| `have_error_on(:key).with_message(msg)` | Error with specific message |
|
|
345
|
+
| `have_errors_on(:key1, :key2)` | Has errors on multiple keys |
|
|
346
|
+
| `have_warning_on(:key)` | Has warning on specific key |
|
|
347
|
+
| `define_argument(:name)` | Service defines argument |
|
|
348
|
+
| `define_output(:name)` | Service defines output |
|
|
349
|
+
| `define_step(:name)` | Service defines step |
|
|
350
|
+
| `define_steps(:a, :b, :c)` | Service defines all steps (any order) |
|
|
351
|
+
| `define_steps_in_order(:a, :b, :c)` | Service defines steps in order |
|
|
352
|
+
| `execute_step(:name)` | Step was executed (tracking required) |
|
|
353
|
+
| `skip_step(:name)` | Step was skipped (tracking required) |
|
|
354
|
+
| `trigger_callback(:name)` | Callback was triggered (tracking required) |
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.2.0 (2025-12-14)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add Cursor rules
|
|
8
|
+
- Add `successful?` as an alias for `success?`
|
|
9
|
+
- Add RuboCop cop `PreferFailMethod` to detect `errors.add(:base, "message")` and suggest using `fail!("message")` instead
|
|
10
|
+
|
|
11
|
+
### Breaking changes
|
|
12
|
+
|
|
13
|
+
- Service runs steps with `always: true` after `fail_immediately!` was called
|
|
14
|
+
|
|
15
|
+
## 3.1.2 (2025-12-13)
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Add `fail!` and `fail_immediately!` helpers
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Split `config.require_type` into `config.require_arg_type` and `config.require_output_type`
|
|
24
|
+
|
|
3
25
|
## 3.1.1 (2025-12-13)
|
|
4
26
|
|
|
5
27
|
### Added
|
|
@@ -10,7 +32,7 @@
|
|
|
10
32
|
|
|
11
33
|
### Breaking changes
|
|
12
34
|
|
|
13
|
-
- Enforce arguments and output types by default.
|
|
35
|
+
- Enforce arguments and output types by default. Use `config.require_arg_type = false` and `config.require_output_type = false` to disable this behavior. The convenience setter `config.require_type = false` sets both options at once for backward compatibility.
|
|
14
36
|
|
|
15
37
|
### Added
|
|
16
38
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
light-services (3.
|
|
4
|
+
light-services (3.2.0)
|
|
5
5
|
|
|
6
6
|
GEM
|
|
7
7
|
remote: https://rubygems.org/
|
|
@@ -33,7 +33,7 @@ GEM
|
|
|
33
33
|
bigdecimal (3.3.1)
|
|
34
34
|
codecov (0.6.0)
|
|
35
35
|
simplecov (>= 0.15, < 0.22)
|
|
36
|
-
concurrent-ruby (1.3.
|
|
36
|
+
concurrent-ruby (1.3.6)
|
|
37
37
|
connection_pool (2.5.5)
|
|
38
38
|
database_cleaner-active_record (2.2.2)
|
|
39
39
|
activerecord (>= 5.a)
|
data/docs/arguments.md
CHANGED
|
@@ -66,13 +66,13 @@ class MyService < ApplicationService
|
|
|
66
66
|
end
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
To disable type enforcement for a specific service:
|
|
69
|
+
To disable type enforcement for arguments in a specific service:
|
|
70
70
|
|
|
71
71
|
```ruby
|
|
72
72
|
class LegacyService < ApplicationService
|
|
73
|
-
config
|
|
73
|
+
config require_arg_type: false
|
|
74
74
|
|
|
75
|
-
arg :name # Allowed when
|
|
75
|
+
arg :name # Allowed when require_arg_type is disabled
|
|
76
76
|
end
|
|
77
77
|
```
|
|
78
78
|
|