
Security News
Follow-up and Clarification on Recent Malicious Ruby Gems Campaign
A clarification on our recent research investigating 60 malicious Ruby gems.
simple_command_dispatcher
Advanced tools
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.
📋 See it in action: Check out the demo application - a Rails API app with tests that demonstrate how to use the gem and its capabilities.
CommandCallable
module for consistent command interfaces with built-in success/failure trackingAdd this line to your application's Gemfile:
gem 'simple_command_dispatcher'
And then execute:
$ bundle
Or install it yourself as:
$ gem install simple_command_dispatcher
# Basic command call
command = SimpleCommandDispatcher.call(
command: 'AuthenticateUser',
command_namespace: 'Api::V1',
request_params: { email: 'user@example.com', password: 'secret' }
)
# This executes: Api::V1::AuthenticateUser.call(email: 'user@example.com', password: 'secret')
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.
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.
Here's how it works with a real controller example:
# app/controllers/api/mechs_controller.rb
class Api::MechsController < ApplicationController
before_action :route_request, except: [:index]
def index
render json: { mechs: Mech.all }
end
def search
# Action intentionally left empty, routing handled by before_action
end
private
def route_request
command = SimpleCommandDispatcher.call(
command: request.path, # "/api/v1/mechs/search"
command_namespace: nil, # nil since the command namespace can be gleaned directly from `command: request.path`
request_params: params # Full Rails params hash
)
if command.success?
render json: { mechs: command.result }, status: :ok
else
render json: { errors: command.errors }, status: :unprocessable_entity
end
end
end
The Convention: Request path /api/v1/mechs/search
automatically maps to command class Api::V1::Mechs::Search
Alternative approach if you need more control over command name and namespace:
# Split the path manually
command = SimpleCommandDispatcher.call(
command: request.path.split("/").last, # "search"
command_namespace: request.path.split("/")[0..2], # "/api/v1/mechs"
request_params: params
)
# app/commands/api/v1/mechs/search.rb
class Api::V1::Mechs::Search
prepend SimpleCommandDispatcher::Commands::CommandCallable
def initialize(params = {})
@name = params[:name]
end
def call
# V1 search logic - simple name search
name.present? ? Mech.where("mech_name ILIKE ?", "%#{name}%") : Mech.none
end
private
attr_reader :name
end
# app/commands/api/v2/mechs/search.rb
class Api::V2::Mechs::Search
prepend SimpleCommandDispatcher::Commands::CommandCallable
def initialize(params = {})
@cost = params[:cost]
@introduction_year = params[:introduction_year]
@mech_name = params[:mech_name]
@tonnage = params[:tonnage]
@variant = params[:variant]
end
def call
# V2 search logic - comprehensive search using scopes
Mech.by_cost(cost)
.or(Mech.by_introduction_year(introduction_year))
.or(Mech.by_mech_name(mech_name))
.or(Mech.by_tonnage(tonnage))
.or(Mech.by_variant(variant))
end
private
attr_reader :cost, :introduction_year, :mech_name, :tonnage, :variant
end
# app/models/mech.rb (V2 scopes)
class Mech < ApplicationRecord
scope :by_mech_name, ->(name) {
name.present? ? where("mech_name ILIKE ?", "%#{name}%") : none
}
scope :by_variant, ->(variant) {
variant.present? ? where("variant ILIKE ?", "%#{variant}%") : none
}
scope :by_tonnage, ->(tonnage) {
tonnage.present? ? where(tonnage: tonnage) : none
}
scope :by_cost, ->(cost) {
cost.present? ? where(cost: cost) : none
}
scope :by_introduction_year, ->(year) {
year.present? ? where(introduction_year: year) : none
}
end
The Magic: By convention, routes automatically map to commands:
/api/v1/mechs/search
→ Api::V1::Mechs::Search
/api/v2/mechs/search
→ Api::V2::Mechs::Search
When you prepend CommandCallable
to your command class, you automatically get:
.call
class method that instantiates and calls your commandcommand.result
success?
and failure?
methods based on error stateerrors
object for consistent error managementThe gem automatically transforms route paths into Ruby class constants using intelligent camelization, allowing flexible input formats:
# All of these are equivalent and call: Api::UserSessions::V1::CreateCommand.call
# Lowercase strings with various separators
SimpleCommandDispatcher.call(
command: :create_command,
command_namespace: 'api::user_sessions::v1'
)
# Mixed case array
SimpleCommandDispatcher.call(
command: 'CreateCommand',
command_namespace: ['api', 'UserSessions', 'v1']
)
# Route-like strings (optimized for Rails controllers)
SimpleCommandDispatcher.call(
command: '/create_command',
command_namespace: '/api/user_sessions/v1'
)
# Mixed separators (hyphens, dots, spaces)
SimpleCommandDispatcher.call(
command: 'create-command',
command_namespace: 'api.user-sessions/v1'
)
The transformation handles Unicode characters and removes all whitespace:
# Unicode support
SimpleCommandDispatcher.call(
command: 'café_command',
command_namespace: 'api :: café :: v1' # Spaces are removed
)
# Calls: Api::Café::V1::CaféCommand.call
The dispatcher intelligently handles different parameter types based on how your command initializer is coded:
# Hash params → keyword arguments
def initialize(name:, email:) # kwargs
# Called with: YourCommand.call(name: 'John', email: 'john@example.com')
end
# Hash params → single hash argument
def initialize(params = {}) # single hash
# Called with: YourCommand.call({name: 'John', email: 'john@example.com'})
end
# Array params → positional arguments
request_params: ['arg1', 'arg2', 'arg3']
# Called with: YourCommand.call('arg1', 'arg2', 'arg3')
# Single param → single argument
request_params: 'single_value'
# Called with: YourCommand.call('single_value')
# app/commands/api/v1/payments/process.rb
class Api::V1::Payments::Process
prepend SimpleCommandDispatcher::Commands::CommandCallable
def initialize(params = {})
@amount = params[:amount]
@card_token = params[:card_token]
@user_id = params[:user_id]
end
def call
validate_payment_data
return nil if errors.any?
charge_card
rescue StandardError => e
errors.add(:payment, e.message)
nil
end
private
attr_reader :amount, :card_token, :user_id
def validate_payment_data
errors.add(:amount, 'must be positive') if amount.to_i <= 0
errors.add(:card_token, 'is required') if card_token.blank?
errors.add(:user_id, 'is required') if user_id.blank?
end
def charge_card
PaymentProcessor.charge(
amount: amount,
card_token: card_token,
user_id: user_id
)
end
end
Route: POST /api/v1/payments/process
automatically calls Api::V1::Payments::Process.call(params)
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.
The dispatcher provides specific error classes for different failure scenarios:
begin
command = SimpleCommandDispatcher.call(
command: 'NonExistentCommand',
command_namespace: 'Api::V1'
)
rescue SimpleCommandDispatcher::Errors::InvalidClassConstantError => e
# Command class doesn't exist
puts "Command not found: #{e.message}"
rescue SimpleCommandDispatcher::Errors::RequiredClassMethodMissingError => e
# Command class exists but doesn't have a .call method
puts "Invalid command: #{e.message}"
rescue ArgumentError => e
# Invalid arguments (empty command, wrong parameter types, etc.)
puts "Invalid arguments: #{e.message}"
end
The gem can be configured in an initializer:
# config/initializers/simple_command_dispatcher.rb
SimpleCommandDispatcher.configure do |config|
# Configuration options will be added in future versions
end
If you're upgrading from v3.x, here are the key changes:
Method signature changed to keyword arguments:
# v3.x (old)
SimpleCommandDispatcher.call(:CreateUser, 'Api::V1', { options }, params)
# v4.x (new)
SimpleCommandDispatcher.call(
command: :CreateUser,
command_namespace: 'Api::V1',
request_params: params
)
Removed simple_command dependency:
.call
class methodRemoved configuration options:
allow_custom_commands
option removed (all commands are "custom" now)Namespace changes:
SimpleCommand::Dispatcher::Errors::*
→ SimpleCommandDispatcher::Errors::*
Bug reports and pull requests are welcome on GitHub at https://github.com/gangelo/simple_command_dispatcher. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.
See CHANGELOG.md for version history and breaking changes.
FAQs
Unknown package
We found that simple_command_dispatcher demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 2 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
A clarification on our recent research investigating 60 malicious Ruby gems.
Security News
ESLint now supports parallel linting with a new --concurrency flag, delivering major speed gains and closing a 10-year-old feature request.
Research
/Security News
A malicious Go module posing as an SSH brute forcer exfiltrates stolen credentials to a Telegram bot controlled by a Russian-speaking threat actor.