cmdx 0.5.0 → 1.0.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/.cursor/rules/cursor-instructions.mdc +6 -0
  4. data/.rubocop.yml +16 -1
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +31 -1
  7. data/README.md +72 -25
  8. data/docs/ai_prompts.md +309 -0
  9. data/docs/basics/call.md +225 -14
  10. data/docs/basics/chain.md +271 -0
  11. data/docs/basics/context.md +232 -33
  12. data/docs/basics/setup.md +76 -12
  13. data/docs/callbacks.md +273 -0
  14. data/docs/configuration.md +158 -28
  15. data/docs/getting_started.md +134 -22
  16. data/docs/interruptions/exceptions.md +189 -11
  17. data/docs/interruptions/faults.md +187 -44
  18. data/docs/interruptions/halt.md +179 -35
  19. data/docs/logging.md +194 -53
  20. data/docs/middlewares.md +735 -0
  21. data/docs/outcomes/result.md +296 -10
  22. data/docs/outcomes/states.md +203 -31
  23. data/docs/outcomes/statuses.md +275 -30
  24. data/docs/parameters/coercions.md +402 -29
  25. data/docs/parameters/defaults.md +249 -25
  26. data/docs/parameters/definitions.md +238 -72
  27. data/docs/parameters/namespacing.md +250 -27
  28. data/docs/parameters/validations.md +193 -168
  29. data/docs/testing.md +550 -0
  30. data/docs/tips_and_tricks.md +95 -43
  31. data/docs/workflows.md +319 -0
  32. data/lib/cmdx/.DS_Store +0 -0
  33. data/lib/cmdx/callback.rb +69 -0
  34. data/lib/cmdx/callback_registry.rb +106 -0
  35. data/lib/cmdx/chain.rb +190 -0
  36. data/lib/cmdx/chain_inspector.rb +149 -0
  37. data/lib/cmdx/chain_serializer.rb +175 -0
  38. data/lib/cmdx/coercions/array.rb +37 -0
  39. data/lib/cmdx/coercions/big_decimal.rb +33 -0
  40. data/lib/cmdx/coercions/boolean.rb +41 -1
  41. data/lib/cmdx/coercions/complex.rb +31 -0
  42. data/lib/cmdx/coercions/date.rb +39 -0
  43. data/lib/cmdx/coercions/date_time.rb +39 -0
  44. data/lib/cmdx/coercions/float.rb +31 -0
  45. data/lib/cmdx/coercions/hash.rb +42 -0
  46. data/lib/cmdx/coercions/integer.rb +32 -0
  47. data/lib/cmdx/coercions/rational.rb +31 -0
  48. data/lib/cmdx/coercions/string.rb +31 -0
  49. data/lib/cmdx/coercions/time.rb +39 -0
  50. data/lib/cmdx/coercions/virtual.rb +31 -0
  51. data/lib/cmdx/configuration.rb +217 -9
  52. data/lib/cmdx/context.rb +173 -2
  53. data/lib/cmdx/core_ext/hash.rb +72 -0
  54. data/lib/cmdx/core_ext/module.rb +94 -0
  55. data/lib/cmdx/core_ext/object.rb +105 -0
  56. data/lib/cmdx/correlator.rb +217 -0
  57. data/lib/cmdx/error.rb +210 -8
  58. data/lib/cmdx/errors.rb +256 -1
  59. data/lib/cmdx/fault.rb +177 -2
  60. data/lib/cmdx/faults.rb +158 -2
  61. data/lib/cmdx/immutator.rb +121 -2
  62. data/lib/cmdx/lazy_struct.rb +261 -18
  63. data/lib/cmdx/log_formatters/json.rb +46 -0
  64. data/lib/cmdx/log_formatters/key_value.rb +46 -0
  65. data/lib/cmdx/log_formatters/line.rb +54 -0
  66. data/lib/cmdx/log_formatters/logstash.rb +64 -0
  67. data/lib/cmdx/log_formatters/pretty_json.rb +57 -0
  68. data/lib/cmdx/log_formatters/pretty_key_value.rb +51 -0
  69. data/lib/cmdx/log_formatters/pretty_line.rb +60 -0
  70. data/lib/cmdx/log_formatters/raw.rb +54 -0
  71. data/lib/cmdx/logger.rb +85 -0
  72. data/lib/cmdx/logger_ansi.rb +93 -7
  73. data/lib/cmdx/logger_serializer.rb +116 -0
  74. data/lib/cmdx/middleware.rb +74 -0
  75. data/lib/cmdx/middleware_registry.rb +106 -0
  76. data/lib/cmdx/middlewares/correlate.rb +266 -0
  77. data/lib/cmdx/middlewares/timeout.rb +232 -0
  78. data/lib/cmdx/parameter.rb +228 -1
  79. data/lib/cmdx/parameter_inspector.rb +61 -0
  80. data/lib/cmdx/parameter_registry.rb +125 -0
  81. data/lib/cmdx/parameter_serializer.rb +83 -0
  82. data/lib/cmdx/parameter_validator.rb +62 -0
  83. data/lib/cmdx/parameter_value.rb +109 -1
  84. data/lib/cmdx/parameters_inspector.rb +59 -0
  85. data/lib/cmdx/parameters_serializer.rb +102 -0
  86. data/lib/cmdx/railtie.rb +123 -3
  87. data/lib/cmdx/result.rb +367 -25
  88. data/lib/cmdx/result_ansi.rb +105 -9
  89. data/lib/cmdx/result_inspector.rb +76 -0
  90. data/lib/cmdx/result_logger.rb +90 -3
  91. data/lib/cmdx/result_serializer.rb +137 -0
  92. data/lib/cmdx/rspec/result_matchers.rb +917 -0
  93. data/lib/cmdx/rspec/task_matchers.rb +570 -0
  94. data/lib/cmdx/task.rb +405 -37
  95. data/lib/cmdx/task_serializer.rb +74 -2
  96. data/lib/cmdx/utils/ansi_color.rb +95 -0
  97. data/lib/cmdx/utils/log_timestamp.rb +48 -0
  98. data/lib/cmdx/utils/monotonic_runtime.rb +71 -4
  99. data/lib/cmdx/utils/name_affix.rb +78 -0
  100. data/lib/cmdx/validators/custom.rb +82 -0
  101. data/lib/cmdx/validators/exclusion.rb +94 -0
  102. data/lib/cmdx/validators/format.rb +102 -8
  103. data/lib/cmdx/validators/inclusion.rb +104 -0
  104. data/lib/cmdx/validators/length.rb +128 -0
  105. data/lib/cmdx/validators/numeric.rb +128 -0
  106. data/lib/cmdx/validators/presence.rb +93 -7
  107. data/lib/cmdx/version.rb +7 -1
  108. data/lib/cmdx/workflow.rb +394 -0
  109. data/lib/cmdx.rb +25 -64
  110. data/lib/generators/cmdx/install_generator.rb +37 -1
  111. data/lib/generators/cmdx/task_generator.rb +69 -1
  112. data/lib/generators/cmdx/templates/install.rb +8 -12
  113. data/lib/generators/cmdx/workflow_generator.rb +109 -0
  114. metadata +54 -15
  115. data/docs/basics/run.md +0 -34
  116. data/docs/batch.md +0 -53
  117. data/docs/example.md +0 -82
  118. data/docs/hooks.md +0 -62
  119. data/lib/cmdx/batch.rb +0 -43
  120. data/lib/cmdx/parameters.rb +0 -35
  121. data/lib/cmdx/run.rb +0 -39
  122. data/lib/cmdx/run_inspector.rb +0 -26
  123. data/lib/cmdx/run_serializer.rb +0 -20
  124. data/lib/cmdx/task_hook.rb +0 -18
  125. data/lib/generators/cmdx/batch_generator.rb +0 -30
  126. /data/lib/generators/cmdx/templates/{batch.rb.tt → workflow.rb.tt} +0 -0
@@ -1,22 +1,108 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
+ # ANSI color formatting module for logger severity levels.
5
+ #
6
+ # The LoggerAnsi module provides ANSI color formatting for log severity levels
7
+ # to enhance readability in terminal output. Each severity level is assigned
8
+ # a specific color and bold formatting to make log messages more visually
9
+ # distinguishable.
10
+ #
11
+ # @example Basic severity colorization
12
+ # LoggerAnsi.call("DEBUG message") # => Blue bold text
13
+ # LoggerAnsi.call("INFO message") # => Green bold text
14
+ # LoggerAnsi.call("WARN message") # => Yellow bold text
15
+ # LoggerAnsi.call("ERROR message") # => Red bold text
16
+ # LoggerAnsi.call("FATAL message") # => Magenta bold text
17
+ #
18
+ # @example Usage in log formatters
19
+ # class CustomFormatter
20
+ # def call(severity, time, progname, msg)
21
+ # colored_severity = LoggerAnsi.call(severity)
22
+ # "#{colored_severity} #{msg}"
23
+ # end
24
+ # end
25
+ #
26
+ # @example Integration with pretty formatters
27
+ # # Used internally by PrettyLine, PrettyJson, PrettyKeyValue formatters
28
+ # formatted_severity = LoggerAnsi.call("ERROR") # => Red bold "ERROR"
29
+ #
30
+ # @see CMDx::Utils::AnsiColor ANSI color utility functions
31
+ # @see CMDx::LogFormatters::PrettyLine Pretty line formatter with colors
32
+ # @see CMDx::LogFormatters::PrettyJson Pretty JSON formatter with colors
4
33
  module LoggerAnsi
5
34
 
35
+ # Mapping of log severity levels to ANSI colors.
36
+ #
37
+ # Maps the first character of severity levels to their corresponding
38
+ # color codes for consistent visual representation across log output.
6
39
  SEVERITY_COLORS = {
7
- "D" => :blue,
8
- "I" => :green,
9
- "W" => :yellow,
10
- "E" => :red,
11
- "F" => :magenta
40
+ "D" => :blue, # DEBUG
41
+ "I" => :green, # INFO
42
+ "W" => :yellow, # WARN
43
+ "E" => :red, # ERROR
44
+ "F" => :magenta # FATAL
12
45
  }.freeze
13
46
 
14
47
  module_function
15
48
 
49
+ # Applies ANSI color formatting to a severity string.
50
+ #
51
+ # Formats the input string with appropriate ANSI color codes based on
52
+ # the first character of the string, which typically represents the
53
+ # log severity level. All formatted text is rendered in bold.
54
+ #
55
+ # @param s [String] The severity string to colorize
56
+ # @return [String] The string with ANSI color codes applied
57
+ #
58
+ # @example Colorizing different severity levels
59
+ # LoggerAnsi.call("DEBUG") # => "\e[1;34mDEBUG\e[0m" (blue bold)
60
+ # LoggerAnsi.call("INFO") # => "\e[1;32mINFO\e[0m" (green bold)
61
+ # LoggerAnsi.call("WARN") # => "\e[1;33mWARN\e[0m" (yellow bold)
62
+ # LoggerAnsi.call("ERROR") # => "\e[1;31mERROR\e[0m" (red bold)
63
+ # LoggerAnsi.call("FATAL") # => "\e[1;35mFATAL\e[0m" (magenta bold)
64
+ #
65
+ # @example Unknown severity level
66
+ # LoggerAnsi.call("CUSTOM") # => "\e[1;39mCUSTOM\e[0m" (default color bold)
67
+ #
68
+ # @example Full log message formatting
69
+ # severity = "ERROR"
70
+ # message = "Task failed with validation errors"
71
+ # colored_severity = LoggerAnsi.call(severity)
72
+ # log_line = "#{colored_severity}: #{message}"
73
+ # # => "\e[1;31mERROR\e[0m: Task failed with validation errors"
16
74
  def call(s)
17
- color = SEVERITY_COLORS[s[0]] || :default
75
+ Utils::AnsiColor.call(s, color: color(s), mode: :bold)
76
+ end
18
77
 
19
- Utils::AnsiColor.call(s, color:, mode: :bold)
78
+ # Determines the appropriate color for a severity string.
79
+ #
80
+ # Extracts the first character from the severity string and maps it to
81
+ # the corresponding color symbol using the SEVERITY_COLORS hash. If no
82
+ # mapping is found for the first character, returns the default color.
83
+ #
84
+ # @param s [String] The severity string to determine color for
85
+ # @return [Symbol] The color symbol for the severity level
86
+ #
87
+ # @example Mapping severity levels to colors
88
+ # color("DEBUG") # => :blue
89
+ # color("INFO") # => :green
90
+ # color("WARN") # => :yellow
91
+ # color("ERROR") # => :red
92
+ # color("FATAL") # => :magenta
93
+ #
94
+ # @example Unknown severity level
95
+ # color("CUSTOM") # => :default
96
+ # color("TRACE") # => :default
97
+ #
98
+ # @example Case sensitivity
99
+ # color("debug") # => :default (lowercase 'd' not mapped)
100
+ # color("Debug") # => :blue (uppercase 'D' is mapped)
101
+ #
102
+ # @note This method only considers the first character of the input string
103
+ # @see SEVERITY_COLORS The mapping hash used for color determination
104
+ def color(s)
105
+ SEVERITY_COLORS[s[0]] || :default
20
106
  end
21
107
 
22
108
  end
@@ -1,14 +1,130 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
+ # Logger message serialization module for structured log output.
5
+ #
6
+ # The LoggerSerializer module provides functionality to serialize log messages
7
+ # into structured hash format suitable for various log formatters. It handles
8
+ # different message types including Result objects and plain messages, with
9
+ # optional ANSI colorization for terminal output.
10
+ #
11
+ # @example Basic message serialization
12
+ # task = ProcessOrderTask.new
13
+ # message = "Processing order 123"
14
+ #
15
+ # LoggerSerializer.call(:info, Time.now, task, message)
16
+ # # => {
17
+ # # origin: "CMDx",
18
+ # # index: 0,
19
+ # # chain_id: "...",
20
+ # # type: "Task",
21
+ # # class: "ProcessOrderTask",
22
+ # # id: "...",
23
+ # # tags: [],
24
+ # # message: "Processing order 123"
25
+ # # }
26
+ #
27
+ # @example Result object serialization
28
+ # result = task.result # CMDx::Result instance
29
+ #
30
+ # LoggerSerializer.call(:info, Time.now, task, result)
31
+ # # => {
32
+ # # origin: "CMDx",
33
+ # # state: "complete",
34
+ # # status: "success",
35
+ # # outcome: "success",
36
+ # # metadata: {},
37
+ # # runtime: 0.5,
38
+ # # index: 0,
39
+ # # chain_id: "...",
40
+ # # # ... other result data
41
+ # # }
42
+ #
43
+ # @example Colorized result serialization
44
+ # LoggerSerializer.call(:info, Time.now, task, result, ansi_colorize: true)
45
+ # # => Same as above but with ANSI color codes in state/status/outcome values
46
+ #
47
+ # @see CMDx::Result Result object structure and data
48
+ # @see CMDx::TaskSerializer Task serialization functionality
49
+ # @see CMDx::ResultAnsi Result ANSI colorization
4
50
  module LoggerSerializer
5
51
 
52
+ # Keys that should be colorized when ANSI colorization is enabled.
53
+ #
54
+ # These keys represent result state information that benefits from
55
+ # color coding in terminal output for better visual distinction.
6
56
  COLORED_KEYS = %i[
7
57
  state status outcome
8
58
  ].freeze
9
59
 
10
60
  module_function
11
61
 
62
+ # Serializes a log message into a structured hash format.
63
+ #
64
+ # Converts log messages into hash format suitable for structured logging.
65
+ # Handles both Result objects and plain messages differently, with optional
66
+ # ANSI colorization for terminal-friendly output.
67
+ #
68
+ # @param _severity [Symbol] Log severity level (not used in current implementation)
69
+ # @param _time [Time] Log timestamp (not used in current implementation)
70
+ # @param task [CMDx::Task] The task instance generating the log message
71
+ # @param message [Object] The message to serialize (Result object or other)
72
+ # @param options [Hash] Serialization options
73
+ # @option options [Boolean] :ansi_colorize (false) Whether to apply ANSI colors
74
+ # @return [Hash] Structured hash representation of the log message
75
+ #
76
+ # @example Plain message serialization
77
+ # LoggerSerializer.call(:info, Time.now, task, "Task started")
78
+ # # => {
79
+ # # origin: "CMDx",
80
+ # # index: 0,
81
+ # # chain_id: "018c2b95-b764-7615-a924-cc5b910ed1e5",
82
+ # # type: "Task",
83
+ # # class: "MyTask",
84
+ # # id: "018c2b95-b764-7615-a924-cc5b910ed1e5",
85
+ # # tags: [],
86
+ # # message: "Task started"
87
+ # # }
88
+ #
89
+ # @example Result object serialization
90
+ # result = CMDx::Result.new(task)
91
+ # result.complete!
92
+ #
93
+ # LoggerSerializer.call(:info, Time.now, task, result)
94
+ # # => {
95
+ # # origin: "CMDx",
96
+ # # state: "complete",
97
+ # # status: "success",
98
+ # # outcome: "success",
99
+ # # metadata: {},
100
+ # # runtime: 0.001,
101
+ # # index: 0,
102
+ # # chain_id: "018c2b95-b764-7615-a924-cc5b910ed1e5",
103
+ # # type: "Task",
104
+ # # class: "MyTask",
105
+ # # id: "018c2b95-b764-7615-a924-cc5b910ed1e5",
106
+ # # tags: []
107
+ # # }
108
+ #
109
+ # @example Colorized result serialization
110
+ # LoggerSerializer.call(:info, Time.now, task, result, ansi_colorize: true)
111
+ # # => Same as above but state/status/outcome values contain ANSI color codes
112
+ # # => { state: "\e[32mcomplete\e[0m", status: "\e[32msuccess\e[0m", ... }
113
+ #
114
+ # @example Hash-like message object
115
+ # custom_message = OpenStruct.new(action: "process", item_id: 123)
116
+ # LoggerSerializer.call(:debug, Time.now, task, custom_message)
117
+ # # => {
118
+ # # origin: "CMDx",
119
+ # # action: "process",
120
+ # # item_id: 123,
121
+ # # index: 0,
122
+ # # chain_id: "...",
123
+ # # type: "Task",
124
+ # # class: "MyTask",
125
+ # # id: "...",
126
+ # # tags: []
127
+ # # }
12
128
  def call(_severity, _time, task, message, **options)
13
129
  m = message.respond_to?(:to_h) ? message.to_h : {}
14
130
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ ##
5
+ # Base class for CMDx middleware that follows Rack-style interface.
6
+ #
7
+ # Middleware components can wrap task execution to provide cross-cutting
8
+ # concerns like logging, authentication, caching, or error handling.
9
+ # Each middleware must implement the `call` method which receives the
10
+ # task instance and a callable that represents the next middleware
11
+ # in the chain.
12
+ #
13
+ # @example Basic middleware implementation
14
+ # class LoggingMiddleware < CMDx::Middleware
15
+ # def call(task, callable)
16
+ # puts "Before executing #{task.class.name}"
17
+ # result = callable.call(task)
18
+ # puts "After executing #{task.class.name}"
19
+ # result
20
+ # end
21
+ # end
22
+ #
23
+ # @example Middleware with initialization parameters
24
+ # class AuthenticationMiddleware < CMDx::Middleware
25
+ # def initialize(required_role)
26
+ # @required_role = required_role
27
+ # end
28
+ #
29
+ # def call(task, callable)
30
+ # unless task.context.user&.has_role?(@required_role)
31
+ # task.fail!(reason: "Insufficient permissions")
32
+ # return task.result
33
+ # end
34
+ # callable.call(task)
35
+ # end
36
+ # end
37
+ #
38
+ # @example Short-circuiting middleware
39
+ # class CachingMiddleware < CMDx::Middleware
40
+ # def call(task, callable)
41
+ # cache_key = "#{task.class.name}:#{task.context.to_h.hash}"
42
+ #
43
+ # if cached_result = Rails.cache.read(cache_key)
44
+ # task.result.merge!(cached_result)
45
+ # return task.result
46
+ # end
47
+ #
48
+ # result = callable.call(task)
49
+ # Rails.cache.write(cache_key, result.to_h) if result.success?
50
+ # result
51
+ # end
52
+ # end
53
+ #
54
+ # @see MiddlewareRegistry management
55
+ # @see Task middleware integration
56
+ class Middleware
57
+
58
+ ##
59
+ # Executes the middleware logic.
60
+ #
61
+ # This method must be implemented by subclasses to define the middleware
62
+ # behavior. The method receives the task instance and a callable that
63
+ # represents the next middleware in the chain or the final task execution.
64
+ #
65
+ # @param task [Task] the task instance being executed
66
+ # @param callable [#call] the next middleware or task execution callable
67
+ # @return [Result] the task execution result
68
+ # @abstract Subclasses must implement this method
69
+ def call(_task, _callable)
70
+ raise UndefinedCallError, "call method not defined in #{self.class.name}"
71
+ end
72
+
73
+ end
74
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ ##
5
+ # The MiddlewareRegistry collection provides a Rack-style middleware chain that wraps
6
+ # task execution with cross-cutting concerns like logging, authentication,
7
+ # caching, and more. Middleware can short-circuit execution by returning
8
+ # early without calling the next middleware in the chain.
9
+ #
10
+ # The MiddlewareRegistry collection extends Array to provide specialized functionality for
11
+ # managing collections of middleware definitions within CMDx tasks. It handles
12
+ # middleware execution coordination, chaining, and inspection.
13
+ #
14
+ # @example Basic middleware usage
15
+ # middleware_registry = MiddlewareRegistry.new
16
+ # middleware_registry.use(LoggingMiddleware)
17
+ # middleware_registry.use(AuthenticationMiddleware, required_role: :admin)
18
+ # middleware_registry.use(CachingMiddleware, ttl: 300)
19
+ #
20
+ # result = middleware_registry.call(task) do |t|
21
+ # t.call
22
+ # t.result
23
+ # end
24
+ #
25
+ # @example Array-like operations
26
+ # middleware_registry << [LoggingMiddleware, [], nil]
27
+ # middleware_registry.size # => 1
28
+ # middleware_registry.empty? # => false
29
+ # middleware_registry.each { |middleware| puts middleware.inspect }
30
+ #
31
+ # @example Using proc middleware
32
+ # middleware_registry.use(proc do |task, callable|
33
+ # puts "Before task execution"
34
+ # result = callable.call(task)
35
+ # puts "After task execution"
36
+ # result
37
+ # end)
38
+ #
39
+ # @see Middleware Base middleware class
40
+ # @since 1.0.0
41
+ class MiddlewareRegistry < Array
42
+
43
+ # Adds middleware to the registry.
44
+ #
45
+ # @param middleware [Class, Object, Proc] The middleware to add
46
+ # @param args [Array] Arguments to pass to middleware constructor
47
+ # @param block [Proc] Block to pass to middleware constructor
48
+ # @return [MiddlewareRegistry] self for method chaining
49
+ #
50
+ # @example Add middleware class
51
+ # registry.use(LoggingMiddleware, log_level: :info)
52
+ #
53
+ # @example Add middleware instance
54
+ # registry.use(LoggingMiddleware.new(log_level: :info))
55
+ #
56
+ # @example Add proc middleware
57
+ # registry.use(proc { |task, callable| callable.call(task) })
58
+ def use(middleware, *args, &block)
59
+ self << [middleware, args, block]
60
+ self
61
+ end
62
+
63
+ # Executes the middleware chain around the given block.
64
+ #
65
+ # @param task [Task] The task instance to pass through middleware
66
+ # @yield [Task] The task instance for final execution
67
+ # @yieldreturn [Object] The result of task execution
68
+ # @return [Object] The result from the middleware chain
69
+ #
70
+ # @example Execute with middleware
71
+ # result = registry.call(task) do |t|
72
+ # t.call
73
+ # t.result
74
+ # end
75
+ def call(task, &)
76
+ return yield(task) if empty?
77
+
78
+ build_chain(&).call(task)
79
+ end
80
+
81
+ private
82
+
83
+ # Builds the middleware call chain.
84
+ #
85
+ # Creates a nested chain of callables where each middleware wraps the next,
86
+ # with the provided block as the innermost callable.
87
+ #
88
+ # @param block [Proc] The final block to execute
89
+ # @return [Proc] The middleware chain as a callable
90
+ def build_chain(&block)
91
+ reverse.reduce(block) do |next_callable, (middleware, args, middleware_block)|
92
+ proc do |task|
93
+ if middleware.respond_to?(:call) && !middleware.respond_to?(:new)
94
+ # Proc middleware
95
+ middleware.call(task, next_callable)
96
+ else
97
+ # Class or instance middleware
98
+ instance = middleware.respond_to?(:new) ? middleware.new(*args, &middleware_block) : middleware
99
+ instance.call(task, next_callable)
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ end
106
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ module Middlewares
5
+ ##
6
+ # Correlation middleware for ensuring consistent correlation ID context during task execution.
7
+ #
8
+ # The Correlate middleware establishes and maintains correlation ID context throughout
9
+ # task execution, enabling seamless request tracking across task boundaries. It ensures
10
+ # that all tasks within an execution chain share the same correlation identifier for
11
+ # comprehensive traceability and debugging.
12
+ #
13
+ # ## Correlation ID Precedence
14
+ #
15
+ # The middleware determines the correlation ID using the following precedence:
16
+ # 1. **Explicit correlation ID** - Value provided during middleware initialization
17
+ # 2. **Current thread correlation** - Existing correlation from `CMDx::Correlator.id`
18
+ # 3. **Chain identifier** - The task's chain ID if no thread correlation exists
19
+ # 4. **Generated UUID** - New correlation ID if none of the above is available
20
+ #
21
+ # ## Conditional Execution
22
+ #
23
+ # The middleware supports conditional execution using `:if` and `:unless` options:
24
+ # - `:if` - Only applies correlation when the condition evaluates to true
25
+ # - `:unless` - Only applies correlation when the condition evaluates to false
26
+ # - Conditions can be Procs, method symbols, or boolean values
27
+ #
28
+ # ## Thread Safety
29
+ #
30
+ # The middleware uses `CMDx::Correlator.use` to establish a correlation context
31
+ # that is automatically restored after task execution, ensuring thread-local
32
+ # isolation and proper cleanup even in case of exceptions.
33
+ #
34
+ # ## Integration with CMDx Framework
35
+ #
36
+ # - **Automatic activation**: Can be applied globally or per-task via `use` directive
37
+ # - **Chain integration**: Works seamlessly with CMDx::Chain correlation inheritance
38
+ # - **Nested tasks**: Maintains correlation context across nested task calls
39
+ # - **Exception safety**: Restores correlation context even when tasks fail
40
+ #
41
+ # @example Basic task-specific middleware application
42
+ # class ProcessOrderTask < CMDx::Task
43
+ # use CMDx::Middlewares::Correlate
44
+ #
45
+ # def call
46
+ # # Task execution maintains correlation context
47
+ # SendEmailTask.call(context) # Inherits same correlation
48
+ # end
49
+ # end
50
+ #
51
+ # @example Middleware with explicit correlation ID
52
+ # class ProcessOrderTask < CMDx::Task
53
+ # use CMDx::Middlewares::Correlate, id: "order-processing-123"
54
+ #
55
+ # def call
56
+ # # Always uses "order-processing-123" as correlation ID
57
+ # context.correlation_used = CMDx::Correlator.id
58
+ # end
59
+ # end
60
+ #
61
+ # result = ProcessOrderTask.call(order_id: 123)
62
+ # result.context.correlation_used # => "order-processing-123"
63
+ #
64
+ # @example Middleware with dynamic correlation ID using procs
65
+ # class ProcessOrderTask < CMDx::Task
66
+ # use CMDx::Middlewares::Correlate, id: -> { "order-#{order_id}-#{Time.now.to_i}" }
67
+ #
68
+ # def call
69
+ # # Uses dynamically generated correlation ID
70
+ # context.correlation_used = CMDx::Correlator.id
71
+ # end
72
+ # end
73
+ #
74
+ # result = ProcessOrderTask.call(order_id: 456)
75
+ # result.context.correlation_used # => "order-456-1703123456"
76
+ #
77
+ # @example Middleware with method-based correlation ID
78
+ # class ProcessOrderTask < CMDx::Task
79
+ # use CMDx::Middlewares::Correlate, id: :generate_order_correlation
80
+ #
81
+ # def call
82
+ # # Uses correlation ID from generate_order_correlation method
83
+ # context.correlation_used = CMDx::Correlator.id
84
+ # end
85
+ #
86
+ # private
87
+ #
88
+ # def generate_order_correlation
89
+ # "order-#{order_id}-#{context.request_id}"
90
+ # end
91
+ # end
92
+ #
93
+ # @example Conditional correlation based on environment
94
+ # class ProcessOrderTask < CMDx::Task
95
+ # use CMDx::Middlewares::Correlate, unless: -> { Rails.env.test? }
96
+ #
97
+ # def call
98
+ # # Correlation only applied in non-test environments
99
+ # context.order = Order.find(order_id)
100
+ # end
101
+ # end
102
+ #
103
+ # @example Conditional correlation based on task state
104
+ # class ProcessOrderTask < CMDx::Task
105
+ # use CMDx::Middlewares::Correlate, if: :correlation_required?
106
+ #
107
+ # def call
108
+ # # Correlation applied only when correlation_required? returns true
109
+ # context.order = Order.find(order_id)
110
+ # end
111
+ #
112
+ # private
113
+ #
114
+ # def correlation_required?
115
+ # context.tracking_enabled == true
116
+ # end
117
+ # end
118
+ #
119
+ # @example Nested task correlation propagation
120
+ # class ParentTask < CMDx::Task
121
+ # use CMDx::Middlewares::Correlate
122
+ #
123
+ # def call
124
+ # # Correlation established at parent level
125
+ # ChildTask.call(context)
126
+ # end
127
+ # end
128
+ #
129
+ # class ChildTask < CMDx::Task
130
+ # use CMDx::Middlewares::Correlate
131
+ #
132
+ # def call
133
+ # # Inherits parent's correlation ID
134
+ # context.child_correlation = CMDx::Correlator.id
135
+ # end
136
+ # end
137
+ #
138
+ # @example Exception handling with correlation restoration
139
+ # class RiskyTask < CMDx::Task
140
+ # use CMDx::Middlewares::Correlate
141
+ #
142
+ # def call
143
+ # raise StandardError, "Task failed"
144
+ # end
145
+ # end
146
+ #
147
+ # CMDx::Correlator.id = "original-correlation"
148
+ #
149
+ # begin
150
+ # RiskyTask.call
151
+ # rescue StandardError
152
+ # CMDx::Correlator.id # => "original-correlation" (properly restored)
153
+ # end
154
+ #
155
+ # @see CMDx::Correlator Thread-safe correlation ID management
156
+ # @see CMDx::Chain Chain execution context with correlation inheritance
157
+ # @see CMDx::Middleware Base middleware class
158
+ # @since 1.0.0
159
+ class Correlate < CMDx::Middleware
160
+
161
+ # @return [String, nil] The explicit correlation ID to use
162
+ # @return [Hash] The conditional options for correlation application
163
+ attr_reader :id, :conditional
164
+
165
+ ##
166
+ # Initializes the Correlate middleware with optional configuration.
167
+ #
168
+ # @param options [Hash] configuration options for the middleware
169
+ # @option options [String, Symbol, Proc] :id explicit correlation ID to use (takes precedence over all other sources)
170
+ # @option options [Proc, Symbol, Boolean] :if condition that must be true for middleware to execute
171
+ # @option options [Proc, Symbol, Boolean] :unless condition that must be false for middleware to execute
172
+ #
173
+ # @example Basic initialization
174
+ # middleware = CMDx::Middlewares::Correlate.new
175
+ #
176
+ # @example With explicit correlation ID
177
+ # middleware = CMDx::Middlewares::Correlate.new(id: "api-request-123")
178
+ #
179
+ # @example With conditional execution
180
+ # middleware = CMDx::Middlewares::Correlate.new(unless: -> { Rails.env.test? })
181
+ # middleware = CMDx::Middlewares::Correlate.new(if: :correlation_enabled?)
182
+ def initialize(options = {})
183
+ @id = options[:id]
184
+ @conditional = options.slice(:if, :unless)
185
+ end
186
+
187
+ ##
188
+ # Executes the task within a managed correlation context.
189
+ #
190
+ # First evaluates any conditional execution rules (`:if` or `:unless` options).
191
+ # If conditions allow execution, establishes a correlation ID using the
192
+ # precedence hierarchy and executes the task within that correlation context.
193
+ # The correlation ID is automatically restored after task completion, ensuring
194
+ # proper cleanup and thread isolation.
195
+ #
196
+ # The correlation ID determination follows this precedence:
197
+ # 1. Explicit correlation ID (provided during middleware initialization)
198
+ # - String/Symbol: Used as-is or called as method if task responds to it
199
+ # - Proc/Lambda: Executed in task context for dynamic generation
200
+ # 2. Current thread correlation (CMDx::Correlator.id)
201
+ # 3. Task's chain ID (task.chain.id)
202
+ # 4. Generated UUID (CMDx::Correlator.generate)
203
+ #
204
+ # @param task [CMDx::Task] the task instance to execute
205
+ # @param callable [#call] the callable that executes the task
206
+ # @return [CMDx::Result] the task execution result
207
+ #
208
+ # @example Basic middleware execution
209
+ # middleware = CMDx::Middlewares::Correlate.new
210
+ # task = ProcessOrderTask.new(order_id: 123)
211
+ # callable = -> { task.call }
212
+ #
213
+ # result = middleware.call(task, callable)
214
+ # # Task executed within correlation context
215
+ #
216
+ # @example Correlation ID precedence in action
217
+ # # Scenario 1: Explicit string correlation ID takes precedence
218
+ # middleware = CMDx::Middlewares::Correlate.new(id: "explicit-123")
219
+ # middleware.call(task, callable) # Uses "explicit-123"
220
+ #
221
+ # # Scenario 2: Dynamic correlation ID using proc
222
+ # middleware = CMDx::Middlewares::Correlate.new(id: -> { "dynamic-#{order_id}" })
223
+ # middleware.call(task, callable) # Uses result of proc execution
224
+ #
225
+ # # Scenario 3: Method-based correlation ID
226
+ # middleware = CMDx::Middlewares::Correlate.new(id: :correlation_method)
227
+ # middleware.call(task, callable) # Uses task.correlation_method if it exists
228
+ #
229
+ # # Scenario 4: Thread correlation when no explicit ID
230
+ # CMDx::Correlator.id = "thread-correlation"
231
+ # middleware = CMDx::Middlewares::Correlate.new
232
+ # middleware.call(task, callable) # Uses "thread-correlation"
233
+ #
234
+ # # Scenario 5: Chain ID when no explicit or thread correlation
235
+ # CMDx::Correlator.clear
236
+ # middleware.call(task, callable) # Uses task.chain.id
237
+ #
238
+ # # Scenario 6: Generated UUID when no other correlation exists
239
+ # CMDx::Correlator.clear
240
+ # # Assuming task.chain.id is nil
241
+ # middleware.call(task, callable) # Uses generated UUID
242
+ #
243
+ # @example Conditional execution
244
+ # # Middleware only executes in production
245
+ # middleware = CMDx::Middlewares::Correlate.new(if: -> { Rails.env.production? })
246
+ # result = middleware.call(task, callable)
247
+ # # Correlation applied only in production environment
248
+ def call(task, callable)
249
+ # Check if correlation should be applied based on conditions
250
+ return callable.call(task) unless task.__cmdx_eval(conditional)
251
+
252
+ # Get correlation ID using yield for dynamic generation
253
+ correlation_id = task.__cmdx_yield(id) ||
254
+ CMDx::Correlator.id ||
255
+ task.chain.id ||
256
+ CMDx::Correlator.generate
257
+
258
+ # Execute task with correlation context
259
+ CMDx::Correlator.use(correlation_id) do
260
+ callable.call(task)
261
+ end
262
+ end
263
+
264
+ end
265
+ end
266
+ end