simple_command_dispatcher 4.0.0 β 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +45 -20
- data/README.md +408 -262
- data/lib/simple_command_dispatcher/commands/command_callable.rb +104 -0
- data/lib/simple_command_dispatcher/commands/errors.rb +83 -0
- data/lib/simple_command_dispatcher/commands/utils.rb +20 -0
- data/lib/simple_command_dispatcher/configuration.rb +25 -6
- data/lib/simple_command_dispatcher/helpers/camelize.rb +1 -1
- data/lib/simple_command_dispatcher/logger.rb +21 -0
- data/lib/simple_command_dispatcher/services/command_service.rb +31 -31
- data/lib/simple_command_dispatcher/services/options_service.rb +37 -0
- data/lib/simple_command_dispatcher/version.rb +1 -1
- data/lib/simple_command_dispatcher.rb +38 -6
- data/simple_command_dispatcher.gemspec +6 -7
- metadata +19 -14
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
[](https://github.com/gangelo/simple_command_dispatcher/actions/workflows/ruby.yml)
|
2
|
+
[](https://badge.fury.io/gh/gangelo%2Fsimple_command_dispatcher)
|
3
|
+
[](https://badge.fury.io/rb/simple_command_dispatcher)
|
4
4
|
[](http://www.rubydoc.info/gems/simple_command_dispatcher/)
|
5
5
|
[](http://www.rubydoc.info/gems/simple_command_dispatcher/)
|
6
6
|
[](https://github.com/gangelo/simple_command_dispatcher/issues)
|
@@ -12,15 +12,17 @@
|
|
12
12
|
|
13
13
|
**simple_command_dispatcher** (SCD) allows your Rails or Rails API application to _dynamically_ call backend command services from your Rails controller actions using a flexible, convention-over-configuration approach.
|
14
14
|
|
15
|
+
π **See it in action:** Check out the [demo application](https://github.com/gangelo/simple_command_dispatcher_demo_app) - a Rails API app with tests that demonstrate how to use the gem and its capabilities.
|
16
|
+
|
15
17
|
## Features
|
16
18
|
|
17
|
-
-
|
18
|
-
-
|
19
|
-
-
|
20
|
-
-
|
21
|
-
-
|
22
|
-
-
|
23
|
-
- π¦ **
|
19
|
+
- π οΈ **Convention Over Configuration**: Call commands dynamically from controller actions using action routes and parameters
|
20
|
+
- π **Command Standardization**: Optional `CommandCallable` module for consistent command interfaces with built-in success/failure tracking
|
21
|
+
- π **Dynamic Route-to-Command Mapping**: Automatically transforms request paths into Ruby class constants
|
22
|
+
- π **Intelligent Parameter Handling**: Supports Hash, Array, and single object parameters with automatic detection
|
23
|
+
- π **Flexible Input Formats**: Accepts strings, arrays, symbols with various separators and Unicode support
|
24
|
+
- β‘ **Performance Optimized**: Uses Rails' proven camelization methods for fast route-to-constant conversion
|
25
|
+
- π¦ **Lightweight**: Minimal dependencies - only ActiveSupport for reliable camelization
|
24
26
|
|
25
27
|
## Installation
|
26
28
|
|
@@ -40,40 +42,302 @@ Or install it yourself as:
|
|
40
42
|
|
41
43
|
## Requirements
|
42
44
|
|
43
|
-
- Ruby >= 3.
|
45
|
+
- Ruby >= 3.3.0
|
44
46
|
- Rails (optional, but optimized for Rails applications)
|
47
|
+
- Rails 8 compatible (tested with ActiveSupport 8.x)
|
48
|
+
|
49
|
+
## Quick Start
|
50
|
+
|
51
|
+
Here's a complete minimal example showing how to use the gem in a Rails controller:
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
# 1. Configure the gem (optional - uses Rails.logger by default)
|
55
|
+
# config/initializers/simple_command_dispatcher.rb
|
56
|
+
SimpleCommandDispatcher.configure do |config|
|
57
|
+
config.logger = Rails.logger
|
58
|
+
end
|
59
|
+
|
60
|
+
# 2. Create a command
|
61
|
+
# app/commands/api/v1/authenticate_user.rb
|
62
|
+
module Api
|
63
|
+
module V1
|
64
|
+
class AuthenticateUser
|
65
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
66
|
+
|
67
|
+
def call
|
68
|
+
user = User.find_by(email: email)
|
69
|
+
return nil unless user&.authenticate(password)
|
70
|
+
|
71
|
+
user
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def initialize(params = {})
|
77
|
+
@email = params[:email]
|
78
|
+
@password = params[:password]
|
79
|
+
end
|
80
|
+
|
81
|
+
attr_reader :email, :password
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# 3. Call the command from your controller
|
87
|
+
# app/controllers/api/v1/sessions_controller.rb
|
88
|
+
class Api::V1::SessionsController < ApplicationController
|
89
|
+
def create
|
90
|
+
command = SimpleCommandDispatcher.call(
|
91
|
+
command: request.path, # "/api/v1/authenticate_user"
|
92
|
+
request_params: params
|
93
|
+
)
|
94
|
+
|
95
|
+
if command.success?
|
96
|
+
render json: { user: command.result }, status: :ok
|
97
|
+
else
|
98
|
+
render json: { errors: command.errors }, status: :unauthorized
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
```
|
45
103
|
|
46
104
|
## Basic Usage
|
47
105
|
|
48
106
|
### Simple Command Dispatch
|
49
107
|
|
50
108
|
```ruby
|
51
|
-
# Basic command
|
52
|
-
|
109
|
+
# Basic command calls - all equivalent
|
110
|
+
|
111
|
+
command = SimpleCommandDispatcher.call(
|
112
|
+
command: '/api/v1/authenticate_user',
|
113
|
+
# No `command_namespace:` param
|
114
|
+
request_params: { email: 'user@example.com', password: 'secret' }
|
115
|
+
)
|
116
|
+
|
117
|
+
command = SimpleCommandDispatcher.call(
|
118
|
+
command: :authenticate_user,
|
119
|
+
command_namespace: '/api/v1',
|
120
|
+
request_params: { email: 'user@example.com', password: 'secret' }
|
121
|
+
)
|
122
|
+
|
123
|
+
command = SimpleCommandDispatcher.call(
|
53
124
|
command: 'AuthenticateUser',
|
54
|
-
command_namespace:
|
125
|
+
command_namespace: %w[api v1],
|
55
126
|
request_params: { email: 'user@example.com', password: 'secret' }
|
56
127
|
)
|
57
128
|
|
58
|
-
#
|
129
|
+
# With debug logging enabled
|
130
|
+
command = SimpleCommandDispatcher.call(
|
131
|
+
command: '/api/v1/authenticate_user',
|
132
|
+
request_params: { email: 'user@example.com', password: 'secret' },
|
133
|
+
options: { debug: true } # Enables detailed debug logging
|
134
|
+
)
|
135
|
+
|
136
|
+
# All the above will execute: Api::V1::AuthenticateUser.call(email: 'user@example.com', password: 'secret')
|
137
|
+
```
|
138
|
+
|
139
|
+
## Command Standardization with CommandCallable
|
140
|
+
|
141
|
+
The gem includes a powerful `CommandCallable` module that standardizes your command classes, providing automatic success/failure tracking, error handling, and a consistent interface. This module is completely optional but highly recommended for building robust, maintainable commands.
|
142
|
+
|
143
|
+
### The Real Power: Dynamic Command Execution using convention over configuration
|
144
|
+
|
145
|
+
Where this gem truly shines is its ability to **dynamically execute commands** using a **convention over configuration** approach. Command names and namespacing match controller action routes, making it possible to dynamically execute commands based on controller/action routes and pass arguments dynamically using params.
|
146
|
+
|
147
|
+
Here's how it works with a real controller example:
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
# app/controllers/api/mechs_controller.rb
|
151
|
+
class Api::MechsController < ApplicationController
|
152
|
+
before_action :route_request, except: [:destroy, :index]
|
153
|
+
|
154
|
+
def index
|
155
|
+
render json: { mechs: Mech.all }
|
156
|
+
end
|
157
|
+
|
158
|
+
def search
|
159
|
+
# Action intentionally left empty, routing handled by before_action
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def route_request
|
165
|
+
command = SimpleCommandDispatcher.call(
|
166
|
+
command: request.path, # "/api/v1/mechs/search"
|
167
|
+
# No need to use the `command_namespace` param, since the command namespace
|
168
|
+
# can be gleaned directly from `command: request.path`.
|
169
|
+
request_params: params # Full Rails params hash
|
170
|
+
)
|
171
|
+
|
172
|
+
if command.success?
|
173
|
+
render json: { mechs: command.result }, status: :ok
|
174
|
+
else
|
175
|
+
render json: { errors: command.errors }, status: :unprocessable_entity
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
```
|
180
|
+
|
181
|
+
**The Convention:** Request path `/api/v1/mechs/search` automatically maps to command class `Api::V1::Mechs::Search`.
|
182
|
+
|
183
|
+
**Alternative approach** for handling nested resource routes with dynamic actions:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
# Handle routes like: /api/v1/mechs/123/variants/456/update
|
187
|
+
# Extract resource action and build namespace from nested resources
|
188
|
+
path_parts = request.path.split("/")
|
189
|
+
action = path_parts.last # "update"
|
190
|
+
resource_path = path_parts[0...-1] # ["/api", "v1", "mechs", "123", "variants", "456"]
|
191
|
+
|
192
|
+
# Build namespace from resource path, filtering out IDs
|
193
|
+
namespace_parts = resource_path.select { |part| !part.match?(/^\d+$/) }
|
194
|
+
|
195
|
+
command = SimpleCommandDispatcher.call(
|
196
|
+
command: action, # "update"
|
197
|
+
command_namespace: namespace_parts, # ["/api", "v1", "mechs", "variants"]
|
198
|
+
request_params: params.merge(
|
199
|
+
mech_id: path_parts[4], # "123"
|
200
|
+
variant_id: path_parts[6] # "456"
|
201
|
+
)
|
202
|
+
)
|
203
|
+
# Calls: Api::V1::Mechs::Variants::Update.call(mech_id: "123", variant_id: "456", ...)
|
204
|
+
```
|
205
|
+
|
206
|
+
### Versioned Command Examples
|
207
|
+
|
208
|
+
```ruby
|
209
|
+
# app/commands/api/v1/mechs/search.rb
|
210
|
+
class Api::V1::Mechs::Search
|
211
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
212
|
+
|
213
|
+
def call
|
214
|
+
# V1 search logic - simple name search
|
215
|
+
name.present? ? Mech.where("mech_name ILIKE ?", "%#{name}%") : Mech.none
|
216
|
+
end
|
217
|
+
|
218
|
+
private
|
219
|
+
|
220
|
+
def initialize(params = {})
|
221
|
+
@name = params[:name]
|
222
|
+
end
|
223
|
+
|
224
|
+
attr_reader :name
|
225
|
+
end
|
226
|
+
|
227
|
+
# app/commands/api/v2/mechs/search.rb
|
228
|
+
class Api::V2::Mechs::Search
|
229
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
230
|
+
|
231
|
+
def call
|
232
|
+
# V2 search logic - comprehensive search using scopes
|
233
|
+
Mech.by_cost(cost)
|
234
|
+
.or(Mech.by_introduction_year(introduction_year))
|
235
|
+
.or(Mech.by_mech_name(mech_name))
|
236
|
+
.or(Mech.by_tonnage(tonnage))
|
237
|
+
.or(Mech.by_variant(variant))
|
238
|
+
end
|
239
|
+
|
240
|
+
private
|
241
|
+
|
242
|
+
def initialize(params = {})
|
243
|
+
@cost = params[:cost]
|
244
|
+
@introduction_year = params[:introduction_year]
|
245
|
+
@mech_name = params[:mech_name]
|
246
|
+
@tonnage = params[:tonnage]
|
247
|
+
@variant = params[:variant]
|
248
|
+
end
|
249
|
+
|
250
|
+
attr_reader :cost, :introduction_year, :mech_name, :tonnage, :variant
|
251
|
+
end
|
252
|
+
|
253
|
+
# app/models/mech.rb (V2 scopes)
|
254
|
+
class Mech < ApplicationRecord
|
255
|
+
scope :by_mech_name, ->(name) {
|
256
|
+
name.present? ? where("mech_name ILIKE ?", "%#{name}%") : none
|
257
|
+
}
|
258
|
+
|
259
|
+
scope :by_variant, ->(variant) {
|
260
|
+
variant.present? ? where("variant ILIKE ?", "%#{variant}%") : none
|
261
|
+
}
|
262
|
+
|
263
|
+
scope :by_tonnage, ->(tonnage) {
|
264
|
+
tonnage.present? ? where(tonnage: tonnage) : none
|
265
|
+
}
|
266
|
+
|
267
|
+
scope :by_cost, ->(cost) {
|
268
|
+
cost.present? ? where(cost: cost) : none
|
269
|
+
}
|
270
|
+
|
271
|
+
scope :by_introduction_year, ->(year) {
|
272
|
+
year.present? ? where(introduction_year: year) : none
|
273
|
+
}
|
274
|
+
end
|
275
|
+
```
|
276
|
+
|
277
|
+
**The Magic:** By convention, routes automatically map to commands:
|
278
|
+
|
279
|
+
- `/api/v1/mechs/search` β `Api::V1::Mechs::Search`
|
280
|
+
- `/api/v2/mechs/search` β `Api::V2::Mechs::Search`
|
281
|
+
|
282
|
+
### What CommandCallable Provides
|
283
|
+
|
284
|
+
When you prepend `CommandCallable` to your command class, you automatically get:
|
285
|
+
|
286
|
+
1. **Class Method Generation**: Automatic `.call` class method that instantiates and calls your command
|
287
|
+
2. **Result Tracking**: Your command's return value is stored in `command.result`
|
288
|
+
3. **Success/Failure Methods**: `success?` and `failure?` methods based on error state
|
289
|
+
4. **Error Handling**: Built-in `errors` object for consistent error management
|
290
|
+
5. **Call Tracking**: Internal tracking to ensure methods work correctly
|
291
|
+
|
292
|
+
**Important:** The `.call` class method returns the command instance itself (not the raw result). Access the actual return value via `.result`:
|
293
|
+
|
294
|
+
```ruby
|
295
|
+
command = AuthenticateUser.call(email: 'user@example.com', password: 'secret')
|
296
|
+
command.success? # => true/false
|
297
|
+
command.result # => the actual User object (or whatever your call method returned)
|
298
|
+
command.errors # => errors collection if any
|
299
|
+
```
|
300
|
+
|
301
|
+
**Best Practice:** Make `initialize` private when using `CommandCallable`. This enforces the use of the `.call` class method and ensures proper success/failure tracking. Making `initialize` private prevents direct instantiation that would bypass CommandCallable's functionality:
|
302
|
+
|
303
|
+
```ruby
|
304
|
+
class YourCommand
|
305
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
306
|
+
|
307
|
+
def call
|
308
|
+
# Your logic here
|
309
|
+
end
|
310
|
+
|
311
|
+
private # <- initialize should be private
|
312
|
+
|
313
|
+
def initialize(params = {})
|
314
|
+
@params = params
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# This works (correct pattern):
|
319
|
+
YourCommand.call(foo: 'bar')
|
320
|
+
|
321
|
+
# This raises NoMethodError (prevents bypassing CommandCallable):
|
322
|
+
YourCommand.new(foo: 'bar')
|
59
323
|
```
|
60
324
|
|
61
|
-
###
|
325
|
+
### Convention Over Configuration: Route-to-Command Mapping
|
62
326
|
|
63
|
-
|
327
|
+
The gem automatically transforms route paths into Ruby class constants using intelligent camelization, allowing flexible input formats:
|
64
328
|
|
65
329
|
```ruby
|
66
330
|
# All of these are equivalent and call: Api::UserSessions::V1::CreateCommand.call
|
67
331
|
|
68
332
|
# Lowercase strings with various separators
|
69
333
|
SimpleCommandDispatcher.call(
|
70
|
-
command:
|
334
|
+
command: :create_command,
|
71
335
|
command_namespace: 'api::user_sessions::v1'
|
72
336
|
)
|
73
337
|
|
74
338
|
# Mixed case array
|
75
339
|
SimpleCommandDispatcher.call(
|
76
|
-
command:
|
340
|
+
command: 'CreateCommand',
|
77
341
|
command_namespace: ['api', 'UserSessions', 'v1']
|
78
342
|
)
|
79
343
|
|
@@ -90,7 +354,7 @@ SimpleCommandDispatcher.call(
|
|
90
354
|
)
|
91
355
|
```
|
92
356
|
|
93
|
-
The
|
357
|
+
The transformation handles Unicode characters and removes all whitespace:
|
94
358
|
|
95
359
|
```ruby
|
96
360
|
# Unicode support
|
@@ -101,219 +365,78 @@ SimpleCommandDispatcher.call(
|
|
101
365
|
# Calls: Api::CafΓ©::V1::CafΓ©Command.call
|
102
366
|
```
|
103
367
|
|
104
|
-
### Parameter Handling
|
368
|
+
### Dynamic Parameter Handling
|
105
369
|
|
106
|
-
The dispatcher
|
370
|
+
The dispatcher intelligently handles different parameter types based on how your command initializer is coded:
|
107
371
|
|
108
372
|
```ruby
|
109
|
-
# Hash
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
request_params: { name: 'John', email: 'john@example.com' }
|
114
|
-
)
|
115
|
-
# Calls: Api::V1::CreateUser.call(name: 'John', email: 'john@example.com')
|
373
|
+
# Hash params β keyword arguments
|
374
|
+
def initialize(name:, email:) # kwargs
|
375
|
+
# Called with: YourCommand.call(name: 'John', email: 'john@example.com')
|
376
|
+
end
|
116
377
|
|
117
|
-
#
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
request_params: ['data1', 'data2', 'data3']
|
122
|
-
)
|
123
|
-
# Calls: Services::ProcessData.call('data1', 'data2', 'data3')
|
378
|
+
# Hash params β single hash argument
|
379
|
+
def initialize(params = {}) # single hash
|
380
|
+
# Called with: YourCommand.call({name: 'John', email: 'john@example.com'})
|
381
|
+
end
|
124
382
|
|
125
|
-
#
|
126
|
-
|
127
|
-
|
128
|
-
command_namespace: 'Mailers',
|
129
|
-
request_params: 'user@example.com'
|
130
|
-
)
|
131
|
-
# Calls: Mailers::SendEmail.call('user@example.com')
|
383
|
+
# Array params β positional arguments
|
384
|
+
request_params: ['arg1', 'arg2', 'arg3']
|
385
|
+
# Called with: YourCommand.call('arg1', 'arg2', 'arg3')
|
132
386
|
|
133
|
-
#
|
134
|
-
|
135
|
-
|
136
|
-
command_namespace: 'System'
|
137
|
-
)
|
138
|
-
# Calls: System::HealthCheck.call
|
387
|
+
# Single param β single argument
|
388
|
+
request_params: 'single_value'
|
389
|
+
# Called with: YourCommand.call('single_value')
|
139
390
|
```
|
140
391
|
|
141
|
-
|
142
|
-
|
143
|
-
Here's a comprehensive example showing how to integrate SCD with a Rails API application:
|
144
|
-
|
145
|
-
### Application Controller
|
392
|
+
### Payment Processing Example
|
146
393
|
|
147
394
|
```ruby
|
148
|
-
# app/
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
path_segments.take(3).join('/')
|
395
|
+
# app/commands/api/v1/payments/process.rb
|
396
|
+
class Api::V1::Payments::Process
|
397
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
398
|
+
|
399
|
+
def call
|
400
|
+
validate_payment_data
|
401
|
+
return nil if errors.any?
|
402
|
+
|
403
|
+
charge_card
|
404
|
+
rescue StandardError => e
|
405
|
+
errors.add(:payment, e.message)
|
406
|
+
nil
|
161
407
|
end
|
162
408
|
|
163
409
|
private
|
164
410
|
|
165
|
-
def
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
request_params: { headers: request.headers }
|
170
|
-
)
|
171
|
-
|
172
|
-
if result.success?
|
173
|
-
@current_user = result.user
|
174
|
-
else
|
175
|
-
render json: { error: 'Not Authorized' }, status: 401
|
176
|
-
end
|
411
|
+
def initialize(params = {})
|
412
|
+
@amount = params[:amount]
|
413
|
+
@card_token = params[:card_token]
|
414
|
+
@user_id = params[:user_id]
|
177
415
|
end
|
178
|
-
end
|
179
|
-
```
|
180
416
|
|
181
|
-
|
417
|
+
attr_reader :amount, :card_token, :user_id
|
182
418
|
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
result = SimpleCommandDispatcher.call(
|
188
|
-
command: 'CreateUser',
|
189
|
-
command_namespace: get_command_namespace,
|
190
|
-
request_params: user_params
|
191
|
-
)
|
192
|
-
|
193
|
-
if result.success?
|
194
|
-
render json: result.user, status: :ok
|
195
|
-
else
|
196
|
-
render json: { errors: result.errors }, status: :unprocessable_entity
|
197
|
-
end
|
419
|
+
def validate_payment_data
|
420
|
+
errors.add(:amount, 'must be positive') if amount.to_i <= 0
|
421
|
+
errors.add(:card_token, 'is required') if card_token.blank?
|
422
|
+
errors.add(:user_id, 'is required') if user_id.blank?
|
198
423
|
end
|
199
424
|
|
200
|
-
def
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
425
|
+
def charge_card
|
426
|
+
PaymentProcessor.charge(
|
427
|
+
amount: amount,
|
428
|
+
card_token: card_token,
|
429
|
+
user_id: user_id
|
205
430
|
)
|
206
|
-
|
207
|
-
if result.success?
|
208
|
-
render json: result.user
|
209
|
-
else
|
210
|
-
render json: { errors: result.errors }, status: :unprocessable_entity
|
211
|
-
end
|
212
|
-
end
|
213
|
-
|
214
|
-
private
|
215
|
-
|
216
|
-
def user_params
|
217
|
-
params.require(:user).permit(:name, :email, :phone)
|
218
431
|
end
|
219
432
|
end
|
220
433
|
```
|
221
434
|
|
222
|
-
|
435
|
+
**Route:** `POST /api/v1/payments/process` automatically calls `Api::V1::Payments::Process.call(params)`
|
223
436
|
|
224
|
-
|
225
|
-
# app/commands/api/my_app/v1/authenticate_request.rb
|
226
|
-
module Api
|
227
|
-
module MyApp
|
228
|
-
module V1
|
229
|
-
class AuthenticateRequest
|
230
|
-
def self.call(headers:)
|
231
|
-
new(headers: headers).call
|
232
|
-
end
|
233
|
-
|
234
|
-
def initialize(headers:)
|
235
|
-
@headers = headers
|
236
|
-
end
|
237
|
-
|
238
|
-
def call
|
239
|
-
user = authenticate_with_token
|
240
|
-
if user
|
241
|
-
OpenStruct.new(success?: true, user: user)
|
242
|
-
else
|
243
|
-
OpenStruct.new(success?: false, errors: ['Invalid token'])
|
244
|
-
end
|
245
|
-
end
|
246
|
-
|
247
|
-
private
|
248
|
-
|
249
|
-
attr_reader :headers
|
250
|
-
|
251
|
-
def authenticate_with_token
|
252
|
-
token = headers['Authorization']&.gsub('Bearer ', '')
|
253
|
-
return nil unless token
|
254
|
-
|
255
|
-
# Your authentication logic here
|
256
|
-
User.find_by(auth_token: token)
|
257
|
-
end
|
258
|
-
end
|
259
|
-
end
|
260
|
-
end
|
261
|
-
end
|
262
|
-
```
|
437
|
+
### Custom Commands
|
263
438
|
|
264
|
-
|
265
|
-
# app/commands/api/my_app/v1/create_user.rb
|
266
|
-
module Api
|
267
|
-
module MyApp
|
268
|
-
module V1
|
269
|
-
class CreateUser
|
270
|
-
def self.call(**params)
|
271
|
-
new(**params).call
|
272
|
-
end
|
273
|
-
|
274
|
-
def initialize(name:, email:, phone: nil)
|
275
|
-
@name = name
|
276
|
-
@email = email
|
277
|
-
@phone = phone
|
278
|
-
end
|
279
|
-
|
280
|
-
def call
|
281
|
-
user = User.new(name: name, email: email, phone: phone)
|
282
|
-
|
283
|
-
if user.save
|
284
|
-
OpenStruct.new(success?: true, user: user)
|
285
|
-
else
|
286
|
-
OpenStruct.new(success?: false, errors: user.errors.full_messages)
|
287
|
-
end
|
288
|
-
end
|
289
|
-
|
290
|
-
private
|
291
|
-
|
292
|
-
attr_reader :name, :email, :phone
|
293
|
-
end
|
294
|
-
end
|
295
|
-
end
|
296
|
-
end
|
297
|
-
```
|
298
|
-
|
299
|
-
### Autoloading Commands
|
300
|
-
|
301
|
-
To ensure your command classes are properly loaded:
|
302
|
-
|
303
|
-
```ruby
|
304
|
-
# config/initializers/simple_command_dispatcher.rb
|
305
|
-
|
306
|
-
# Autoload command classes
|
307
|
-
Rails.application.config.to_prepare do
|
308
|
-
commands_path = Rails.root.join('app', 'commands')
|
309
|
-
|
310
|
-
if commands_path.exist?
|
311
|
-
Dir[commands_path.join('**', '*.rb')].each do |file|
|
312
|
-
require_dependency file
|
313
|
-
end
|
314
|
-
end
|
315
|
-
end
|
316
|
-
```
|
439
|
+
You can create your own command classes without `CommandCallable`. Just ensure your command responds to the `.call` class method and returns whatever structure you need. The dispatcher will call your command and return the result - your convention, your rules.
|
317
440
|
|
318
441
|
## Error Handling
|
319
442
|
|
@@ -321,7 +444,7 @@ The dispatcher provides specific error classes for different failure scenarios:
|
|
321
444
|
|
322
445
|
```ruby
|
323
446
|
begin
|
324
|
-
|
447
|
+
command = SimpleCommandDispatcher.call(
|
325
448
|
command: 'NonExistentCommand',
|
326
449
|
command_namespace: 'Api::V1'
|
327
450
|
)
|
@@ -337,81 +460,104 @@ rescue ArgumentError => e
|
|
337
460
|
end
|
338
461
|
```
|
339
462
|
|
340
|
-
##
|
341
|
-
|
342
|
-
### Route-Based Command Dispatch
|
463
|
+
## Configuration
|
343
464
|
|
344
|
-
|
465
|
+
The gem can be configured in an initializer:
|
345
466
|
|
346
467
|
```ruby
|
347
|
-
#
|
348
|
-
|
349
|
-
#
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
SimpleCommandDispatcher.call(
|
356
|
-
command: "#{command}_#{controller_name.singularize}", # "create_user"
|
357
|
-
command_namespace: namespace,
|
358
|
-
request_params: request_params
|
359
|
-
)
|
468
|
+
# config/initializers/simple_command_dispatcher.rb
|
469
|
+
SimpleCommandDispatcher.configure do |config|
|
470
|
+
# Configure the logger (defaults to Rails.logger in Rails apps, or Logger.new($stdout) otherwise)
|
471
|
+
config.logger = Rails.logger
|
472
|
+
|
473
|
+
# Or use a custom logger
|
474
|
+
# config.logger = Logger.new('log/commands.log')
|
360
475
|
end
|
361
476
|
```
|
362
477
|
|
363
|
-
###
|
478
|
+
### Using Configuration in Commands
|
479
|
+
|
480
|
+
You can access the configured logger within your commands to add custom logging:
|
364
481
|
|
365
482
|
```ruby
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
483
|
+
class Api::V1::Payments::Process
|
484
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
485
|
+
|
486
|
+
def call
|
487
|
+
logger.info("Processing payment for user #{user_id}")
|
488
|
+
|
489
|
+
validate_payment_data
|
490
|
+
return nil if errors.any?
|
491
|
+
|
492
|
+
result = charge_card
|
493
|
+
logger.info("Payment successful: #{result.inspect}")
|
494
|
+
result
|
495
|
+
rescue StandardError => e
|
496
|
+
logger.error("Payment failed: #{e.message}")
|
497
|
+
errors.add(:payment, e.message)
|
498
|
+
nil
|
499
|
+
end
|
374
500
|
|
375
|
-
|
376
|
-
result = call_versioned_command('authenticate_user', 'v2')
|
377
|
-
```
|
501
|
+
private
|
378
502
|
|
379
|
-
|
503
|
+
def initialize(params = {})
|
504
|
+
@amount = params[:amount]
|
505
|
+
@card_token = params[:card_token]
|
506
|
+
@user_id = params[:user_id]
|
507
|
+
end
|
380
508
|
|
381
|
-
|
382
|
-
|
383
|
-
def
|
384
|
-
|
385
|
-
{ command: 'validate_user', params: user_data },
|
386
|
-
{ command: 'create_user', params: user_data },
|
387
|
-
{ command: 'send_welcome_email', params: { email: user_data[:email] } }
|
388
|
-
]
|
389
|
-
|
390
|
-
results = commands.map do |cmd|
|
391
|
-
SimpleCommandDispatcher.call(
|
392
|
-
command: cmd[:command],
|
393
|
-
command_namespace: 'user_registration',
|
394
|
-
request_params: cmd[:params]
|
395
|
-
)
|
509
|
+
attr_reader :amount, :card_token, :user_id
|
510
|
+
|
511
|
+
def logger
|
512
|
+
SimpleCommandDispatcher.configuration.logger
|
396
513
|
end
|
397
514
|
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
515
|
+
def validate_payment_data
|
516
|
+
errors.add(:amount, 'must be positive') if amount.to_i <= 0
|
517
|
+
errors.add(:card_token, 'is required') if card_token.blank?
|
518
|
+
errors.add(:user_id, 'is required') if user_id.blank?
|
519
|
+
end
|
520
|
+
|
521
|
+
def charge_card
|
522
|
+
PaymentProcessor.charge(
|
523
|
+
amount: amount,
|
524
|
+
card_token: card_token,
|
525
|
+
user_id: user_id
|
526
|
+
)
|
403
527
|
end
|
404
528
|
end
|
405
529
|
```
|
406
530
|
|
407
|
-
|
531
|
+
### Debug Logging
|
408
532
|
|
409
|
-
The gem can be
|
533
|
+
The gem includes built-in debug logging that can be enabled using the `debug` option. This is useful for debugging command execution flow:
|
410
534
|
|
411
535
|
```ruby
|
412
|
-
#
|
536
|
+
# Enable debug logging for a single command
|
537
|
+
command = SimpleCommandDispatcher.call(
|
538
|
+
command: :authenticate_user,
|
539
|
+
command_namespace: '/api/v1',
|
540
|
+
request_params: { email: 'user@example.com', password: 'secret' },
|
541
|
+
options: { debug: true }
|
542
|
+
)
|
543
|
+
|
544
|
+
# Debug logging outputs:
|
545
|
+
# - Begin dispatching command (with command and namespace details)
|
546
|
+
# - Command to execute (the fully qualified class name)
|
547
|
+
# - Constantized command (the actual class constant)
|
548
|
+
# - End dispatching command
|
549
|
+
```
|
550
|
+
|
551
|
+
**Important:** Debug logging mode **does not** skip executionβit still runs your command and returns real results, but with detailed debug output to help you understand what's happening internally.
|
552
|
+
|
553
|
+
**Configure logging level:**
|
554
|
+
|
555
|
+
```ruby
|
556
|
+
# In your Rails initializer or application setup
|
413
557
|
SimpleCommandDispatcher.configure do |config|
|
414
|
-
|
558
|
+
logger = Logger.new($stdout)
|
559
|
+
logger.level = Logger::DEBUG # Set appropriate level
|
560
|
+
config.logger = logger
|
415
561
|
end
|
416
562
|
```
|
417
563
|
|