light-services 2.2 → 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 (74) 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 +5 -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 +24 -114
  39. data/lib/light/services/base_with_context.rb +2 -3
  40. data/lib/light/services/callbacks.rb +103 -0
  41. data/lib/light/services/collection.rb +97 -0
  42. data/lib/light/services/concerns/execution.rb +76 -0
  43. data/lib/light/services/concerns/parent_service.rb +34 -0
  44. data/lib/light/services/concerns/state_management.rb +30 -0
  45. data/lib/light/services/config.rb +4 -18
  46. data/lib/light/services/constants.rb +97 -0
  47. data/lib/light/services/dsl/arguments_dsl.rb +84 -0
  48. data/lib/light/services/dsl/outputs_dsl.rb +80 -0
  49. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  50. data/lib/light/services/dsl/validation.rb +132 -0
  51. data/lib/light/services/exceptions.rb +7 -2
  52. data/lib/light/services/messages.rb +19 -31
  53. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  54. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  55. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  56. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  57. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  58. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  59. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  60. data/lib/light/services/rspec.rb +15 -0
  61. data/lib/light/services/settings/field.rb +86 -0
  62. data/lib/light/services/settings/step.rb +31 -16
  63. data/lib/light/services/utils.rb +38 -0
  64. data/lib/light/services/version.rb +1 -1
  65. data/lib/light/services.rb +2 -0
  66. data/light-services.gemspec +6 -8
  67. metadata +54 -26
  68. data/lib/light/services/class_based_collection/base.rb +0 -86
  69. data/lib/light/services/class_based_collection/mount.rb +0 -33
  70. data/lib/light/services/collection/arguments.rb +0 -34
  71. data/lib/light/services/collection/base.rb +0 -59
  72. data/lib/light/services/collection/outputs.rb +0 -16
  73. data/lib/light/services/settings/argument.rb +0 -68
  74. data/lib/light/services/settings/output.rb +0 -34
data/docs/errors.md ADDED
@@ -0,0 +1,250 @@
1
+ # Errors
2
+
3
+ Errors are a natural part of every application. This guide explores how to handle errors within Light Services, drawing parallels to ActiveModel errors.
4
+
5
+ ## Error Structure
6
+
7
+ Light Service errors follow a structure similar to ActiveModel errors. Here's a simplified example:
8
+
9
+ ```ruby
10
+ {
11
+ email: ["must be a valid email"],
12
+ password: ["is too short", "must contain at least one number"]
13
+ }
14
+ ```
15
+
16
+ ## Adding Errors
17
+
18
+ To add an error to your service, use the `errors.add` method.
19
+
20
+ {% hint style="info" %}
21
+ By default, adding an error marks the service as failed, preventing subsequent steps from executing. This behavior can be customized in the configuration for individual services and errors.
22
+ {% endhint %}
23
+
24
+ ```ruby
25
+ class ParsePage < ApplicationService
26
+ # Arguments
27
+ arg :url, type: String
28
+ # ...
29
+
30
+ # Steps
31
+ step :validate
32
+ step :parse
33
+ # ...
34
+
35
+ private
36
+
37
+ def validate
38
+ # Multiple errors can be added with the same key
39
+ errors.add(:url, "must be a valid URL") unless url.match?(URI::DEFAULT_PARSER.make_regexp)
40
+ errors.add(:url, "must be a secure link") unless url.start_with?("https")
41
+ end
42
+
43
+ # ...
44
+ end
45
+ ```
46
+
47
+ ## Reading Errors
48
+
49
+ To check if a service has errors, you can use the `#failed?` method. You can also use methods like `errors.any?` to inspect errors.
50
+
51
+ ```ruby
52
+ class ParsePage < ApplicationService
53
+ def parse
54
+ nodes.each do |node|
55
+ if node.nil? || (node.respond_to?(:empty?) && node.empty?)
56
+ errors.add(:base, "Node #{node} is blank")
57
+ else
58
+ parse_node(node)
59
+ end
60
+ end
61
+
62
+ if failed? # or errors.any?
63
+ puts "Not all nodes were parsed"
64
+ end
65
+ end
66
+ end
67
+ ```
68
+
69
+ You can access errors outside the service using the `#errors` method.
70
+
71
+ ```ruby
72
+ service = ParsePage.run(url: "rubygems")
73
+
74
+ if service.failed?
75
+ puts service.errors
76
+ puts service.errors[:url]
77
+ puts service.errors.to_h # Returns errors as a hash
78
+ end
79
+ ```
80
+
81
+ ## Adding Warnings
82
+
83
+ Sometimes, you may want to add a warning instead of an error. Warnings are similar to errors but they do not mark the service as failed. By default they also do not stop execution and do not roll back the transaction (both behaviors can be configured globally or per-message).
84
+
85
+ ```ruby
86
+ class ParsePage < ApplicationService
87
+ def validate
88
+ errors.add(:url, "must be a valid URL") unless url.match?(URI::DEFAULT_PARSER.make_regexp)
89
+ warnings.add(:url, "should be a secure link") unless url.start_with?("https")
90
+ end
91
+ end
92
+ ```
93
+
94
+ ```ruby
95
+ service = ParsePage.run(url: "http://rubygems.org")
96
+
97
+ if service.warnings.any?
98
+ puts service.warnings
99
+ puts service.warnings[:url]
100
+ puts service.warnings.to_h # Returns warnings as a hash
101
+ end
102
+ ```
103
+
104
+ ## Copying Errors
105
+
106
+ ### From ActiveRecord Models
107
+
108
+ Use `errors.copy_from` (or its alias `errors.from_record`) to copy errors from an ActiveRecord model:
109
+
110
+ ```ruby
111
+ class User::Create < ApplicationService
112
+ def create_user
113
+ self.user = User.new(attributes)
114
+
115
+ unless user.save
116
+ errors.copy_from(user) # Copies all validation errors from the user model
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### From Another Service
123
+
124
+ Copy errors from a child service that wasn't run in the same context:
125
+
126
+ ```ruby
127
+ class Order::Process < ApplicationService
128
+ def process_payment
129
+ payment_service = Payment::Charge.run(amount:, card:)
130
+
131
+ if payment_service.failed?
132
+ errors.copy_from(payment_service)
133
+ end
134
+ end
135
+ end
136
+ ```
137
+
138
+ ## Converting Errors to Hash
139
+
140
+ Use `errors.to_h` to get a hash representation of all errors:
141
+
142
+ ```ruby
143
+ service = User::Create.run(email: "invalid")
144
+
145
+ if service.failed?
146
+ service.errors.to_h
147
+ # => { email: ["is invalid"], password: ["can't be blank"] }
148
+ end
149
+ ```
150
+
151
+ ## Per-Message Options
152
+
153
+ When adding errors, you can control behavior on a per-message basis:
154
+
155
+ ### Control Break Behavior
156
+
157
+ ```ruby
158
+ def validate
159
+ # This error won't stop subsequent steps from running
160
+ errors.add(:warning_field, "has a minor issue", break: false)
161
+
162
+ # This error WILL stop execution (default behavior)
163
+ errors.add(:critical_field, "is completely invalid")
164
+ end
165
+ ```
166
+
167
+ ### Control Rollback Behavior
168
+
169
+ ```ruby
170
+ def process
171
+ # This error won't trigger a transaction rollback
172
+ errors.add(:notification, "failed to send", rollback: false)
173
+
174
+ # This error WILL rollback (default behavior when use_transactions is true)
175
+ errors.add(:payment, "failed to process")
176
+ end
177
+ ```
178
+
179
+ ## Checking for Errors and Warnings
180
+
181
+ Light Services provides convenient methods to check error/warning states:
182
+
183
+ ```ruby
184
+ service = MyService.run(args)
185
+
186
+ # Check if service has any errors
187
+ service.failed? # => true/false
188
+ service.success? # => true/false (opposite of failed?)
189
+ service.errors? # => true/false (same as errors.any?)
190
+
191
+ # Check if service has any warnings
192
+ service.warnings? # => true/false (same as warnings.any?)
193
+ ```
194
+
195
+ By following these guidelines, you can effectively manage errors and warnings in Light Services, ensuring a smoother and more robust application experience.
196
+
197
+ ## Exception Classes
198
+
199
+ Light Services defines several exception classes for different error scenarios:
200
+
201
+ | Exception | Description |
202
+ |-----------|-------------|
203
+ | `Light::Services::Error` | Base exception class for all Light Services errors |
204
+ | `Light::Services::ArgTypeError` | Raised when an argument type validation fails |
205
+ | `Light::Services::ReservedNameError` | Raised when using a reserved name for arguments, outputs, or steps |
206
+ | `Light::Services::InvalidNameError` | Raised when using an invalid name format |
207
+ | `Light::Services::NoStepsError` | Raised when a service has no steps defined and no `run` method |
208
+
209
+ ### NoStepsError
210
+
211
+ This exception is raised when you attempt to execute a service that has no steps defined and no `run` method as a fallback:
212
+
213
+ ```ruby
214
+ class EmptyService < ApplicationService
215
+ # No steps defined and no run method
216
+ end
217
+
218
+ EmptyService.run # => raises Light::Services::NoStepsError
219
+ ```
220
+
221
+ To fix this, either define at least one step or implement a `run` method:
222
+
223
+ ```ruby
224
+ # Option 1: Define steps
225
+ class MyService < ApplicationService
226
+ step :do_work
227
+
228
+ private
229
+
230
+ def do_work
231
+ # ...
232
+ end
233
+ end
234
+
235
+ # Option 2: Use run method
236
+ class MyService < ApplicationService
237
+ private
238
+
239
+ def run
240
+ # ...
241
+ end
242
+ end
243
+ ```
244
+
245
+ ## What's next?
246
+
247
+ Learn about callbacks to add logging, benchmarking, and other cross-cutting concerns to your services.
248
+
249
+ [Next: Callbacks](callbacks.md)
250
+
@@ -0,0 +1,250 @@
1
+ # Rails Generators
2
+
3
+ Light Services includes Rails generators to help you quickly set up and create services in your Rails application. These generators follow Rails conventions and integrate seamlessly with your Rails workflow.
4
+
5
+ ## Install Generator
6
+
7
+ The install generator sets up Light Services in your Rails application by creating the base `ApplicationService` class and configuration files.
8
+
9
+ ### Usage
10
+
11
+ ```bash
12
+ bin/rails generate light_services:install
13
+ ```
14
+
15
+ ### What It Creates
16
+
17
+ The install generator creates the following files:
18
+
19
+ 1. **`app/services/application_service.rb`** - Base service class for your application
20
+ ```ruby
21
+ class ApplicationService < Light::Services::Base
22
+ # Add common arguments, callbacks, or helpers shared across all services.
23
+ #
24
+ # Example: Add a context argument for the current user
25
+ # arg :current_user, type: User, optional: true, context: true
26
+ end
27
+ ```
28
+
29
+ 2. **`config/initializers/light_services.rb`** - Configuration file (unless `--skip-initializer` is used)
30
+ This file contains the global configuration for Light Services in your Rails application.
31
+
32
+ 3. **`spec/services/application_service_spec.rb`** - RSpec test file (if RSpec is detected and `--skip-spec` is not used)
33
+
34
+ ### Options
35
+
36
+ - `--skip-initializer` - Skip creating the initializer file
37
+ - `--skip-spec` - Skip creating the spec file
38
+
39
+ ### Examples
40
+
41
+ ```bash
42
+ # Standard installation
43
+ bin/rails generate light_services:install
44
+
45
+ # Skip initializer
46
+ bin/rails generate light_services:install --skip-initializer
47
+
48
+ # Skip spec file
49
+ bin/rails generate light_services:install --skip-spec
50
+ ```
51
+
52
+ ## Service Generator
53
+
54
+ The service generator creates a new service class that inherits from `ApplicationService`. It supports namespaced services and can pre-populate arguments, steps, and outputs.
55
+
56
+ ### Usage
57
+
58
+ ```bash
59
+ bin/rails generate light_services:service NAME [options]
60
+ ```
61
+
62
+ ### What It Creates
63
+
64
+ The service generator creates:
65
+
66
+ 1. **Service file** - `app/services/{name}.rb`
67
+ 2. **Spec file** - `spec/services/{name}_spec.rb` (if RSpec is detected and `--skip-spec` is not used)
68
+
69
+ ### Options
70
+
71
+ - `--args` - List of arguments for the service (space-separated)
72
+ - `--steps` - List of steps for the service (space-separated)
73
+ - `--outputs` - List of outputs for the service (space-separated)
74
+ - `--skip-spec` - Skip creating the spec file
75
+ - `--parent` - Parent class (default: `ApplicationService`)
76
+
77
+ ### Examples
78
+
79
+ #### Basic Service
80
+
81
+ Create a simple service without any predefined structure:
82
+
83
+ ```bash
84
+ bin/rails generate light_services:service user/create
85
+ ```
86
+
87
+ This creates:
88
+ ```ruby
89
+ # app/services/user/create.rb
90
+ class User::Create < ApplicationService
91
+ # step :step_a
92
+ # step :step_b
93
+
94
+ private
95
+
96
+ # def step_a
97
+ # # TODO: Implement service logic
98
+ # end
99
+
100
+ # def step_b
101
+ # # TODO: Implement service logic
102
+ # end
103
+ end
104
+ ```
105
+
106
+ #### Service with Arguments, Steps, and Outputs
107
+
108
+ Create a fully structured service:
109
+
110
+ ```bash
111
+ bin/rails generate light_services:service CreateOrder \
112
+ --args=user product quantity \
113
+ --steps=validate_stock create_order send_confirmation \
114
+ --outputs=order
115
+ ```
116
+
117
+ This creates:
118
+ ```ruby
119
+ # app/services/create_order.rb
120
+ class CreateOrder < ApplicationService
121
+ # Arguments
122
+ arg :user
123
+ arg :product
124
+ arg :quantity
125
+
126
+ # Steps
127
+ step :validate_stock
128
+ step :create_order
129
+ step :send_confirmation
130
+
131
+ # Outputs
132
+ output :order
133
+
134
+ private
135
+
136
+ def validate_stock
137
+ # TODO: Implement validate_stock
138
+ end
139
+
140
+ def create_order
141
+ # TODO: Implement create_order
142
+ end
143
+
144
+ def send_confirmation
145
+ # TODO: Implement send_confirmation
146
+ end
147
+ end
148
+ ```
149
+
150
+ #### Namespaced Service
151
+
152
+ Create a service within a namespace:
153
+
154
+ ```bash
155
+ bin/rails generate light_services:service payment/process \
156
+ --args=order payment_method \
157
+ --steps=validate_payment charge_card update_order \
158
+ --outputs=transaction
159
+ ```
160
+
161
+ This creates:
162
+ ```ruby
163
+ # app/services/payment/process.rb
164
+ class Payment::Process < ApplicationService
165
+ # Arguments
166
+ arg :order
167
+ arg :payment_method
168
+
169
+ # Steps
170
+ step :validate_payment
171
+ step :charge_card
172
+ step :update_order
173
+
174
+ # Outputs
175
+ output :transaction
176
+
177
+ private
178
+
179
+ def validate_payment
180
+ # TODO: Implement validate_payment
181
+ end
182
+
183
+ def charge_card
184
+ # TODO: Implement charge_card
185
+ end
186
+
187
+ def update_order
188
+ # TODO: Implement update_order
189
+ end
190
+ end
191
+ ```
192
+
193
+ #### Custom Parent Class
194
+
195
+ Create a service that inherits from a custom parent class:
196
+
197
+ ```bash
198
+ bin/rails generate light_services:service admin/reports/generate \
199
+ --parent=AdminService \
200
+ --args=start_date end_date \
201
+ --steps=fetch_data generate_report \
202
+ --outputs=report
203
+ ```
204
+
205
+ ## RSpec Integration
206
+
207
+ Both generators automatically detect if RSpec is installed in your Rails application by checking for the presence of the `spec/` directory. If RSpec is detected, the generators will create corresponding spec files with basic test structure.
208
+
209
+ ### Example Spec File
210
+
211
+ ```ruby
212
+ # spec/services/user/create_spec.rb
213
+ require "rails_helper"
214
+
215
+ RSpec.describe User::Create do
216
+ describe ".run" do
217
+ it "creates a user" do
218
+ service = described_class.run(...)
219
+ expect(service).to be_success
220
+ end
221
+ end
222
+ end
223
+ ```
224
+
225
+ You can skip spec file generation with the `--skip-spec` option:
226
+
227
+ ```bash
228
+ bin/rails generate light_services:service user/create --skip-spec
229
+ ```
230
+
231
+ ## Best Practices
232
+
233
+ 1. **Run the install generator first** - Always run `light_services:install` before creating individual services to set up the base `ApplicationService` class.
234
+
235
+ 2. **Use namespaces** - Organize related services under namespaces (e.g., `User::Create`, `Payment::Process`) to keep your services organized.
236
+
237
+ 3. **Start with structure** - Use `--args`, `--steps`, and `--outputs` options to create a skeleton for your service, then fill in the implementation.
238
+
239
+ 4. **Keep it simple** - Don't over-specify. If you're not sure about the exact steps, create a basic service and add them as you develop.
240
+
241
+ 5. **Follow conventions** - Use descriptive names for services that indicate the action being performed (e.g., `CreateOrder`, `User::Authenticate`, `Payment::Refund`).
242
+
243
+ ## Next Steps
244
+
245
+ After generating your services, learn more about:
246
+
247
+ - [Arguments](arguments.md) - Define and validate service inputs
248
+ - [Steps](steps.md) - Organize service logic into steps
249
+ - [Outputs](outputs.md) - Define service outputs
250
+ - [Testing](testing.md) - Write comprehensive tests for your services
data/docs/outputs.md ADDED
@@ -0,0 +1,135 @@
1
+ # Outputs
2
+
3
+ Outputs are the results of a service.
4
+
5
+ ## TL;DR
6
+
7
+ - Define outputs using the `output` keyword in the service class
8
+ - Outputs can have default values
9
+ - Outputs can be validated by type (validated when the service succeeds)
10
+
11
+ ## Define Outputs
12
+
13
+ You define outputs using the `output` keyword in the service class.
14
+
15
+ ```ruby
16
+ class AI::Chat < ApplicationService
17
+ output :messages
18
+ output :cost
19
+ end
20
+ ```
21
+
22
+ ## Write Outputs
23
+
24
+ Outputs function similarly to instance variables created with `attr_accessor`.
25
+
26
+ ```ruby
27
+ class AI::Chat < ApplicationService
28
+ # Steps
29
+ step :chat
30
+
31
+ # Outputs
32
+ output :messages
33
+ output :cost
34
+
35
+ private
36
+
37
+ def chat
38
+ self.messages = ["Hello!", "Hi, how are you?"]
39
+ self.cost = 0.0013
40
+ end
41
+ end
42
+ ```
43
+
44
+ To set outputs programmatically, use the `outputs.set` method or hash syntax.
45
+
46
+ ```ruby
47
+ class AI::Chat < ApplicationService
48
+ # ...
49
+
50
+ def chat
51
+ outputs.set(:messages, ["Hello!", "Hi, how are you?"])
52
+ outputs.set(:cost, 0.0013)
53
+
54
+ # Or use hash syntax
55
+
56
+ outputs[:messages] = ["Hello!", "Hi, how are you?"]
57
+ outputs[:cost] = 0.0013
58
+ end
59
+ end
60
+ ```
61
+
62
+ ## Type Validation
63
+
64
+ You can specify the type of output using the `type` option. The output type will be validated when the service successfully completes.
65
+
66
+ ```ruby
67
+ class AI::Chat < ApplicationService
68
+ output :messages, type: Array
69
+ output :cost, type: Float
70
+ end
71
+ ```
72
+
73
+ You can specify multiple allowed types using an array.
74
+
75
+ ```ruby
76
+ class AI::Chat < ApplicationService
77
+ output :result, type: [String, Hash]
78
+ end
79
+ ```
80
+
81
+ ### dry-types Support
82
+
83
+ Outputs also support [dry-types](https://dry-rb.org/gems/dry-types) for advanced type validation and coercion.
84
+
85
+ ```ruby
86
+ require "dry-types"
87
+
88
+ module Types
89
+ include Dry.Types()
90
+ end
91
+
92
+ class AI::Chat < ApplicationService
93
+ # Strict type validation
94
+ output :messages, type: Types::Strict::Array.of(Types::Hash)
95
+
96
+ # Coercible types - values are coerced on output validation
97
+ output :total_tokens, type: Types::Coercible::Integer
98
+
99
+ # Constrained types
100
+ output :cost, type: Types::Float.constrained(gteq: 0)
101
+ end
102
+ ```
103
+
104
+ ## Default Values
105
+
106
+ Set default values for outputs using the `default` option. The default value will be automatically set before the execution of steps.
107
+
108
+ ```ruby
109
+ class AI::Chat < ApplicationService
110
+ output :cost, default: 0.0
111
+ end
112
+ ```
113
+
114
+ ## Removing Inherited Outputs
115
+
116
+ When inheriting from a parent service, you can remove outputs using `remove_output`:
117
+
118
+ ```ruby
119
+ class BaseReportService < ApplicationService
120
+ output :report
121
+ output :debug_info
122
+ end
123
+
124
+ class ProductionReportService < BaseReportService
125
+ # Don't expose debug info in production
126
+ remove_output :debug_info
127
+ end
128
+ ```
129
+
130
+ ## What's Next?
131
+
132
+ Next, learn about context.
133
+
134
+ [Next: Context](context.md)
135
+