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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Pundit Authorization
|
|
2
|
+
|
|
3
|
+
[Pundit](https://github.com/varvet/pundit) is a simple, flexible authorization library for Ruby on Rails. This recipe shows how to integrate Pundit authorization into your Light Services.
|
|
4
|
+
|
|
5
|
+
## Why Use Pundit with Services?
|
|
6
|
+
|
|
7
|
+
- **Centralized authorization**: Keep authorization logic in policy classes
|
|
8
|
+
- **Consistent patterns**: Same authorization approach across controllers and services
|
|
9
|
+
- **Testable**: Policies are easy to unit test
|
|
10
|
+
- **Reusable**: Services can be called from controllers, jobs, or other services with consistent authorization
|
|
11
|
+
|
|
12
|
+
## Basic Setup
|
|
13
|
+
|
|
14
|
+
### 1. Create an Authorization Concern
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# app/services/concerns/authorize_user.rb
|
|
18
|
+
module AuthorizeUser
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
arg :current_user, type: User, optional: true, context: true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Authorize an action on a record or class
|
|
26
|
+
def auth(record, action)
|
|
27
|
+
policy = policy(record)
|
|
28
|
+
|
|
29
|
+
unless policy.public_send(action)
|
|
30
|
+
errors.add(:authorization, "You are not authorized to perform this action")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
alias_method :authorize!, :auth
|
|
34
|
+
|
|
35
|
+
# Get permitted attributes for an action
|
|
36
|
+
def permitted_attributes(record, action = nil)
|
|
37
|
+
policy = policy(record)
|
|
38
|
+
|
|
39
|
+
method_name = if action
|
|
40
|
+
"permitted_attributes_for_#{action}"
|
|
41
|
+
else
|
|
42
|
+
"permitted_attributes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if policy.respond_to?(method_name)
|
|
46
|
+
attributes = policy.public_send(method_name)
|
|
47
|
+
params.require(param_key(record)).permit(*attributes)
|
|
48
|
+
else
|
|
49
|
+
raise Pundit::NotDefinedError, "#{method_name} not defined in #{policy.class}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def policy(record)
|
|
56
|
+
Pundit.policy!(current_user, record)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def param_key(record)
|
|
60
|
+
if record.is_a?(Class)
|
|
61
|
+
record.model_name.param_key
|
|
62
|
+
else
|
|
63
|
+
record.model_name.param_key
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. Include in ApplicationService
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# app/services/application_service.rb
|
|
73
|
+
class ApplicationService < Light::Services::Base
|
|
74
|
+
include AuthorizeUser
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Usage Examples
|
|
79
|
+
|
|
80
|
+
### Authorizing Actions
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class Post::Update < ApplicationService
|
|
84
|
+
arg :post, type: Post
|
|
85
|
+
arg :attributes, type: Hash
|
|
86
|
+
|
|
87
|
+
step :authorize
|
|
88
|
+
step :update_post
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def authorize
|
|
93
|
+
auth(post, :update?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def update_post
|
|
97
|
+
post.update!(attributes)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Authorizing on a Class (for Create actions)
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class Post::Create < ApplicationService
|
|
106
|
+
arg :attributes, type: Hash
|
|
107
|
+
|
|
108
|
+
step :authorize
|
|
109
|
+
step :create_post
|
|
110
|
+
|
|
111
|
+
output :post
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def authorize
|
|
116
|
+
auth(Post, :create?)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create_post
|
|
120
|
+
self.post = Post.create!(attributes)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Using Permitted Attributes
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class Post::Create < ApplicationService
|
|
129
|
+
step :authorize
|
|
130
|
+
step :create_post
|
|
131
|
+
|
|
132
|
+
output :post
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def authorize
|
|
137
|
+
auth(Post, :create?)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def create_post
|
|
141
|
+
self.post = Post.create!(permitted_attributes(Post, :create))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Policy Example
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# app/policies/post_policy.rb
|
|
150
|
+
class PostPolicy < ApplicationPolicy
|
|
151
|
+
def create?
|
|
152
|
+
!user.nil?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def update?
|
|
156
|
+
!user.nil? && (record.author == user || user.admin?)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def destroy?
|
|
160
|
+
!user.nil? && (record.author == user || user.admin?)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def permitted_attributes_for_create
|
|
164
|
+
[:title, :body, :category_id]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def permitted_attributes_for_update
|
|
168
|
+
attributes = [:title, :body]
|
|
169
|
+
attributes << :category_id if user.admin?
|
|
170
|
+
attributes
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Integration with CRUD Services
|
|
176
|
+
|
|
177
|
+
Combine Pundit with the [CRUD recipe](crud.md) for powerful, authorized services:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# app/services/create_record_service.rb
|
|
181
|
+
class CreateRecordService < ApplicationService
|
|
182
|
+
arg :record_class, type: Class
|
|
183
|
+
arg :attributes, type: Hash, default: {}
|
|
184
|
+
|
|
185
|
+
step :authorize
|
|
186
|
+
step :create_record
|
|
187
|
+
|
|
188
|
+
output :record
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def authorize
|
|
193
|
+
auth(record_class, :create?)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def create_record
|
|
197
|
+
attrs = permitted_attributes(record_class, :create)
|
|
198
|
+
.to_h
|
|
199
|
+
.merge(attributes)
|
|
200
|
+
|
|
201
|
+
self.record = record_class.create!(attrs)
|
|
202
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
203
|
+
errors.copy_from(e.record)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Handling Authorization Failures
|
|
209
|
+
|
|
210
|
+
### Option 1: Collect as Errors (Default)
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
def authorize
|
|
214
|
+
auth(record, :update?)
|
|
215
|
+
# If unauthorized, an error is added and subsequent steps are skipped
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Option 2: Raise Exceptions
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
def authorize
|
|
223
|
+
unless policy(record).update?
|
|
224
|
+
raise Pundit::NotAuthorizedError, "not authorized to update this record"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Option 3: Custom Error Handling
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
def authorize
|
|
233
|
+
return if policy(record).update?
|
|
234
|
+
|
|
235
|
+
errors.add(:base, I18n.t("pundit.not_authorized"))
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Testing Services with Authorization
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
RSpec.describe Post::Update do
|
|
243
|
+
let(:author) { create(:user) }
|
|
244
|
+
let(:other_user) { create(:user) }
|
|
245
|
+
let(:post) { create(:post, author: author) }
|
|
246
|
+
|
|
247
|
+
context "when user is the author" do
|
|
248
|
+
it "updates the post" do
|
|
249
|
+
service = described_class.run(
|
|
250
|
+
current_user: author,
|
|
251
|
+
post: post,
|
|
252
|
+
attributes: { title: "New Title" }
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
expect(service).to be_success
|
|
256
|
+
expect(post.reload.title).to eq("New Title")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
context "when user is not the author" do
|
|
261
|
+
it "returns authorization error" do
|
|
262
|
+
service = described_class.run(
|
|
263
|
+
current_user: other_user,
|
|
264
|
+
post: post,
|
|
265
|
+
attributes: { title: "New Title" }
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
expect(service).to be_failed
|
|
269
|
+
expect(service.errors[:authorization]).to be_present
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
context "when no user is provided" do
|
|
274
|
+
it "returns authorization error" do
|
|
275
|
+
service = described_class.run(
|
|
276
|
+
current_user: nil,
|
|
277
|
+
post: post,
|
|
278
|
+
attributes: { title: "New Title" }
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
expect(service).to be_failed
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Skipping Authorization
|
|
288
|
+
|
|
289
|
+
For system-level operations or background jobs where authorization isn't needed:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
class Post::SystemUpdate < UpdateRecordService
|
|
293
|
+
# Remove the authorize step for system operations
|
|
294
|
+
remove_step :authorize
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Or create a "system" flag:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
class ApplicationService < Light::Services::Base
|
|
302
|
+
arg :system, type: [TrueClass, FalseClass], default: false, context: true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
class Post::Update < ApplicationService
|
|
306
|
+
step :authorize, unless: :system?
|
|
307
|
+
step :update_post
|
|
308
|
+
|
|
309
|
+
# ...
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Usage in background job
|
|
313
|
+
Post::Update.run(post: post, attributes: attrs, system: true)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## What's Next?
|
|
317
|
+
|
|
318
|
+
Return to the recipes overview:
|
|
319
|
+
|
|
320
|
+
[Back to Recipes](recipes.md)
|
data/docs/quickstart.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
Light Services are framework-agnostic and can be used in any Ruby project.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "light-services", "~> 3.0"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or you can install it yourself by running:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle add light-services --version "~> 3.0"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Create `ApplicationService`
|
|
20
|
+
|
|
21
|
+
{% hint style="info" %}
|
|
22
|
+
This step is optional but recommended. Creating a base class for your services can help organize your code. This base class will act as the parent for all your services, where you can include common logic such as helpers, logging, error handling, etc.
|
|
23
|
+
{% endhint %}
|
|
24
|
+
|
|
25
|
+
### For Rails Applications
|
|
26
|
+
|
|
27
|
+
If you're using Rails, you can use the install generator to set up Light Services automatically:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bin/rails generate light_services:install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This will create the `ApplicationService` base class, an initializer, and a spec file (if RSpec is detected). See [Rails Generators](generators.md) for more details.
|
|
34
|
+
|
|
35
|
+
### For Non-Rails Applications
|
|
36
|
+
|
|
37
|
+
First, create a folder for your services. The path will depend on the framework you are using. For Rails, you can create a folder in `app/services`.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
mkdir app/services
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Next, create your base class. You can name it as you wish, but we recommend `ApplicationService`.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/services/application_service.rb
|
|
47
|
+
class ApplicationService < Light::Services::Base
|
|
48
|
+
# Add common arguments, callbacks, or helpers shared across all services.
|
|
49
|
+
#
|
|
50
|
+
# Example: Add a context argument for the current user
|
|
51
|
+
# arg :current_user, type: User, optional: true, context: true
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Create Your First Service
|
|
56
|
+
|
|
57
|
+
Now let's create our first service. We'll make a simple service that returns a greeting message.
|
|
58
|
+
|
|
59
|
+
{% hint style="info" %}
|
|
60
|
+
**Rails users:** You can use the service generator to create services quickly:
|
|
61
|
+
```bash
|
|
62
|
+
bin/rails generate light_services:service GreetService --args=name --steps=greet --outputs=greeted
|
|
63
|
+
```
|
|
64
|
+
See [Rails Generators](generators.md) for more information.
|
|
65
|
+
{% endhint %}
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# app/services/greet_service.rb
|
|
69
|
+
class GreetService < ApplicationService
|
|
70
|
+
# Arguments
|
|
71
|
+
arg :name, type: String
|
|
72
|
+
|
|
73
|
+
# Steps
|
|
74
|
+
step :greet
|
|
75
|
+
|
|
76
|
+
# Outputs
|
|
77
|
+
output :greeted, type: [TrueClass, FalseClass], default: false
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def greet
|
|
82
|
+
puts "Hello, #{name}!"
|
|
83
|
+
self.greeted = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Run the Service
|
|
89
|
+
|
|
90
|
+
Now you can run your service from anywhere in your application.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
service = GreetService.run(name: "John")
|
|
94
|
+
service.greeted # => true
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Check for Success or Failure
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
service = GreetService.run(name: "John")
|
|
101
|
+
|
|
102
|
+
if service.success?
|
|
103
|
+
puts "Greeting sent!"
|
|
104
|
+
puts service.greeted
|
|
105
|
+
else
|
|
106
|
+
puts "Failed: #{service.errors.to_h}"
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Raise on Error with `run!`
|
|
111
|
+
|
|
112
|
+
Use `run!` when you want errors to raise exceptions instead of being collected:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# This will raise Light::Services::Error if any errors are added
|
|
116
|
+
service = GreetService.run!(name: "John")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This is equivalent to:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
service = GreetService.run({ name: "John" }, { raise_on_error: true })
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
{% hint style="info" %}
|
|
126
|
+
Looks easy, right? But this is just the beginning. Light Services can do much more ๐
|
|
127
|
+
{% endhint %}
|
|
128
|
+
|
|
129
|
+
## What's Next?
|
|
130
|
+
|
|
131
|
+
Learn how to configure Light Services for your application:
|
|
132
|
+
|
|
133
|
+
[Next: Configuration](configuration.md)
|
|
134
|
+
|
data/docs/readme.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Light Services
|
|
2
|
+
|
|
3
|
+
Light Services is a simple yet powerful way to organize business logic in Ruby applications. Build services that are easy to test, maintain, and understand.
|
|
4
|
+
|
|
5
|
+
[Get started with Quickstart](quickstart.md)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- โจ **Simple**: Define your service as a class with `arguments`, `steps`, and `outputs`
|
|
10
|
+
- ๐ฆ **No runtime dependencies**: Works stand-alone without requiring external gems at runtime
|
|
11
|
+
- ๐ **Transactions**: Automatically rollback database changes if any step fails
|
|
12
|
+
- ๐งฌ **Inheritance**: Inherit from other services to reuse logic seamlessly
|
|
13
|
+
- โ ๏ธ **Error Handling**: Collect errors from steps and handle them your way
|
|
14
|
+
- ๐ **Context**: Run multiple services sequentially within the same context
|
|
15
|
+
- ๐งช **RSpec Matchers**: Built-in RSpec matchers for expressive service tests
|
|
16
|
+
- ๐ **RuboCop Integration**: Custom cops to enforce best practices at lint time
|
|
17
|
+
- ๐ **Framework Agnostic**: Compatible with Rails, Hanami, or any Ruby framework
|
|
18
|
+
- ๐งฉ **Modularity**: Isolate and test your services with ease
|
|
19
|
+
- โ
**100% Test Coverage**: Thoroughly tested and reliable
|
|
20
|
+
- โ๏ธ **Battle-Tested**: In production use since 2017
|
|
21
|
+
|
|
22
|
+
## Simple Example
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class GreetService < Light::Services::Base
|
|
26
|
+
# Arguments
|
|
27
|
+
arg :name, type: String
|
|
28
|
+
arg :age, type: Integer
|
|
29
|
+
|
|
30
|
+
# Steps
|
|
31
|
+
step :build_message
|
|
32
|
+
step :send_message
|
|
33
|
+
|
|
34
|
+
# Outputs
|
|
35
|
+
output :message, type: String
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_message
|
|
40
|
+
self.message = "Hello, #{name}! You are #{age} years old."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def send_message
|
|
44
|
+
# Send logic goes here
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Advanced Example (with dry-types and conditions)
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class User::ResetPassword < Light::Services::Base
|
|
53
|
+
# Arguments with dry-types for advanced validation and coercion
|
|
54
|
+
arg :user, type: Types.Instance(User), optional: true
|
|
55
|
+
arg :email, type: Types::Coercible::String, optional: true
|
|
56
|
+
arg :send_email, type: Types::Params::Bool, default: true
|
|
57
|
+
|
|
58
|
+
# Steps
|
|
59
|
+
step :validate
|
|
60
|
+
step :find_user, unless: :user?
|
|
61
|
+
step :generate_reset_token
|
|
62
|
+
step :save_reset_token
|
|
63
|
+
step :send_reset_email, if: :send_email?
|
|
64
|
+
|
|
65
|
+
# Outputs with dry-types
|
|
66
|
+
output :user, type: Types.Instance(User)
|
|
67
|
+
output :reset_token, type: Types::Strict::String
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate
|
|
72
|
+
errors.add(:base, "user or email is required") if !user? && !email?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find_user
|
|
76
|
+
self.user = User.find_by("LOWER(email) = ?", email.downcase)
|
|
77
|
+
errors.add(:email, "not found") unless user
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def generate_reset_token
|
|
81
|
+
self.reset_token = SecureRandom.hex(32)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def save_reset_token
|
|
85
|
+
user.update!(
|
|
86
|
+
reset_password_token: reset_token,
|
|
87
|
+
reset_password_sent_at: Time.current,
|
|
88
|
+
)
|
|
89
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
90
|
+
errors.from_record(e.record)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def send_reset_email
|
|
94
|
+
Mailer::SendEmail
|
|
95
|
+
.with(self) # Call sub-service with the same context
|
|
96
|
+
.run(template: :reset_password, user:, reset_token:)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
[Get started with Light Services](quickstart.md)
|
data/docs/recipes.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Recipes
|
|
2
|
+
|
|
3
|
+
This section contains practical recipes for common patterns when using Light Services.
|
|
4
|
+
|
|
5
|
+
- [CRUD](crud.md) - Implementing top-level services for CRUD operations
|
|
6
|
+
- [Service Rendering](service-rendering.md) - Easy way to render service results and errors
|
|
7
|
+
- [Pundit Authorization](pundit-authorization.md) - Integrating Pundit authorization with Light Services
|
|
8
|
+
|
|
9
|
+
## Getting Started
|
|
10
|
+
|
|
11
|
+
These recipes build upon each other. We recommend starting with CRUD services, then adding service rendering for cleaner controllers, and finally integrating Pundit for authorization.
|
|
12
|
+
|
|
13
|
+
[Start with CRUD](crud.md)
|
|
14
|
+
|