simple_command_dispatcher 4.0.0 → 4.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/CHANGELOG.md +25 -0
- data/Gemfile.lock +2 -2
- data/README.md +207 -273
- data/lib/simple_command_dispatcher/commands/command_callable.rb +52 -0
- data/lib/simple_command_dispatcher/commands/errors.rb +44 -0
- data/lib/simple_command_dispatcher/commands/utils.rb +20 -0
- data/lib/simple_command_dispatcher/version.rb +1 -1
- data/lib/simple_command_dispatcher.rb +1 -0
- data/simple_command_dispatcher.gemspec +6 -7
- metadata +15 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 78aeec50e25d248707c465097b003bf35eb4f6783c7405f68ea08264a1e655a3
|
4
|
+
data.tar.gz: 3d1333a439993ac0ad274c87d4244aadada69bcc7ddb5b064f34847b43396e46
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c8c0b2631ad96c502f878df531e41f04a11ef92d11f059303fc4be3ad18afcd7f927c50cc9978f4c63e14f3026e03b7fa78529025a18d99f4bf31e4919152a3f
|
7
|
+
data.tar.gz: fe7cb001f4315809acd6699f40e199b864e9e0c88da78ac84ba806f989309367a54e1af71ce7fa490c5cca946a391498bd1ef56e3c2f1a2af0374efa45dbf853
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,30 @@
|
|
1
1
|
# CHANGELOG
|
2
2
|
|
3
|
+
## Version 4.1.0 - 2025-07-14
|
4
|
+
|
5
|
+
- **New Feature: CommandCallable Module**:
|
6
|
+
|
7
|
+
- Introduced `SimpleCommandDispatcher::Commands::CommandCallable` module for standardizing command classes
|
8
|
+
- Provides automatic `.call` class method generation that instantiates and calls your command
|
9
|
+
- Built-in success/failure tracking with `success?` and `failure?` methods based on error state
|
10
|
+
- Automatic result tracking - command return values stored in `command.result`
|
11
|
+
- Consistent error handling with built-in `errors` object for error collection and management
|
12
|
+
- Call tracking to ensure methods work correctly and commands are properly executed
|
13
|
+
- Completely optional but recommended for building robust, maintainable commands
|
14
|
+
|
15
|
+
- **Enhanced Documentation**:
|
16
|
+
|
17
|
+
- Major README.md overhaul with real-world examples showcasing dynamic command execution
|
18
|
+
- Added comprehensive examples demonstrating convention over configuration approach
|
19
|
+
- Included versioned API command examples (V1 vs V2) showing practical usage patterns
|
20
|
+
- Added controller examples showing how to use `request.path` and `params` for dynamic routing
|
21
|
+
- Enhanced payment processing example with proper error handling and rescue patterns
|
22
|
+
- Added efficient database query examples using ActiveRecord scopes
|
23
|
+
- Improved parameter handling documentation showing kwargs vs single hash approaches
|
24
|
+
- Added alternative command splitting approach for more granular control
|
25
|
+
- Updated all examples to use `command` variable instead of `result` for clarity
|
26
|
+
- Added custom command guidance for users who prefer to roll their own implementations
|
27
|
+
|
3
28
|
## Version 4.0.0 - 2025-07-12
|
4
29
|
|
5
30
|
- **Documentation Overhaul**:
|
data/Gemfile.lock
CHANGED
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,7 +42,7 @@ 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)
|
45
47
|
|
46
48
|
## Basic Usage
|
@@ -49,31 +51,171 @@ Or install it yourself as:
|
|
49
51
|
|
50
52
|
```ruby
|
51
53
|
# Basic command call
|
52
|
-
|
54
|
+
command = SimpleCommandDispatcher.call(
|
53
55
|
command: 'AuthenticateUser',
|
54
56
|
command_namespace: 'Api::V1',
|
55
57
|
request_params: { email: 'user@example.com', password: 'secret' }
|
56
58
|
)
|
57
59
|
|
58
|
-
# This
|
60
|
+
# This executes: Api::V1::AuthenticateUser.call(email: 'user@example.com', password: 'secret')
|
61
|
+
```
|
62
|
+
|
63
|
+
## Command Standardization with CommandCallable
|
64
|
+
|
65
|
+
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.
|
66
|
+
|
67
|
+
### The Real Power: Dynamic Command Execution using convention over configuration
|
68
|
+
|
69
|
+
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.
|
70
|
+
|
71
|
+
Here's how it works with a real controller example:
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
# app/controllers/api/mechs_controller.rb
|
75
|
+
class Api::MechsController < ApplicationController
|
76
|
+
before_action :route_request, except: [:index]
|
77
|
+
|
78
|
+
def index
|
79
|
+
render json: { mechs: Mech.all }
|
80
|
+
end
|
81
|
+
|
82
|
+
def search
|
83
|
+
# Action intentionally left empty, routing handled by before_action
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def route_request
|
89
|
+
command = SimpleCommandDispatcher.call(
|
90
|
+
command: request.path, # "/api/v1/mechs/search"
|
91
|
+
command_namespace: nil, # nil since the command namespace can be gleaned directly from `command: request.path`
|
92
|
+
request_params: params # Full Rails params hash
|
93
|
+
)
|
94
|
+
|
95
|
+
if command.success?
|
96
|
+
render json: { mechs: command.result }, status: :ok
|
97
|
+
else
|
98
|
+
render json: { errors: command.errors }, status: :unprocessable_entity
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
```
|
103
|
+
|
104
|
+
**The Convention:** Request path `/api/v1/mechs/search` automatically maps to command class `Api::V1::Mechs::Search`
|
105
|
+
|
106
|
+
**Alternative approach** if you need more control over command name and namespace:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
# Split the path manually
|
110
|
+
command = SimpleCommandDispatcher.call(
|
111
|
+
command: request.path.split("/").last, # "search"
|
112
|
+
command_namespace: request.path.split("/")[0..2], # "/api/v1/mechs"
|
113
|
+
request_params: params
|
114
|
+
)
|
115
|
+
```
|
116
|
+
|
117
|
+
### Versioned Command Examples
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
# app/commands/api/v1/mechs/search.rb
|
121
|
+
class Api::V1::Mechs::Search
|
122
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
123
|
+
|
124
|
+
def initialize(params = {})
|
125
|
+
@name = params[:name]
|
126
|
+
end
|
127
|
+
|
128
|
+
def call
|
129
|
+
# V1 search logic - simple name search
|
130
|
+
name.present? ? Mech.where("mech_name ILIKE ?", "%#{name}%") : Mech.none
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
attr_reader :name
|
136
|
+
end
|
137
|
+
|
138
|
+
# app/commands/api/v2/mechs/search.rb
|
139
|
+
class Api::V2::Mechs::Search
|
140
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
141
|
+
|
142
|
+
def initialize(params = {})
|
143
|
+
@cost = params[:cost]
|
144
|
+
@introduction_year = params[:introduction_year]
|
145
|
+
@mech_name = params[:mech_name]
|
146
|
+
@tonnage = params[:tonnage]
|
147
|
+
@variant = params[:variant]
|
148
|
+
end
|
149
|
+
|
150
|
+
def call
|
151
|
+
# V2 search logic - comprehensive search using scopes
|
152
|
+
Mech.by_cost(cost)
|
153
|
+
.or(Mech.by_introduction_year(introduction_year))
|
154
|
+
.or(Mech.by_mech_name(mech_name))
|
155
|
+
.or(Mech.by_tonnage(tonnage))
|
156
|
+
.or(Mech.by_variant(variant))
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
|
161
|
+
attr_reader :cost, :introduction_year, :mech_name, :tonnage, :variant
|
162
|
+
end
|
163
|
+
|
164
|
+
# app/models/mech.rb (V2 scopes)
|
165
|
+
class Mech < ApplicationRecord
|
166
|
+
scope :by_mech_name, ->(name) {
|
167
|
+
name.present? ? where("mech_name ILIKE ?", "%#{name}%") : none
|
168
|
+
}
|
169
|
+
|
170
|
+
scope :by_variant, ->(variant) {
|
171
|
+
variant.present? ? where("variant ILIKE ?", "%#{variant}%") : none
|
172
|
+
}
|
173
|
+
|
174
|
+
scope :by_tonnage, ->(tonnage) {
|
175
|
+
tonnage.present? ? where(tonnage: tonnage) : none
|
176
|
+
}
|
177
|
+
|
178
|
+
scope :by_cost, ->(cost) {
|
179
|
+
cost.present? ? where(cost: cost) : none
|
180
|
+
}
|
181
|
+
|
182
|
+
scope :by_introduction_year, ->(year) {
|
183
|
+
year.present? ? where(introduction_year: year) : none
|
184
|
+
}
|
185
|
+
end
|
59
186
|
```
|
60
187
|
|
61
|
-
|
188
|
+
**The Magic:** By convention, routes automatically map to commands:
|
189
|
+
|
190
|
+
- `/api/v1/mechs/search` → `Api::V1::Mechs::Search`
|
191
|
+
- `/api/v2/mechs/search` → `Api::V2::Mechs::Search`
|
192
|
+
|
193
|
+
### What CommandCallable Provides
|
194
|
+
|
195
|
+
When you prepend `CommandCallable` to your command class, you automatically get:
|
62
196
|
|
63
|
-
|
197
|
+
1. **Class Method Generation**: Automatic `.call` class method that instantiates and calls your command
|
198
|
+
2. **Result Tracking**: Your command's return value is stored in `command.result`
|
199
|
+
3. **Success/Failure Methods**: `success?` and `failure?` methods based on error state
|
200
|
+
4. **Error Handling**: Built-in `errors` object for consistent error management
|
201
|
+
5. **Call Tracking**: Internal tracking to ensure methods work correctly
|
202
|
+
|
203
|
+
### Convention Over Configuration: Route-to-Command Mapping
|
204
|
+
|
205
|
+
The gem automatically transforms route paths into Ruby class constants using intelligent camelization, allowing flexible input formats:
|
64
206
|
|
65
207
|
```ruby
|
66
208
|
# All of these are equivalent and call: Api::UserSessions::V1::CreateCommand.call
|
67
209
|
|
68
210
|
# Lowercase strings with various separators
|
69
211
|
SimpleCommandDispatcher.call(
|
70
|
-
command:
|
212
|
+
command: :create_command,
|
71
213
|
command_namespace: 'api::user_sessions::v1'
|
72
214
|
)
|
73
215
|
|
74
216
|
# Mixed case array
|
75
217
|
SimpleCommandDispatcher.call(
|
76
|
-
command:
|
218
|
+
command: 'CreateCommand',
|
77
219
|
command_namespace: ['api', 'UserSessions', 'v1']
|
78
220
|
)
|
79
221
|
|
@@ -90,7 +232,7 @@ SimpleCommandDispatcher.call(
|
|
90
232
|
)
|
91
233
|
```
|
92
234
|
|
93
|
-
The
|
235
|
+
The transformation handles Unicode characters and removes all whitespace:
|
94
236
|
|
95
237
|
```ruby
|
96
238
|
# Unicode support
|
@@ -101,219 +243,78 @@ SimpleCommandDispatcher.call(
|
|
101
243
|
# Calls: Api::Café::V1::CaféCommand.call
|
102
244
|
```
|
103
245
|
|
104
|
-
### Parameter Handling
|
105
|
-
|
106
|
-
The dispatcher supports multiple parameter formats:
|
107
|
-
|
108
|
-
```ruby
|
109
|
-
# Hash parameters (passed as keyword arguments)
|
110
|
-
SimpleCommandDispatcher.call(
|
111
|
-
command: 'CreateUser',
|
112
|
-
command_namespace: 'Api::V1',
|
113
|
-
request_params: { name: 'John', email: 'john@example.com' }
|
114
|
-
)
|
115
|
-
# Calls: Api::V1::CreateUser.call(name: 'John', email: 'john@example.com')
|
116
|
-
|
117
|
-
# Array parameters (passed as positional arguments)
|
118
|
-
SimpleCommandDispatcher.call(
|
119
|
-
command: 'ProcessData',
|
120
|
-
command_namespace: 'Services',
|
121
|
-
request_params: ['data1', 'data2', 'data3']
|
122
|
-
)
|
123
|
-
# Calls: Services::ProcessData.call('data1', 'data2', 'data3')
|
124
|
-
|
125
|
-
# Single parameter
|
126
|
-
SimpleCommandDispatcher.call(
|
127
|
-
command: 'SendEmail',
|
128
|
-
command_namespace: 'Mailers',
|
129
|
-
request_params: 'user@example.com'
|
130
|
-
)
|
131
|
-
# Calls: Mailers::SendEmail.call('user@example.com')
|
132
|
-
|
133
|
-
# No parameters
|
134
|
-
SimpleCommandDispatcher.call(
|
135
|
-
command: 'HealthCheck',
|
136
|
-
command_namespace: 'System'
|
137
|
-
)
|
138
|
-
# Calls: System::HealthCheck.call
|
139
|
-
```
|
140
|
-
|
141
|
-
## Rails Integration Example
|
246
|
+
### Dynamic Parameter Handling
|
142
247
|
|
143
|
-
|
144
|
-
|
145
|
-
### Application Controller
|
248
|
+
The dispatcher intelligently handles different parameter types based on how your command initializer is coded:
|
146
249
|
|
147
250
|
```ruby
|
148
|
-
#
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
before_action :authenticate_request
|
153
|
-
attr_reader :current_user
|
154
|
-
|
155
|
-
protected
|
156
|
-
|
157
|
-
def get_command_namespace
|
158
|
-
# Extract namespace from request path: "/api/my_app/v1/users" → "api/my_app/v1"
|
159
|
-
path_segments = request.path.split('/').reject(&:empty?)
|
160
|
-
path_segments.take(3).join('/')
|
161
|
-
end
|
251
|
+
# Hash params → keyword arguments
|
252
|
+
def initialize(name:, email:) # kwargs
|
253
|
+
# Called with: YourCommand.call(name: 'John', email: 'john@example.com')
|
254
|
+
end
|
162
255
|
|
163
|
-
|
256
|
+
# Hash params → single hash argument
|
257
|
+
def initialize(params = {}) # single hash
|
258
|
+
# Called with: YourCommand.call({name: 'John', email: 'john@example.com'})
|
259
|
+
end
|
164
260
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
command_namespace: get_command_namespace,
|
169
|
-
request_params: { headers: request.headers }
|
170
|
-
)
|
261
|
+
# Array params → positional arguments
|
262
|
+
request_params: ['arg1', 'arg2', 'arg3']
|
263
|
+
# Called with: YourCommand.call('arg1', 'arg2', 'arg3')
|
171
264
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
render json: { error: 'Not Authorized' }, status: 401
|
176
|
-
end
|
177
|
-
end
|
178
|
-
end
|
265
|
+
# Single param → single argument
|
266
|
+
request_params: 'single_value'
|
267
|
+
# Called with: YourCommand.call('single_value')
|
179
268
|
```
|
180
269
|
|
181
|
-
###
|
270
|
+
### Payment Processing Example
|
182
271
|
|
183
272
|
```ruby
|
184
|
-
# app/
|
185
|
-
class Api::
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
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
|
273
|
+
# app/commands/api/v1/payments/process.rb
|
274
|
+
class Api::V1::Payments::Process
|
275
|
+
prepend SimpleCommandDispatcher::Commands::CommandCallable
|
276
|
+
|
277
|
+
def initialize(params = {})
|
278
|
+
@amount = params[:amount]
|
279
|
+
@card_token = params[:card_token]
|
280
|
+
@user_id = params[:user_id]
|
198
281
|
end
|
199
282
|
|
200
|
-
def
|
201
|
-
|
202
|
-
|
203
|
-
command_namespace: get_command_namespace,
|
204
|
-
request_params: { id: params[:id], **user_params }
|
205
|
-
)
|
283
|
+
def call
|
284
|
+
validate_payment_data
|
285
|
+
return nil if errors.any?
|
206
286
|
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
end
|
287
|
+
charge_card
|
288
|
+
rescue StandardError => e
|
289
|
+
errors.add(:payment, e.message)
|
290
|
+
nil
|
212
291
|
end
|
213
292
|
|
214
293
|
private
|
215
294
|
|
216
|
-
|
217
|
-
params.require(:user).permit(:name, :email, :phone)
|
218
|
-
end
|
219
|
-
end
|
220
|
-
```
|
221
|
-
|
222
|
-
### Command Classes
|
295
|
+
attr_reader :amount, :card_token, :user_id
|
223
296
|
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
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
|
297
|
+
def validate_payment_data
|
298
|
+
errors.add(:amount, 'must be positive') if amount.to_i <= 0
|
299
|
+
errors.add(:card_token, 'is required') if card_token.blank?
|
300
|
+
errors.add(:user_id, 'is required') if user_id.blank?
|
260
301
|
end
|
261
|
-
end
|
262
|
-
```
|
263
302
|
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
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
|
303
|
+
def charge_card
|
304
|
+
PaymentProcessor.charge(
|
305
|
+
amount: amount,
|
306
|
+
card_token: card_token,
|
307
|
+
user_id: user_id
|
308
|
+
)
|
295
309
|
end
|
296
310
|
end
|
297
311
|
```
|
298
312
|
|
299
|
-
|
313
|
+
**Route:** `POST /api/v1/payments/process` automatically calls `Api::V1::Payments::Process.call(params)`
|
300
314
|
|
301
|
-
|
315
|
+
### Custom Commands
|
302
316
|
|
303
|
-
|
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
|
-
```
|
317
|
+
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
318
|
|
318
319
|
## Error Handling
|
319
320
|
|
@@ -321,7 +322,7 @@ The dispatcher provides specific error classes for different failure scenarios:
|
|
321
322
|
|
322
323
|
```ruby
|
323
324
|
begin
|
324
|
-
|
325
|
+
command = SimpleCommandDispatcher.call(
|
325
326
|
command: 'NonExistentCommand',
|
326
327
|
command_namespace: 'Api::V1'
|
327
328
|
)
|
@@ -337,73 +338,6 @@ rescue ArgumentError => e
|
|
337
338
|
end
|
338
339
|
```
|
339
340
|
|
340
|
-
## Advanced Usage
|
341
|
-
|
342
|
-
### Route-Based Command Dispatch
|
343
|
-
|
344
|
-
For RESTful APIs, you can map routes directly to commands:
|
345
|
-
|
346
|
-
```ruby
|
347
|
-
# Extract command from route
|
348
|
-
def dispatch_from_route
|
349
|
-
# Route: "/api/my_app/v1/users/create"
|
350
|
-
path_segments = request.path.split('/').reject(&:empty?)
|
351
|
-
|
352
|
-
namespace = path_segments.take(3).join('/') # "api/my_app/v1"
|
353
|
-
command = path_segments.last # "create"
|
354
|
-
|
355
|
-
SimpleCommandDispatcher.call(
|
356
|
-
command: "#{command}_#{controller_name.singularize}", # "create_user"
|
357
|
-
command_namespace: namespace,
|
358
|
-
request_params: request_params
|
359
|
-
)
|
360
|
-
end
|
361
|
-
```
|
362
|
-
|
363
|
-
### Dynamic API Versioning
|
364
|
-
|
365
|
-
```ruby
|
366
|
-
# Handle multiple API versions dynamically
|
367
|
-
def call_versioned_command(command_name, version = 'v1')
|
368
|
-
SimpleCommandDispatcher.call(
|
369
|
-
command: command_name,
|
370
|
-
command_namespace: ['api', app_name, version],
|
371
|
-
request_params: request_params
|
372
|
-
)
|
373
|
-
end
|
374
|
-
|
375
|
-
# Usage
|
376
|
-
result = call_versioned_command('authenticate_user', 'v2')
|
377
|
-
```
|
378
|
-
|
379
|
-
### Batch Command Execution
|
380
|
-
|
381
|
-
```ruby
|
382
|
-
# Execute multiple related commands
|
383
|
-
def process_user_registration(user_data)
|
384
|
-
commands = [
|
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
|
-
)
|
396
|
-
end
|
397
|
-
|
398
|
-
# Check if all commands succeeded
|
399
|
-
if results.all?(&:success?)
|
400
|
-
{ success: true, user: results[1].user }
|
401
|
-
else
|
402
|
-
{ success: false, errors: results.map(&:errors).flatten.compact }
|
403
|
-
end
|
404
|
-
end
|
405
|
-
```
|
406
|
-
|
407
341
|
## Configuration
|
408
342
|
|
409
343
|
The gem can be configured in an initializer:
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'errors'
|
4
|
+
|
5
|
+
module SimpleCommandDispatcher
|
6
|
+
module Commands
|
7
|
+
module CommandCallable
|
8
|
+
attr_reader :result
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
# Accept everything, essentially: `call(*args, **kwargs)``
|
12
|
+
def call(...)
|
13
|
+
new(...).call
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.prepended(base)
|
18
|
+
base.extend ClassMethods
|
19
|
+
end
|
20
|
+
|
21
|
+
def call
|
22
|
+
raise NotImplementedError unless defined?(super)
|
23
|
+
|
24
|
+
@called = true
|
25
|
+
@result = super
|
26
|
+
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
def success?
|
31
|
+
called? && !failure?
|
32
|
+
end
|
33
|
+
alias successful? success?
|
34
|
+
|
35
|
+
def failure?
|
36
|
+
called? && errors.any?
|
37
|
+
end
|
38
|
+
|
39
|
+
def errors
|
40
|
+
return super if defined?(super)
|
41
|
+
|
42
|
+
@errors ||= Errors.new
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def called?
|
48
|
+
@called ||= false
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'utils'
|
4
|
+
|
5
|
+
module SimpleCommandDispatcher
|
6
|
+
module Commands
|
7
|
+
module CommandCallable
|
8
|
+
class NotImplementedError < ::StandardError; end
|
9
|
+
|
10
|
+
class Errors < Hash
|
11
|
+
def add(key, value, _opts = {})
|
12
|
+
self[key] ||= []
|
13
|
+
self[key] << value
|
14
|
+
self[key].uniq!
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_multiple_errors(errors_hash)
|
18
|
+
errors_hash.each do |key, values|
|
19
|
+
CommandCallable::Utils.array_wrap(values).each { |value| add key, value }
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def each
|
24
|
+
each_key do |field|
|
25
|
+
self[field].each { |message| yield field, message }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def full_messages
|
30
|
+
map { |attribute, message| full_message(attribute, message) }
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def full_message(attribute, message)
|
36
|
+
return message if attribute == :base
|
37
|
+
|
38
|
+
attr_name = attribute.to_s.tr('.', '_').capitalize
|
39
|
+
"#{attr_name} #{message}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCommandDispatcher
|
4
|
+
module Commands
|
5
|
+
module CommandCallable
|
6
|
+
module Utils
|
7
|
+
# Borrowed from active_support/core_ext/array/wrap
|
8
|
+
def self.array_wrap(object)
|
9
|
+
if object.nil?
|
10
|
+
[]
|
11
|
+
elsif object.respond_to?(:to_ary)
|
12
|
+
object.to_ary || [object]
|
13
|
+
else
|
14
|
+
[object]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require 'active_support/core_ext/object/blank'
|
4
4
|
require 'active_support/core_ext/string/inflections'
|
5
5
|
require 'core_ext/kernel'
|
6
|
+
require 'simple_command_dispatcher/commands/command_callable'
|
6
7
|
require 'simple_command_dispatcher/configuration'
|
7
8
|
require 'simple_command_dispatcher/errors'
|
8
9
|
require 'simple_command_dispatcher/services/command_service'
|
@@ -10,12 +10,11 @@ Gem::Specification.new do |spec|
|
|
10
10
|
spec.authors = ['Gene M. Angelo, Jr.']
|
11
11
|
spec.email = ['public.gma@gmail.com']
|
12
12
|
|
13
|
-
spec.summary = '
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
based on the API version. simple_command_dispatcher allows you to execute either command with one line of code dynamically.'.gsub(/\s+/, ' ')
|
13
|
+
spec.summary = 'Dynamic command execution for Rails applications using convention over configuration - automatically maps request routes to command classes.'
|
14
|
+
spec.description = 'A lightweight Ruby gem that enables Rails applications to dynamically execute command objects using convention over configuration. ' \
|
15
|
+
'Automatically transforms request paths into Ruby class constants, allowing controllers to dispatch commands based on routes and parameters. ' \
|
16
|
+
'Features the optional CommandCallable module for standardized command interfaces with built-in success/failure tracking and error handling. ' \
|
17
|
+
'Perfect for clean, maintainable Rails APIs with RESTful route-to-command mapping. Only depends on ActiveSupport for reliable camelization.'
|
19
18
|
spec.homepage = 'https://github.com/gangelo/simple_command_dispatcher'
|
20
19
|
spec.license = 'MIT'
|
21
20
|
|
@@ -37,5 +36,5 @@ Gem::Specification.new do |spec|
|
|
37
36
|
|
38
37
|
spec.required_ruby_version = Gem::Requirement.new('>= 3.3', '< 4.0')
|
39
38
|
|
40
|
-
spec.add_runtime_dependency 'activesupport', '>= 7.0.8', '<
|
39
|
+
spec.add_runtime_dependency 'activesupport', '>= 7.0.8', '< 9.0'
|
41
40
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: simple_command_dispatcher
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.
|
4
|
+
version: 4.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Gene M. Angelo, Jr.
|
@@ -18,7 +18,7 @@ dependencies:
|
|
18
18
|
version: 7.0.8
|
19
19
|
- - "<"
|
20
20
|
- !ruby/object:Gem::Version
|
21
|
-
version: '
|
21
|
+
version: '9.0'
|
22
22
|
type: :runtime
|
23
23
|
prerelease: false
|
24
24
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -28,13 +28,14 @@ dependencies:
|
|
28
28
|
version: 7.0.8
|
29
29
|
- - "<"
|
30
30
|
- !ruby/object:Gem::Version
|
31
|
-
version: '
|
32
|
-
description:
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
31
|
+
version: '9.0'
|
32
|
+
description: A lightweight Ruby gem that enables Rails applications to dynamically
|
33
|
+
execute command objects using convention over configuration. Automatically transforms
|
34
|
+
request paths into Ruby class constants, allowing controllers to dispatch commands
|
35
|
+
based on routes and parameters. Features the optional CommandCallable module for
|
36
|
+
standardized command interfaces with built-in success/failure tracking and error
|
37
|
+
handling. Perfect for clean, maintainable Rails APIs with RESTful route-to-command
|
38
|
+
mapping. Only depends on ActiveSupport for reliable camelization.
|
38
39
|
email:
|
39
40
|
- public.gma@gmail.com
|
40
41
|
executables: []
|
@@ -61,6 +62,9 @@ files:
|
|
61
62
|
- bin/setup
|
62
63
|
- lib/core_ext/kernel.rb
|
63
64
|
- lib/simple_command_dispatcher.rb
|
65
|
+
- lib/simple_command_dispatcher/commands/command_callable.rb
|
66
|
+
- lib/simple_command_dispatcher/commands/errors.rb
|
67
|
+
- lib/simple_command_dispatcher/commands/utils.rb
|
64
68
|
- lib/simple_command_dispatcher/configuration.rb
|
65
69
|
- lib/simple_command_dispatcher/errors.rb
|
66
70
|
- lib/simple_command_dispatcher/errors/invalid_class_constant_error.rb
|
@@ -94,7 +98,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
98
|
requirements: []
|
95
99
|
rubygems_version: 3.6.8
|
96
100
|
specification_version: 4
|
97
|
-
summary:
|
98
|
-
|
99
|
-
Ideal for rails-api.
|
101
|
+
summary: Dynamic command execution for Rails applications using convention over configuration
|
102
|
+
- automatically maps request routes to command classes.
|
100
103
|
test_files: []
|