light-services 3.1.2 → 3.2.1
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 +20 -2
- data/Gemfile.lock +2 -2
- data/docs/generators.md +1 -1
- data/docs/pundit-authorization.md +1 -1
- data/docs/rubocop.md +46 -0
- data/docs/steps.md +2 -2
- 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 +2 -1
- data/lib/light/services/concerns/execution.rb +0 -1
- data/lib/light/services/rubocop/cop/light_services/prefer_fail_method.rb +113 -0
- data/lib/light/services/rubocop/cop/light_services/reserved_name.rb +56 -0
- data/lib/light/services/rubocop.rb +2 -0
- data/lib/light/services/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2d31f74eaaa7add84302e90f4d2748c5c099f34ea44e1be0d8eb6faa286ec37
|
|
4
|
+
data.tar.gz: 7bb83450e6be42ab27938df758644820476138684712f7d015694b4d1f675d92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2cf4be4b70f330c77d53b104b4f4c36686a6a1aaeba1cee0478d25941c06c5ff7e31d9bc0d4998710dd8a4b811e06f39794d54b9102663480d5d32adfbca45a
|
|
7
|
+
data.tar.gz: 56134a214344e9a3c7448b4cca8becdb05d03df864962faa5c1f9412cf65ce774a5245ac1a5eda2127e86bdc5b41d01a5ab2893109b401d8259f3dc704420220
|
|
@@ -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,6 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 3.1
|
|
3
|
+
## 3.2.1 (2025-12-15)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add RuboCop cop `ReservedName`
|
|
8
|
+
|
|
9
|
+
## 3.2.0 (2025-12-15)
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Add Cursor rules
|
|
14
|
+
- Add `successful?` as an alias for `success?`
|
|
15
|
+
- Add RuboCop cop `PreferFailMethod` to detect `errors.add(:base, "message")` and suggest using `fail!("message")` instead
|
|
16
|
+
|
|
17
|
+
### Breaking changes
|
|
18
|
+
|
|
19
|
+
- Service runs steps with `always: true` after `fail_immediately!` was called
|
|
20
|
+
|
|
21
|
+
## 3.1.2 (2025-12-14)
|
|
4
22
|
|
|
5
23
|
### Added
|
|
6
24
|
|
|
@@ -10,7 +28,7 @@
|
|
|
10
28
|
|
|
11
29
|
- Split `config.require_type` into `config.require_arg_type` and `config.require_output_type`
|
|
12
30
|
|
|
13
|
-
## 3.1.1 (2025-12-
|
|
31
|
+
## 3.1.1 (2025-12-14)
|
|
14
32
|
|
|
15
33
|
### Added
|
|
16
34
|
|
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.1)
|
|
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/generators.md
CHANGED
data/docs/rubocop.md
CHANGED
|
@@ -232,6 +232,47 @@ LightServices/DeprecatedMethods:
|
|
|
232
232
|
ServicePattern: 'Service$' # default: matches classes ending with "Service"
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
+
### LightServices/PreferFailMethod
|
|
236
|
+
|
|
237
|
+
Detects `errors.add(:base, "message")` calls and suggests using the `fail!("message")` helper instead. Includes autocorrection.
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# bad
|
|
241
|
+
class MyService < ApplicationService
|
|
242
|
+
step :process
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def process
|
|
247
|
+
errors.add(:base, "user is required")
|
|
248
|
+
errors.add(:base, "invalid input", rollback: false)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# good
|
|
253
|
+
class MyService < ApplicationService
|
|
254
|
+
step :process
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
def process
|
|
259
|
+
fail!("user is required")
|
|
260
|
+
fail!("invalid input", rollback: false)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The cop only detects `errors.add(:base, ...)` calls. It does not flag `errors.add(:field_name, ...)` calls for specific fields, as those should not use `fail!`.
|
|
266
|
+
|
|
267
|
+
**Configuration:** Customize the base service classes to check:
|
|
268
|
+
|
|
269
|
+
```yaml
|
|
270
|
+
LightServices/PreferFailMethod:
|
|
271
|
+
BaseServiceClasses:
|
|
272
|
+
- ApplicationService
|
|
273
|
+
- BaseCreator
|
|
274
|
+
```
|
|
275
|
+
|
|
235
276
|
## Configuration
|
|
236
277
|
|
|
237
278
|
Full configuration example:
|
|
@@ -267,6 +308,11 @@ LightServices/NoDirectInstantiation:
|
|
|
267
308
|
LightServices/DeprecatedMethods:
|
|
268
309
|
Enabled: true
|
|
269
310
|
ServicePattern: 'Service$'
|
|
311
|
+
|
|
312
|
+
LightServices/PreferFailMethod:
|
|
313
|
+
Enabled: true
|
|
314
|
+
BaseServiceClasses:
|
|
315
|
+
- ApplicationService
|
|
270
316
|
```
|
|
271
317
|
|
|
272
318
|
To disable a cop for specific files:
|
data/docs/steps.md
CHANGED
|
@@ -325,8 +325,8 @@ class Payment::Process < ApplicationService
|
|
|
325
325
|
end
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
{% hint style="
|
|
329
|
-
`fail_immediately!` raises an internal exception to halt execution. Steps marked with `always: true` will
|
|
328
|
+
{% hint style="info" %}
|
|
329
|
+
`fail_immediately!` raises an internal exception to halt execution. Steps marked with `always: true` will still run when `fail_immediately!` is called, allowing for cleanup operations.
|
|
330
330
|
{% endhint %}
|
|
331
331
|
|
|
332
332
|
{% hint style="danger" %}
|
data/docs/testing.md
CHANGED
|
@@ -30,7 +30,7 @@ RSpec.describe GreetService do
|
|
|
30
30
|
it "returns a greeting message" do
|
|
31
31
|
service = described_class.run(name: "John")
|
|
32
32
|
|
|
33
|
-
expect(service).to
|
|
33
|
+
expect(service).to be_successful
|
|
34
34
|
expect(service.message).to eq("Hello, John!")
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -48,7 +48,7 @@ RSpec.describe User::Create do
|
|
|
48
48
|
it "creates a user" do
|
|
49
49
|
service = described_class.run(attributes: attributes)
|
|
50
50
|
|
|
51
|
-
expect(service).to
|
|
51
|
+
expect(service).to be_successful
|
|
52
52
|
expect(service.user).to be_persisted
|
|
53
53
|
expect(service.user.email).to eq("test@example.com")
|
|
54
54
|
end
|
|
@@ -86,7 +86,7 @@ RSpec.describe Comment::Create do
|
|
|
86
86
|
text: "Great post!"
|
|
87
87
|
)
|
|
88
88
|
|
|
89
|
-
expect(service).to
|
|
89
|
+
expect(service).to be_successful
|
|
90
90
|
expect(service.comment.user).to eq(current_user)
|
|
91
91
|
end
|
|
92
92
|
end
|
|
@@ -122,7 +122,7 @@ RSpec.describe Order::Create do
|
|
|
122
122
|
items: [{ product_id: product.id, quantity: 2 }]
|
|
123
123
|
)
|
|
124
124
|
|
|
125
|
-
expect(service).to
|
|
125
|
+
expect(service).to be_successful
|
|
126
126
|
expect(service.order.order_items.count).to eq(1)
|
|
127
127
|
expect(service.order.total).to eq(200)
|
|
128
128
|
end
|
|
@@ -252,7 +252,7 @@ RSpec.describe DataImport do
|
|
|
252
252
|
it "completes with warnings for skipped records" do
|
|
253
253
|
service = described_class.run(data: mixed_valid_invalid_data)
|
|
254
254
|
|
|
255
|
-
expect(service).to
|
|
255
|
+
expect(service).to be_successful # Warnings don't fail the service
|
|
256
256
|
expect(service.warnings?).to be true
|
|
257
257
|
expect(service.warnings[:skipped]).to include("Row 3: invalid format")
|
|
258
258
|
end
|
|
@@ -273,7 +273,7 @@ RSpec.describe Payment::Charge do
|
|
|
273
273
|
it "processes payment successfully" do
|
|
274
274
|
service = described_class.run(amount: 1000, card_token: "tok_visa")
|
|
275
275
|
|
|
276
|
-
expect(service).to
|
|
276
|
+
expect(service).to be_successful
|
|
277
277
|
expect(service.payment_intent_id).to eq("pi_123")
|
|
278
278
|
end
|
|
279
279
|
|
|
@@ -312,7 +312,7 @@ RSpec.describe MyService do
|
|
|
312
312
|
|
|
313
313
|
it "accepts optional arguments as nil" do
|
|
314
314
|
service = described_class.run(name: "John", nickname: nil)
|
|
315
|
-
expect(service).to
|
|
315
|
+
expect(service).to be_successful
|
|
316
316
|
end
|
|
317
317
|
end
|
|
318
318
|
end
|
|
@@ -353,7 +353,7 @@ Create a helper module for common service testing patterns:
|
|
|
353
353
|
# spec/support/service_helpers.rb
|
|
354
354
|
module ServiceHelpers
|
|
355
355
|
def expect_service_success(service)
|
|
356
|
-
expect(service).to
|
|
356
|
+
expect(service).to be_successful, -> { "Expected success but got errors: #{service.errors.to_h}" }
|
|
357
357
|
end
|
|
358
358
|
|
|
359
359
|
def expect_service_failure(service, key = nil)
|
data/lib/light/services/base.rb
CHANGED
|
@@ -89,6 +89,7 @@ module Light
|
|
|
89
89
|
def success?
|
|
90
90
|
!errors?
|
|
91
91
|
end
|
|
92
|
+
alias successful? success?
|
|
92
93
|
|
|
93
94
|
# Check if the service completed with errors.
|
|
94
95
|
#
|
|
@@ -145,12 +146,12 @@ module Light
|
|
|
145
146
|
end
|
|
146
147
|
|
|
147
148
|
# Add an error and stop execution immediately, causing transaction rollback.
|
|
149
|
+
# Steps marked with `always: true` will still run after this method is called.
|
|
148
150
|
#
|
|
149
151
|
# @param message [String] the error message
|
|
150
152
|
# @raise [FailExecution] always raises to halt execution and rollback
|
|
151
153
|
# @return [void]
|
|
152
154
|
def fail_immediately!(message)
|
|
153
|
-
@stopped = true
|
|
154
155
|
errors.add(:base, message, rollback: false)
|
|
155
156
|
raise Light::Services::FailExecution
|
|
156
157
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Detects `errors.add(:base, "message")` and suggests using `fail!("message")` instead.
|
|
7
|
+
#
|
|
8
|
+
# This cop checks calls inside service classes that inherit from
|
|
9
|
+
# Light::Services::Base or any configured base service classes.
|
|
10
|
+
#
|
|
11
|
+
# @safety
|
|
12
|
+
# This cop's autocorrection is safe as `fail!` is a wrapper method
|
|
13
|
+
# that calls `errors.add(:base, message)` internally.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# # bad
|
|
17
|
+
# class User::Create < ApplicationService
|
|
18
|
+
# step :process
|
|
19
|
+
#
|
|
20
|
+
# private
|
|
21
|
+
#
|
|
22
|
+
# def process
|
|
23
|
+
# errors.add(:base, "user is required")
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # good
|
|
28
|
+
# class User::Create < ApplicationService
|
|
29
|
+
# step :process
|
|
30
|
+
#
|
|
31
|
+
# private
|
|
32
|
+
#
|
|
33
|
+
# def process
|
|
34
|
+
# fail!("user is required")
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
class PreferFailMethod < Base
|
|
39
|
+
extend AutoCorrector
|
|
40
|
+
|
|
41
|
+
MSG = "Use `fail!(...)` instead of `errors.add(:base, ...)`."
|
|
42
|
+
|
|
43
|
+
def_node_matcher :errors_add_base?, <<~PATTERN
|
|
44
|
+
(send
|
|
45
|
+
(send nil? :errors) :add
|
|
46
|
+
(sym :base)
|
|
47
|
+
...)
|
|
48
|
+
PATTERN
|
|
49
|
+
|
|
50
|
+
DEFAULT_BASE_CLASSES = ["ApplicationService"].freeze
|
|
51
|
+
|
|
52
|
+
def on_class(node)
|
|
53
|
+
@in_service_class = service_class?(node)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def after_class(_node)
|
|
57
|
+
@in_service_class = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def on_send(node)
|
|
61
|
+
return unless @in_service_class
|
|
62
|
+
return unless node.method_name == :add
|
|
63
|
+
return unless node.receiver&.method_name == :errors
|
|
64
|
+
|
|
65
|
+
return unless errors_add_base?(node)
|
|
66
|
+
|
|
67
|
+
# Only flag if there's a message argument after :base
|
|
68
|
+
# errors.add(:base) without a message is invalid Light Services syntax
|
|
69
|
+
message_args = node.arguments[1..]
|
|
70
|
+
return if message_args.empty?
|
|
71
|
+
|
|
72
|
+
add_offense(node, message: MSG) do |corrector|
|
|
73
|
+
autocorrect(corrector, node, message_args)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def autocorrect(corrector, node, message_args)
|
|
80
|
+
# Get the source code for all arguments after the :base symbol
|
|
81
|
+
args_source = message_args.map(&:source).join(", ")
|
|
82
|
+
replacement = "fail!(#{args_source})"
|
|
83
|
+
|
|
84
|
+
corrector.replace(node, replacement)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def service_class?(node)
|
|
88
|
+
return false unless node.parent_class
|
|
89
|
+
|
|
90
|
+
parent_class_name = extract_class_name(node.parent_class)
|
|
91
|
+
return false unless parent_class_name
|
|
92
|
+
|
|
93
|
+
# Check for direct Light::Services::Base inheritance
|
|
94
|
+
return true if parent_class_name == "Light::Services::Base"
|
|
95
|
+
|
|
96
|
+
# Check against configured base service classes
|
|
97
|
+
base_classes = cop_config.fetch("BaseServiceClasses", DEFAULT_BASE_CLASSES)
|
|
98
|
+
base_classes.include?(parent_class_name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_class_name(node)
|
|
102
|
+
case node.type
|
|
103
|
+
when :const
|
|
104
|
+
node.const_name
|
|
105
|
+
when :send
|
|
106
|
+
# For namespaced constants like Light::Services::Base
|
|
107
|
+
node.source
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../../../light/services/constants"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module LightServices
|
|
8
|
+
# Ensures that `arg`, `step`, and `output` declarations do not use reserved names
|
|
9
|
+
# that would conflict with Light::Services methods.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad
|
|
13
|
+
# arg :errors, type: Array
|
|
14
|
+
# arg :outputs, type: Hash
|
|
15
|
+
# step :call
|
|
16
|
+
# output :success?, type: [TrueClass, FalseClass]
|
|
17
|
+
#
|
|
18
|
+
# # good
|
|
19
|
+
# arg :validation_errors, type: Array
|
|
20
|
+
# arg :result_outputs, type: Hash
|
|
21
|
+
# step :execute
|
|
22
|
+
# output :succeeded, type: [TrueClass, FalseClass]
|
|
23
|
+
#
|
|
24
|
+
class ReservedName < Base
|
|
25
|
+
include RuboCop::Cop::RangeHelp
|
|
26
|
+
|
|
27
|
+
MSG = "`%<name>s` is a reserved name and cannot be used as %<field_type>s. " \
|
|
28
|
+
"It conflicts with Light::Services methods."
|
|
29
|
+
|
|
30
|
+
SEVERITY = :error
|
|
31
|
+
|
|
32
|
+
RESTRICT_ON_SEND = [:arg, :step, :output].freeze
|
|
33
|
+
|
|
34
|
+
FIELD_TYPE_NAMES = {
|
|
35
|
+
arg: "an argument",
|
|
36
|
+
step: "a step",
|
|
37
|
+
output: "an output",
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# @!method dsl_call?(node)
|
|
41
|
+
def_node_matcher :dsl_call?, <<~PATTERN
|
|
42
|
+
(send nil? ${:arg :step :output} (sym $_) ...)
|
|
43
|
+
PATTERN
|
|
44
|
+
|
|
45
|
+
def on_send(node)
|
|
46
|
+
dsl_call?(node) do |method_name, name|
|
|
47
|
+
return unless Light::Services::ReservedNames::ALL.include?(name)
|
|
48
|
+
|
|
49
|
+
field_type = FIELD_TYPE_NAMES[method_name]
|
|
50
|
+
add_offense(node, message: format(MSG, name: name, field_type: field_type), severity: SEVERITY)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -9,4 +9,6 @@ require_relative "rubocop/cop/light_services/dsl_order"
|
|
|
9
9
|
require_relative "rubocop/cop/light_services/missing_private_keyword"
|
|
10
10
|
require_relative "rubocop/cop/light_services/no_direct_instantiation"
|
|
11
11
|
require_relative "rubocop/cop/light_services/output_type_required"
|
|
12
|
+
require_relative "rubocop/cop/light_services/prefer_fail_method"
|
|
13
|
+
require_relative "rubocop/cop/light_services/reserved_name"
|
|
12
14
|
require_relative "rubocop/cop/light_services/step_method_exists"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: light-services
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.1
|
|
4
|
+
version: 3.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Kodkod
|
|
@@ -17,6 +17,8 @@ executables: []
|
|
|
17
17
|
extensions: []
|
|
18
18
|
extra_rdoc_files: []
|
|
19
19
|
files:
|
|
20
|
+
- ".cursor/rules/services-rspec/RULE.md"
|
|
21
|
+
- ".cursor/rules/services/RULE.md"
|
|
20
22
|
- ".github/config/rubocop_linter_action.yml"
|
|
21
23
|
- ".github/dependabot.yml"
|
|
22
24
|
- ".github/workflows/ci.yml"
|
|
@@ -96,6 +98,8 @@ files:
|
|
|
96
98
|
- lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb
|
|
97
99
|
- lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb
|
|
98
100
|
- lib/light/services/rubocop/cop/light_services/output_type_required.rb
|
|
101
|
+
- lib/light/services/rubocop/cop/light_services/prefer_fail_method.rb
|
|
102
|
+
- lib/light/services/rubocop/cop/light_services/reserved_name.rb
|
|
99
103
|
- lib/light/services/rubocop/cop/light_services/step_method_exists.rb
|
|
100
104
|
- lib/light/services/settings/field.rb
|
|
101
105
|
- lib/light/services/settings/step.rb
|