light-services 2.2.1 โ†’ 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -4
  3. data/.github/workflows/ci.yml +12 -12
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +77 -7
  6. data/CHANGELOG.md +23 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +76 -13
  11. data/docs/arguments.md +267 -0
  12. data/docs/best-practices.md +153 -0
  13. data/docs/callbacks.md +476 -0
  14. data/docs/concepts.md +80 -0
  15. data/docs/configuration.md +168 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +250 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +135 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +100 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/service-rendering.md +222 -0
  26. data/docs/steps.md +337 -0
  27. data/docs/summary.md +19 -0
  28. data/docs/testing.md +549 -0
  29. data/lib/generators/light_services/install/USAGE +15 -0
  30. data/lib/generators/light_services/install/install_generator.rb +41 -0
  31. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  32. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  33. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  34. data/lib/generators/light_services/service/USAGE +21 -0
  35. data/lib/generators/light_services/service/service_generator.rb +68 -0
  36. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  37. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  38. data/lib/light/services/base.rb +23 -113
  39. data/lib/light/services/callbacks.rb +103 -0
  40. data/lib/light/services/collection.rb +97 -0
  41. data/lib/light/services/concerns/execution.rb +76 -0
  42. data/lib/light/services/concerns/parent_service.rb +34 -0
  43. data/lib/light/services/concerns/state_management.rb +30 -0
  44. data/lib/light/services/config.rb +4 -18
  45. data/lib/light/services/constants.rb +97 -0
  46. data/lib/light/services/dsl/arguments_dsl.rb +84 -0
  47. data/lib/light/services/dsl/outputs_dsl.rb +80 -0
  48. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  49. data/lib/light/services/dsl/validation.rb +132 -0
  50. data/lib/light/services/exceptions.rb +7 -2
  51. data/lib/light/services/messages.rb +19 -31
  52. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  53. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  54. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  55. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  56. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  57. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  58. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  59. data/lib/light/services/rspec.rb +15 -0
  60. data/lib/light/services/settings/field.rb +86 -0
  61. data/lib/light/services/settings/step.rb +31 -16
  62. data/lib/light/services/utils.rb +38 -0
  63. data/lib/light/services/version.rb +1 -1
  64. data/lib/light/services.rb +2 -0
  65. data/light-services.gemspec +6 -8
  66. metadata +54 -26
  67. data/lib/light/services/class_based_collection/base.rb +0 -86
  68. data/lib/light/services/class_based_collection/mount.rb +0 -33
  69. data/lib/light/services/collection/arguments.rb +0 -34
  70. data/lib/light/services/collection/base.rb +0 -59
  71. data/lib/light/services/collection/outputs.rb +0 -16
  72. data/lib/light/services/settings/argument.rb +0 -68
  73. 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)
@@ -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, 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,100 @@
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
+ - ๐ŸŒ **Framework Agnostic**: Compatible with Rails, Hanami, or any Ruby framework
17
+ - ๐Ÿงฉ **Modularity**: Isolate and test your services with ease
18
+ - โœ… **100% Test Coverage**: Thoroughly tested and reliable
19
+ - โš”๏ธ **Battle-Tested**: In production use since 2017
20
+
21
+ ## Simple Example
22
+
23
+ ```ruby
24
+ class GreetService < Light::Services::Base
25
+ # Arguments
26
+ arg :name
27
+ arg :age
28
+
29
+ # Steps
30
+ step :build_message
31
+ step :send_message
32
+
33
+ # Outputs
34
+ output :message
35
+
36
+ private
37
+
38
+ def build_message
39
+ self.message = "Hello, #{name}! You are #{age} years old."
40
+ end
41
+
42
+ def send_message
43
+ # Send logic goes here
44
+ end
45
+ end
46
+ ```
47
+
48
+ ## Advanced Example
49
+
50
+ ```ruby
51
+ class User::ResetPassword < Light::Services::Base
52
+ # Arguments
53
+ arg :user, type: User, optional: true
54
+ arg :email, type: String, optional: true
55
+ arg :send_email, type: [TrueClass, FalseClass], default: true
56
+
57
+ # Steps
58
+ step :validate
59
+ step :find_user, unless: :user?
60
+ step :generate_reset_token
61
+ step :save_reset_token
62
+ step :send_reset_email, if: :send_email?
63
+
64
+ # Outputs
65
+ output :user, type: User
66
+ output :reset_token, type: String
67
+
68
+ private
69
+
70
+ def validate
71
+ errors.add(:base, "user or email is required") if !user? && !email?
72
+ end
73
+
74
+ def find_user
75
+ self.user = User.find_by("LOWER(email) = ?", email.downcase)
76
+ errors.add(:email, "not found") unless user
77
+ end
78
+
79
+ def generate_reset_token
80
+ self.reset_token = SecureRandom.hex(32)
81
+ end
82
+
83
+ def save_reset_token
84
+ user.update!(
85
+ reset_password_token: reset_token,
86
+ reset_password_sent_at: Time.current,
87
+ )
88
+ rescue ActiveRecord::RecordInvalid => e
89
+ errors.from_record(e.record)
90
+ end
91
+
92
+ def send_reset_email
93
+ Mailer::SendEmail
94
+ .with(self) # Call sub-service with the same context
95
+ .run(template: :reset_password, user:, reset_token:)
96
+ end
97
+ end
98
+ ```
99
+
100
+ [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
+