simple_command_dispatcher 3.0.4 → 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.
@@ -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
@@ -1,44 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SimpleCommand
4
- module Dispatcher
5
- # Gem configuration settings class. Use this class to configure this gem.
3
+ # This is the configuration for SimpleCommandDispatcher.
4
+ module SimpleCommandDispatcher
5
+ class << self
6
+ attr_reader :configuration
7
+
8
+ # Configures SimpleCommandDispatcher by yielding the configuration object to the block.
6
9
  #
7
- # To configure this gem in your application, simply add the following code in your application and set the
8
- # appropriate configuration settings.
10
+ # @yield [Configuration] yields the configuration object to the block
11
+ # @return [Configuration] returns the configuration object
9
12
  #
10
13
  # @example
11
14
  #
12
- # SimpleCommand::Dispatcher.configure do |config|
13
- # config.allow_custom_commands = true
14
- # end
15
- #
16
- class Configuration
17
- # Gets/sets the *allow_custom_commands* configuration setting (defaults to false).
18
- # If this setting is set to *false*, only command classes that prepend the *SimpleCommand* module
19
- # will be considered acceptable to run, all other command classes will fail to run. If this
20
- # setting is set to *true*, any command class will be considered acceptable to run, regardless of
21
- # whether or not the class prepends the *SimpleCommand* module.
22
- #
23
- # For information about the simple_command gem, visit {https://rubygems.org/gems/simple_command}
24
- #
25
- # @return [Boolean] the value.
26
- #
27
- attr_accessor :allow_custom_commands
28
-
29
- def initialize
30
- # The default is to use any command that exposes a ::call class method.
31
- reset
32
- end
33
-
34
- # Resets the configuration to use the default values.
35
- #
36
- # @return [nil] returns nil.
37
- #
38
- def reset
39
- @allow_custom_commands = false
40
- nil
41
- end
15
+ # SimpleCommandDispatcher.configure do |config|
16
+ # config.some_option = 'some value'
17
+ # end
18
+ def configure
19
+ self.configuration ||= Configuration.new
20
+
21
+ yield(configuration) if block_given?
22
+
23
+ configuration
24
+ end
25
+
26
+ private
27
+
28
+ attr_writer :configuration
29
+ end
30
+
31
+ # This class encapsulates the configuration properties for this gem and
32
+ # provides methods and attributes that allow for management of the same.
33
+ class Configuration
34
+ # TODO: Add attr_xxx here
35
+
36
+ # Initializes a new Configuration instance with default values
37
+ def initialize
38
+ reset
39
+ end
40
+
41
+ # Resets all configuration attributes to their default values
42
+ def reset
43
+ # TODO: Reset our attributes here e.g. @attr = nil
42
44
  end
43
45
  end
44
46
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCommandDispatcher
4
+ module Errors
5
+ # This error is raised when a command class constant is not found or invalid.
6
+ class InvalidClassConstantError < StandardError
7
+ # Initializes a new InvalidClassConstantError
8
+ #
9
+ # @param constantized_class_string [String] the class string that failed to constantize
10
+ # @param error_message [String] the underlying error message
11
+ def initialize(constantized_class_string, error_message)
12
+ super("\"#{constantized_class_string}\" is not a valid class constant. Error message: \"#{error_message}\".")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCommandDispatcher
4
+ module Errors
5
+ # This error is raised when a required class method is missing on the command class.
6
+ class RequiredClassMethodMissingError < StandardError
7
+ # Initializes a new RequiredClassMethodMissingError
8
+ #
9
+ # @param command_class_constant [Class] the command class that is missing the required method
10
+ def initialize(command_class_constant)
11
+ super("Class \"#{command_class_constant}\" does not respond_to? class method \"call\".")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors/invalid_class_constant_error'
4
+ require_relative 'errors/required_class_method_missing_error'
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'trim_all'
4
+
5
+ module SimpleCommandDispatcher
6
+ module Helpers
7
+ module Camelize
8
+ include TrimAll
9
+
10
+ # Transforms a RESTful route into a Ruby constant string for instantiation
11
+ #
12
+ # @param token [String] the route path to be camelized
13
+ # @return [String] the camelized constant name
14
+ #
15
+ # @example
16
+ #
17
+ # camelize("/api/users/v1") # => "Api::Users::V1"
18
+ # # Then: Api::Users::V1.new.call
19
+ #
20
+ def camelize(token)
21
+ raise ArgumentError, 'Token is not a String' unless token.instance_of? String
22
+
23
+ return if token.empty?
24
+
25
+ # For RESTful paths → Ruby constants, use Rails' proven methods
26
+ # They're fast, reliable, and handle edge cases that matter for constants
27
+ result = trim_all(token)
28
+ .gsub(%r{[/\-\.\s:]+}, '/') # Normalize separators to /
29
+ .split('/') # Split into path segments
30
+ .reject(&:empty?) # Remove empty segments
31
+ .map { |segment| segment.underscore.camelize } # Rails camelization
32
+ .join('::') # Join as Ruby namespace
33
+
34
+ result.empty? ? '' : result
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # Usage example:
41
+ # "/api/user_sessions/v1" → "Api::UserSessions::V1"
42
+ #
43
+ # Then in your dispatcher:
44
+ # constant_name = camelize(request.path)
45
+ # command_class = Object.const_get(constant_name)
46
+ # command_class.new.call
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCommandDispatcher
4
+ module Helpers
5
+ module TrimAll
6
+ # Removes all whitespace from the given string, including Unicode whitespace
7
+ #
8
+ # @param string [String] the string to remove whitespace from
9
+ # @return [String] the string with all whitespace removed
10
+ def trim_all(string)
11
+ # Using Unicode property \p{Space} to match all Unicode whitespace characters
12
+ string.gsub(/\p{Space}+/, '')
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/camelize'
4
+ require_relative '../helpers/trim_all'
5
+
6
+ module SimpleCommandDispatcher
7
+ module Services
8
+ # Returns a string of modules that can be subsequently prepended to a class, to create a fully qualified,
9
+ # constantized class.
10
+ #
11
+ # The command_namespace is provided during initialization and can be a Hash, Array, or String.
12
+ #
13
+ # @return [String] a string of modules that can be subsequently prepended to a class, to create a
14
+ # constantized class.
15
+ #
16
+ # @raise [ArgumentError] if the command_namespace is not of type String, Hash or Array.
17
+ #
18
+ # @example
19
+ #
20
+ # to_class_modules_string("Api") # => "Api::"
21
+ # to_class_modules_string([:Api, :AppName, :V1]) # => "Api::AppName::V1::"
22
+ # to_class_modules_string({ api: :Api, app_name: :AppName, api_version: :V1 }) # => "Api::AppName::V1::"
23
+ # to_class_modules_string({ api: :api, app_name: :app_name, api_version: :v1 }, { titleize_module: true })
24
+ # # => "Api::AppName::V1::"
25
+ #
26
+ class CommandNamespaceService
27
+ include Helpers::Camelize
28
+ include Helpers::TrimAll
29
+
30
+ def initialize(command_namespace:)
31
+ @command_namespace = command_namespace
32
+ end
33
+
34
+ # Handles command module transformations from String, Hash or Array into
35
+ # a fully qualified class modules string (e.g. "A::B::C::").
36
+ def to_class_modules_string
37
+ return '' if command_namespace.blank?
38
+
39
+ class_modules_string = join_class_modules_if(command_namespace:)
40
+ class_modules_string = trim_all(camelize(class_modules_string))
41
+ "#{class_modules_string}::"
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :command_namespace
47
+
48
+ def join_class_modules_if(command_namespace:)
49
+ case command_namespace
50
+ when String
51
+ command_namespace
52
+ when Array
53
+ command_namespace.join('::')
54
+ when Hash
55
+ command_namespace.values.join('::')
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative '../helpers/camelize'
5
+ require_relative 'command_namespace_service'
6
+
7
+ module SimpleCommandDispatcher
8
+ module Services
9
+ # Handles class and module transformations and instantiation.
10
+ class CommandService
11
+ include Helpers::Camelize
12
+
13
+ def initialize(command:, command_namespace: {})
14
+ @command = validate_command(command:)
15
+ @command_namespace = validate_command_namespace(command_namespace:)
16
+ end
17
+
18
+ # Returns a constantized class (as a Class constant), given the command and command_namespace
19
+ # that were provided during initialization.
20
+ #
21
+ # @return [Class] the class constant.
22
+ #
23
+ # @raise [Errors::InvalidClassConstantError] if the constantized class string cannot be constantized; that is,
24
+ # if it is not a valid class constant.
25
+ #
26
+ # @example
27
+ #
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
+ # # => 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
34
+ #
35
+ def to_class
36
+ qualified_class_string = to_qualified_class_string(command, command_namespace)
37
+
38
+ begin
39
+ qualified_class_string.constantize
40
+ rescue StandardError => e
41
+ raise Errors::InvalidClassConstantError.new(qualified_class_string, e.message)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_accessor :command, :command_namespace
48
+
49
+ # 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
+ #
55
+ # @return [String] the fully qualified class, which includes module(s) and class name.
56
+ #
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
+ class_modules_string = CommandNamespaceService.new(command_namespace:).to_class_modules_string
66
+ class_string = to_class_string(command:)
67
+ "#{class_modules_string}#{class_string}"
68
+ end
69
+
70
+ # Returns the command as a string after transformations have been applied.
71
+ # The command is automatically camelized/titleized during processing.
72
+ #
73
+ # @param command [Symbol, String] the class name to be transformed.
74
+ #
75
+ # @return [String] the transformed class as a string.
76
+ #
77
+ # @example
78
+ #
79
+ # to_class_string(command: "MyClass") # => "MyClass"
80
+ # to_class_string(command: "my_class") # => "MyClass"
81
+ # to_class_string(command: :MyClass) # => "MyClass"
82
+ # to_class_string(command: :my_class) # => "MyClass"
83
+ #
84
+ def to_class_string(command:)
85
+ camelize(command)
86
+ end
87
+
88
+ # @!visibility public
89
+ #
90
+ # Validates command and returns command as a string after all blanks have been removed using
91
+ # command.gsub(/\s+/, "").
92
+ #
93
+ # @param [Symbol or String] command the class name to be validated. command cannot be empty?
94
+ #
95
+ # @return [String] the validated class as a string with blanks removed.
96
+ #
97
+ # @raise [ArgumentError] if the command is empty? or not of type String or Symbol.
98
+ #
99
+ # @example
100
+ #
101
+ # validate_command(" My Class ") # => "MyClass"
102
+ # validate_command(:MyClass) # => "MyClass"
103
+ #
104
+ def validate_command(command:)
105
+ unless command.is_a?(Symbol) || command.is_a?(String)
106
+ raise ArgumentError,
107
+ 'command is not a String or Symbol. command must equal the class name of the ' \
108
+ 'command to call in the form of a String or Symbol.'
109
+ end
110
+
111
+ command = command.to_s.strip
112
+
113
+ raise ArgumentError, 'command is empty?' if command.empty?
114
+
115
+ command
116
+ end
117
+
118
+ # @!visibility public
119
+ #
120
+ # Validates and returns command_namespace.
121
+ #
122
+ # @param [Hash, Array or String] command_namespace the module(s) to be validated.
123
+ #
124
+ # @return [Hash, Array or String] the validated module(s).
125
+ #
126
+ # @raise [ArgumentError] if the command_namespace is not of type String, Hash or Array.
127
+ #
128
+ # @example
129
+ #
130
+ # validate_command_namespace(" Module ") # => " Module "
131
+ # validate_command_namespace(:Module) # => :Module
132
+ # validate_command_namespace("ModuleA::ModuleB") # => "ModuleA::ModuleB"
133
+ #
134
+ def validate_command_namespace(command_namespace:)
135
+ return {} if command_namespace.blank?
136
+
137
+ unless valid_command_namespace_type?(command_namespace:)
138
+ raise ArgumentError,
139
+ 'Argument command_namespace is not a String, Hash or Array.'
140
+ end
141
+
142
+ command_namespace
143
+ end
144
+
145
+ def valid_command_namespace_type?(command_namespace:)
146
+ command_namespace.instance_of?(String) ||
147
+ command_namespace.instance_of?(Hash) ||
148
+ command_namespace.instance_of?(Array)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SimpleCommand
4
- module Dispatcher
5
- VERSION = '3.0.4'
6
- end
3
+ module SimpleCommandDispatcher
4
+ VERSION = '4.1.0'
7
5
  end