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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module SimpleCommandDispatcher
6
+ module Commands
7
+ # CommandCallable provides a standardized interface for command objects with built-in
8
+ # success/failure tracking and error handling.
9
+ #
10
+ # When prepended to a command class, it:
11
+ # - Adds a class-level `.call` method that instantiates and executes the command
12
+ # - Tracks command execution with `success?` and `failure?` methods
13
+ # - Provides error collection via the `errors` object
14
+ # - Stores the command's return value in `result`
15
+ #
16
+ # @example Basic usage
17
+ # class AuthenticateUser
18
+ # prepend SimpleCommandDispatcher::Commands::CommandCallable
19
+ #
20
+ # def initialize(email:, password:)
21
+ # @email = email
22
+ # @password = password
23
+ # end
24
+ #
25
+ # def call
26
+ # return nil unless user = User.find_by(email: @email)
27
+ # return nil unless user.authenticate(@password)
28
+ #
29
+ # user
30
+ # end
31
+ # end
32
+ #
33
+ # command = AuthenticateUser.call(email: 'user@example.com', password: 'secret')
34
+ # command.success? # => true if user found and authenticated
35
+ # command.result # => User object or nil
36
+ module CommandCallable
37
+ # @return [Object] the return value from the command's call method
38
+ attr_reader :result
39
+
40
+ module ClassMethods
41
+ # Creates a new instance of the command and calls it, passing all arguments through.
42
+ #
43
+ # @param args [Array] positional arguments passed to initialize
44
+ # @param kwargs [Hash] keyword arguments passed to initialize
45
+ # @return [Object] the command instance (not the result - use .result to get the return value)
46
+ def call(...)
47
+ new(...).call
48
+ end
49
+ end
50
+
51
+ def self.prepended(base)
52
+ base.extend ClassMethods
53
+ end
54
+
55
+ # Executes the command by calling super (your command's implementation).
56
+ # Tracks execution state and stores the result.
57
+ #
58
+ # @return [self] the command instance for method chaining
59
+ # @raise [NotImplementedError] if the including class doesn't define a call method
60
+ def call
61
+ raise NotImplementedError unless defined?(super)
62
+
63
+ @called = true
64
+ @result = super
65
+
66
+ self
67
+ end
68
+
69
+ # Returns true if the command was called successfully (no errors).
70
+ #
71
+ # @return [Boolean] true if called and no errors present
72
+ def success?
73
+ called? && !failure?
74
+ end
75
+ alias successful? success?
76
+
77
+ # Returns true if the command was called but has errors.
78
+ #
79
+ # @return [Boolean] true if called and errors are present
80
+ def failure?
81
+ called? && errors.any?
82
+ end
83
+
84
+ # Returns the errors collection for this command.
85
+ # If the command class defines its own errors method, that will be used instead.
86
+ #
87
+ # @return [Errors] the errors collection
88
+ def errors
89
+ return super if defined?(super)
90
+
91
+ @errors ||= Errors.new
92
+ end
93
+
94
+ private
95
+
96
+ # Returns true if the command's call method has been invoked.
97
+ #
98
+ # @return [Boolean] true if call has been invoked
99
+ def called?
100
+ @called ||= false
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'utils'
4
+
5
+ module SimpleCommandDispatcher
6
+ module Commands
7
+ module CommandCallable
8
+ # Raised when a command's call method is not implemented
9
+ class NotImplementedError < ::StandardError; end
10
+
11
+ # Error collection for CommandCallable commands.
12
+ # Stores validation errors as a hash where keys are field names and values are arrays of error messages.
13
+ class Errors < Hash
14
+ # Adds an error message to the specified field.
15
+ # Automatically prevents duplicate messages for the same field.
16
+ #
17
+ # @param key [Symbol, String] the field name
18
+ # @param value [String] the error message
19
+ # @param _opts [Hash] reserved for future use
20
+ # @return [Array] the updated array of error messages for this field
21
+ #
22
+ # @example
23
+ # errors.add(:email, 'is required')
24
+ # errors.add(:email, 'is invalid')
25
+ # errors[:email] # => ['is required', 'is invalid']
26
+ def add(key, value, _opts = {})
27
+ self[key] ||= []
28
+ self[key] << value
29
+ self[key].uniq!
30
+ end
31
+
32
+ # Adds multiple errors from a hash.
33
+ # Values can be single messages or arrays of messages.
34
+ #
35
+ # @param errors_hash [Hash] hash of field names to error message(s)
36
+ #
37
+ # @example
38
+ # errors.add_multiple_errors(email: 'is required', password: ['is too short', 'is too weak'])
39
+ def add_multiple_errors(errors_hash)
40
+ errors_hash.each do |key, values|
41
+ CommandCallable::Utils.array_wrap(values).each { |value| add key, value }
42
+ end
43
+ end
44
+
45
+ # Iterates over each field and message pair.
46
+ # If a field has multiple messages, yields once for each message.
47
+ #
48
+ # @yieldparam field [Symbol] the field name
49
+ # @yieldparam message [String] the error message
50
+ #
51
+ # @example
52
+ # errors.each { |field, message| puts "#{field}: #{message}" }
53
+ def each
54
+ each_key do |field|
55
+ self[field].each { |message| yield field, message }
56
+ end
57
+ end
58
+
59
+ # Returns an array of formatted error messages.
60
+ # Messages are prefixed with the capitalized field name, except for :base.
61
+ #
62
+ # @return [Array<String>] formatted error messages
63
+ #
64
+ # @example
65
+ # errors.add(:email, 'is required')
66
+ # errors.add(:base, 'Something went wrong')
67
+ # errors.full_messages # => ['Email is required', 'Something went wrong']
68
+ def full_messages
69
+ map { |attribute, message| full_message(attribute, message) }
70
+ end
71
+
72
+ private
73
+
74
+ def full_message(attribute, message)
75
+ return message if attribute == :base
76
+
77
+ attr_name = attribute.to_s.tr('.', '_').capitalize
78
+ "#{attr_name} #{message}"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ 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,8 +3,6 @@
3
3
  # This is the configuration for SimpleCommandDispatcher.
4
4
  module SimpleCommandDispatcher
5
5
  class << self
6
- attr_reader :configuration
7
-
8
6
  # Configures SimpleCommandDispatcher by yielding the configuration object to the block.
9
7
  #
10
8
  # @yield [Configuration] yields the configuration object to the block
@@ -13,7 +11,7 @@ module SimpleCommandDispatcher
13
11
  # @example
14
12
  #
15
13
  # SimpleCommandDispatcher.configure do |config|
16
- # config.some_option = 'some value'
14
+ # config.logger = Rails.logger
17
15
  # end
18
16
  def configure
19
17
  self.configuration ||= Configuration.new
@@ -23,6 +21,13 @@ module SimpleCommandDispatcher
23
21
  configuration
24
22
  end
25
23
 
24
+ # Returns the configuration object, initializing it if necessary
25
+ #
26
+ # @return [Configuration] the configuration object
27
+ def configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
26
31
  private
27
32
 
28
33
  attr_writer :configuration
@@ -31,16 +36,30 @@ module SimpleCommandDispatcher
31
36
  # This class encapsulates the configuration properties for this gem and
32
37
  # provides methods and attributes that allow for management of the same.
33
38
  class Configuration
34
- # TODO: Add attr_xxx here
39
+ # @return [Logger] the logger instance used for debug output.
40
+ # Defaults to Rails.logger in Rails applications, or Logger.new($stdout) otherwise.
41
+ attr_accessor :logger
35
42
 
36
43
  # Initializes a new Configuration instance with default values
37
44
  def initialize
38
45
  reset
39
46
  end
40
47
 
41
- # Resets all configuration attributes to their default values
48
+ # Resets all configuration attributes to their default values.
49
+ # Sets logger to Rails.logger if Rails is defined, otherwise creates a new Logger writing to $stdout.
42
50
  def reset
43
- # TODO: Reset our attributes here e.g. @attr = nil
51
+ @logger = default_logger
52
+ end
53
+
54
+ private
55
+
56
+ def default_logger
57
+ if defined?(Rails) && Rails.respond_to?(:logger)
58
+ Rails.logger
59
+ else
60
+ require 'logger'
61
+ ::Logger.new($stdout)
62
+ end
44
63
  end
45
64
  end
46
65
  end
@@ -25,7 +25,7 @@ module SimpleCommandDispatcher
25
25
  # For RESTful paths → Ruby constants, use Rails' proven methods
26
26
  # They're fast, reliable, and handle edge cases that matter for constants
27
27
  result = trim_all(token)
28
- .gsub(%r{[/\-\.\s:]+}, '/') # Normalize separators to /
28
+ .gsub(%r{[/\-.\s:]+}, '/') # Normalize separators to /
29
29
  .split('/') # Split into path segments
30
30
  .reject(&:empty?) # Remove empty segments
31
31
  .map { |segment| segment.underscore.camelize } # Rails camelization
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCommandDispatcher
4
+ # Provides logging functionality for SimpleCommandDispatcher.
5
+ # Supports configuration to use Rails logger or custom loggers.
6
+ module Logger
7
+ private
8
+
9
+ def log_debug(string)
10
+ logger.debug(string) if logger.respond_to?(:debug)
11
+ end
12
+
13
+ def log_error(string)
14
+ logger.error(string) if logger.respond_to?(:error)
15
+ end
16
+
17
+ def logger
18
+ SimpleCommandDispatcher.configuration.logger
19
+ end
20
+ end
21
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../errors'
4
4
  require_relative '../helpers/camelize'
5
+ require_relative '../logger'
5
6
  require_relative 'command_namespace_service'
6
7
 
7
8
  module SimpleCommandDispatcher
@@ -9,8 +10,10 @@ module SimpleCommandDispatcher
9
10
  # Handles class and module transformations and instantiation.
10
11
  class CommandService
11
12
  include Helpers::Camelize
13
+ include Logger
12
14
 
13
- def initialize(command:, command_namespace: {})
15
+ def initialize(command:, command_namespace: {}, options: {})
16
+ @options = options
14
17
  @command = validate_command(command:)
15
18
  @command_namespace = validate_command_namespace(command_namespace:)
16
19
  end
@@ -25,15 +28,24 @@ module SimpleCommandDispatcher
25
28
  #
26
29
  # @example
27
30
  #
28
- # to_class("Authenticate", "Api") # => Api::Authenticate
29
- # to_class(:Authenticate, [:Api, :AppName, :V1]) # => Api::AppName::V1::Authenticate
30
- # to_class(:Authenticate, { :api :Api, app_name: :AppName, api_version: :V2 })
31
+ # CommandService.new(command: "Authenticate", command_namespace: "Api").to_class
32
+ # # => Api::Authenticate
33
+ # CommandService.new(command: :Authenticate, command_namespace: [:Api, :AppName, :V1]).to_class
34
+ # # => Api::AppName::V1::Authenticate
35
+ # CommandService.new(command: :Authenticate,
36
+ # command_namespace: { api: :Api, app_name: :AppName, api_version: :V2 }).to_class
31
37
  # # => Api::AppName::V2::Authenticate
32
- # to_class("authenticate", { :api :api, app_name: :app_name, api_version: :v1 },
33
- # { titleize_class: true, titleize_module: true }) # => Api::AppName::V1::Authenticate
38
+ # CommandService.new(command: "authenticate", command_namespace: "api::app_name::v1").to_class
39
+ # # => Api::AppName::V1::Authenticate
34
40
  #
35
41
  def to_class
36
- qualified_class_string = to_qualified_class_string(command, command_namespace)
42
+ qualified_class_string = to_qualified_class_string
43
+
44
+ if options.debug?
45
+ log_debug <<~DEBUG
46
+ Command to execute: #{qualified_class_string.inspect}
47
+ DEBUG
48
+ end
37
49
 
38
50
  begin
39
51
  qualified_class_string.constantize
@@ -44,24 +56,13 @@ module SimpleCommandDispatcher
44
56
 
45
57
  private
46
58
 
47
- attr_accessor :command, :command_namespace
59
+ attr_reader :options, :command, :command_namespace
48
60
 
49
61
  # Returns a fully-qualified constantized class (as a string), given the command and command_namespace.
50
- # Both parameters are automatically camelized/titleized during processing.
51
- #
52
- # @param command [Symbol, String] the class name.
53
- # @param command_namespace [Hash, Array, String] the modules command belongs to.
54
62
  #
55
63
  # @return [String] the fully qualified class, which includes module(s) and class name.
56
64
  #
57
- # @example
58
- #
59
- # to_qualified_class_string("authenticate", "api") # => "Api::Authenticate"
60
- # to_qualified_class_string(:Authenticate, [:Api, :AppName, :V1]) # => "Api::AppName::V1::Authenticate"
61
- # to_qualified_class_string(:authenticate, { api: :api, app_name: :app_name, api_version: :v1 })
62
- # # => "Api::AppName::V1::Authenticate"
63
- #
64
- def to_qualified_class_string(command, command_namespace)
65
+ def to_qualified_class_string
65
66
  class_modules_string = CommandNamespaceService.new(command_namespace:).to_class_modules_string
66
67
  class_string = to_class_string(command:)
67
68
  "#{class_modules_string}#{class_string}"
@@ -87,19 +88,18 @@ module SimpleCommandDispatcher
87
88
 
88
89
  # @!visibility public
89
90
  #
90
- # Validates command and returns command as a string after all blanks have been removed using
91
- # command.gsub(/\s+/, "").
91
+ # Validates command and returns command as a string after leading and trailing whitespace is stripped.
92
92
  #
93
- # @param [Symbol or String] command the class name to be validated. command cannot be empty?
93
+ # @param command [Symbol, String] the class name to be validated. command cannot be empty after stripping.
94
94
  #
95
- # @return [String] the validated class as a string with blanks removed.
95
+ # @return [String] the validated class as a string with leading/trailing whitespace removed.
96
96
  #
97
97
  # @raise [ArgumentError] if the command is empty? or not of type String or Symbol.
98
98
  #
99
99
  # @example
100
100
  #
101
- # validate_command(" My Class ") # => "MyClass"
102
- # validate_command(:MyClass) # => "MyClass"
101
+ # validate_command(command: " MyClass ") # => "MyClass"
102
+ # validate_command(command: :MyClass) # => "MyClass"
103
103
  #
104
104
  def validate_command(command:)
105
105
  unless command.is_a?(Symbol) || command.is_a?(String)
@@ -119,17 +119,17 @@ module SimpleCommandDispatcher
119
119
  #
120
120
  # Validates and returns command_namespace.
121
121
  #
122
- # @param [Hash, Array or String] command_namespace the module(s) to be validated.
122
+ # @param command_namespace [Hash, Array, String] the module(s) to be validated.
123
123
  #
124
- # @return [Hash, Array or String] the validated module(s).
124
+ # @return [Hash, Array, String] the validated module(s), or {} if blank.
125
125
  #
126
126
  # @raise [ArgumentError] if the command_namespace is not of type String, Hash or Array.
127
127
  #
128
128
  # @example
129
129
  #
130
- # validate_command_namespace(" Module ") # => " Module "
131
- # validate_command_namespace(:Module) # => :Module
132
- # validate_command_namespace("ModuleA::ModuleB") # => "ModuleA::ModuleB"
130
+ # validate_command_namespace(command_namespace: "Api::V1") # => "Api::V1"
131
+ # validate_command_namespace(command_namespace: [:Api, :V1]) # => [:Api, :V1]
132
+ # validate_command_namespace(command_namespace: { api: :Api, version: :V1 }) # => { api: :Api, version: :V1 }
133
133
  #
134
134
  def validate_command_namespace(command_namespace:)
135
135
  return {} if command_namespace.blank?
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCommandDispatcher
4
+ module Services
5
+ # Handles options for command execution and ensures proper initialization.
6
+ #
7
+ # @example
8
+ # options = OptionsService.new(options: { debug: true })
9
+ # options.debug? # => true
10
+ class OptionsService
11
+ # Default options for command execution
12
+ DEFAULT_OPTIONS = {
13
+ debug: false
14
+ }.freeze
15
+
16
+ # Initializes the options service with the provided options merged with defaults.
17
+ #
18
+ # @param options [Hash] custom options
19
+ # @option options [Boolean] :debug (false) enables debug logging when true
20
+ def initialize(options: {})
21
+ @options = DEFAULT_OPTIONS.merge(options)
22
+ end
23
+
24
+ # Returns true if debug mode is enabled.
25
+ # When enabled, debug logging will show command execution flow.
26
+ #
27
+ # @return [Boolean] true if debug mode is enabled
28
+ def debug?
29
+ options[:debug]
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :options
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCommandDispatcher
4
- VERSION = '4.0.0'
4
+ VERSION = '4.2.0'
5
5
  end
@@ -3,12 +3,17 @@
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'
9
+ require 'simple_command_dispatcher/logger'
8
10
  require 'simple_command_dispatcher/services/command_service'
11
+ require 'simple_command_dispatcher/services/options_service'
9
12
  require 'simple_command_dispatcher/version'
10
13
 
11
14
  module SimpleCommandDispatcher
15
+ extend Logger
16
+
12
17
  # Provides a way to call your custom commands dynamically.
13
18
  #
14
19
  class << self
@@ -17,10 +22,10 @@ module SimpleCommandDispatcher
17
22
  #
18
23
  # @param command [Symbol, String] the name of the Command to call.
19
24
  #
20
- # @param command_namespace [Hash, Array] the ruby modules that qualify the Command to call.
25
+ # @param command_namespace [Hash, Array, String] the ruby modules that qualify the Command to call.
21
26
  # When passing a Hash, the Hash keys serve as documentation only.
22
- # For example, ['Api', 'AppName', 'V1'] and { :api :Api, app_name: :AppName, api_version: :V1 }
23
- # will both produce 'Api::AppName::V1', this string will be prepended to the command to form the Command
27
+ # For example, ['Api', 'AppName', 'V1'], 'Api::AppName::V1', and { :api :Api, app_name: :AppName, api_version: :V1 }
28
+ # will all produce 'Api::AppName::V1', this string will be prepended to the command to form the Command
24
29
  # to call (e.g. 'Api::AppName::V1::MySimpleCommand' = Api::AppName::V1::MySimpleCommand.call(*request_params)).
25
30
  #
26
31
  # @param request_params [Hash, Array, Object] the parameters to pass to the call method of the Command. This
@@ -28,6 +33,10 @@ module SimpleCommandDispatcher
28
33
  # keyword arguments, Array parameters are passed as positional arguments, and other objects are passed
29
34
  # as a single argument.
30
35
  #
36
+ # @param options [Hash] optional configuration for command execution.
37
+ # Supported options:
38
+ # - :debug [Boolean] when true, enables debug logging of command execution flow
39
+ #
31
40
  # @return [Object] the Object returned as a result of calling the Command#call method.
32
41
  #
33
42
  # @example
@@ -49,20 +58,43 @@ module SimpleCommandDispatcher
49
58
  # command_namespace: ['Api::Auth::JazzMeUp', :V1],
50
59
  # request_params: ['jazz_me@gmail.com', 'JazzM3!']) # => Command result
51
60
  #
52
- def call(command:, command_namespace: {}, request_params: nil)
61
+ def call(command:, command_namespace: {}, request_params: nil, options: {})
62
+ @options = Services::OptionsService.new(options:)
63
+
64
+ if @options.debug?
65
+ log_debug <<~DEBUG
66
+ Begin dispatching command
67
+ command: #{command.inspect}
68
+ command_namespace: #{command_namespace.inspect}
69
+ DEBUG
70
+ end
71
+
53
72
  # Create a constantized class from our command and command_namespace...
54
- constantized_class_object = Services::CommandService.new(command:, command_namespace:).to_class
73
+ constantized_class_object = Services::CommandService.new(command:, command_namespace:, options: @options).to_class
74
+
75
+ if @options.debug?
76
+ log_debug <<~DEBUG
77
+ Constantized command: #{constantized_class_object.inspect}
78
+ DEBUG
79
+ end
80
+
55
81
  validate_command!(constantized_class_object)
56
82
 
57
83
  # We know we have a valid command class object if we get here. All we need to do is call the .call
58
84
  # class method, pass the request_params arguments depending on the request_params data type, and
59
85
  # return the results.
60
86
 
61
- call_command(constantized_class_object:, request_params:)
87
+ command_object = call_command(constantized_class_object:, request_params:)
88
+
89
+ log_debug 'End dispatching command' if @options.debug?
90
+
91
+ command_object
62
92
  end
63
93
 
64
94
  private
65
95
 
96
+ attr_reader :options
97
+
66
98
  def call_command(constantized_class_object:, request_params:)
67
99
  if request_params.is_a?(Hash)
68
100
  constantized_class_object.call(**request_params)
@@ -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 = 'Provides a way to dispatch simple_command (ruby gem) commands or your own custom commands (service objects) in a more dynamic manner
14
- within your service API. Ideal for rails-api.'
15
- spec.description = 'Within a services API (rails-api for instance), you may have a need to execute different simple_commands or your own custom commands (service objects)
16
- based on one or more factors: multiple application, API version, user type, user credentials, etc. For example,
17
- your service API may need to execute either Api::Auth::V1::AuthenticateCommand.call(...) or Api::Auth::V2::AuthenticateCommand.call(...)
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', '< 8.0'
39
+ spec.add_runtime_dependency 'activesupport', '>= 7.0.8', '< 9.0'
41
40
  end