cmdx 1.21.0 → 2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -1
- data/README.md +37 -24
- data/lib/cmdx/.DS_Store +0 -0
- data/lib/cmdx/callbacks.rb +179 -0
- data/lib/cmdx/chain.rb +78 -175
- data/lib/cmdx/coercions/array.rb +19 -33
- data/lib/cmdx/coercions/big_decimal.rb +12 -29
- data/lib/cmdx/coercions/boolean.rb +25 -45
- data/lib/cmdx/coercions/coerce.rb +32 -0
- data/lib/cmdx/coercions/complex.rb +12 -27
- data/lib/cmdx/coercions/date.rb +29 -33
- data/lib/cmdx/coercions/date_time.rb +29 -33
- data/lib/cmdx/coercions/float.rb +8 -29
- data/lib/cmdx/coercions/hash.rb +17 -43
- data/lib/cmdx/coercions/integer.rb +8 -32
- data/lib/cmdx/coercions/rational.rb +12 -33
- data/lib/cmdx/coercions/string.rb +6 -24
- data/lib/cmdx/coercions/symbol.rb +12 -26
- data/lib/cmdx/coercions/time.rb +31 -35
- data/lib/cmdx/coercions.rb +174 -0
- data/lib/cmdx/configuration.rb +45 -237
- data/lib/cmdx/context.rb +264 -243
- data/lib/cmdx/deprecation.rb +67 -0
- data/lib/cmdx/deprecators/error.rb +22 -0
- data/lib/cmdx/deprecators/log.rb +22 -0
- data/lib/cmdx/deprecators/warn.rb +21 -0
- data/lib/cmdx/deprecators.rb +101 -0
- data/lib/cmdx/errors.rb +145 -79
- data/lib/cmdx/executors/fiber.rb +42 -0
- data/lib/cmdx/executors/thread.rb +36 -0
- data/lib/cmdx/executors.rb +95 -0
- data/lib/cmdx/fault.rb +85 -78
- data/lib/cmdx/i18n_proxy.rb +104 -0
- data/lib/cmdx/input.rb +294 -0
- data/lib/cmdx/inputs.rb +218 -0
- data/lib/cmdx/log_formatters/json.rb +9 -20
- data/lib/cmdx/log_formatters/key_value.rb +10 -21
- data/lib/cmdx/log_formatters/line.rb +7 -19
- data/lib/cmdx/log_formatters/logstash.rb +8 -21
- data/lib/cmdx/log_formatters/raw.rb +8 -20
- data/lib/cmdx/logger_proxy.rb +30 -0
- data/lib/cmdx/mergers/deep_merge.rb +23 -0
- data/lib/cmdx/mergers/last_write_wins.rb +23 -0
- data/lib/cmdx/mergers/no_merge.rb +20 -0
- data/lib/cmdx/mergers.rb +95 -0
- data/lib/cmdx/middlewares.rb +128 -0
- data/lib/cmdx/output.rb +115 -0
- data/lib/cmdx/outputs.rb +66 -0
- data/lib/cmdx/pipeline.rb +144 -131
- data/lib/cmdx/railtie.rb +10 -36
- data/lib/cmdx/result.rb +247 -524
- data/lib/cmdx/retriers/bounded_random.rb +24 -0
- data/lib/cmdx/retriers/decorrelated_jitter.rb +28 -0
- data/lib/cmdx/retriers/exponential.rb +23 -0
- data/lib/cmdx/retriers/fibonacci.rb +39 -0
- data/lib/cmdx/retriers/full_random.rb +23 -0
- data/lib/cmdx/retriers/half_random.rb +24 -0
- data/lib/cmdx/retriers/linear.rb +23 -0
- data/lib/cmdx/retriers.rb +106 -0
- data/lib/cmdx/retry.rb +117 -138
- data/lib/cmdx/runtime.rb +251 -0
- data/lib/cmdx/settings.rb +68 -200
- data/lib/cmdx/signal.rb +165 -0
- data/lib/cmdx/task.rb +443 -343
- data/lib/cmdx/telemetry.rb +108 -0
- data/lib/cmdx/util.rb +73 -0
- data/lib/cmdx/validators/absence.rb +10 -39
- data/lib/cmdx/validators/exclusion.rb +33 -52
- data/lib/cmdx/validators/format.rb +19 -49
- data/lib/cmdx/validators/inclusion.rb +33 -54
- data/lib/cmdx/validators/length.rb +125 -127
- data/lib/cmdx/validators/numeric.rb +123 -123
- data/lib/cmdx/validators/presence.rb +10 -39
- data/lib/cmdx/validators/validate.rb +31 -0
- data/lib/cmdx/validators.rb +161 -0
- data/lib/cmdx/version.rb +2 -4
- data/lib/cmdx/workflow.rb +71 -96
- data/lib/cmdx.rb +111 -42
- data/lib/generators/cmdx/install_generator.rb +7 -17
- data/lib/generators/cmdx/task_generator.rb +12 -29
- data/lib/generators/cmdx/templates/install.rb +120 -48
- data/lib/generators/cmdx/templates/task.rb.tt +1 -1
- data/lib/generators/cmdx/templates/workflow.rb.tt +1 -2
- data/lib/generators/cmdx/workflow_generator.rb +12 -29
- data/lib/locales/en.yml +8 -7
- data/mkdocs.yml +25 -23
- metadata +39 -138
- data/lib/cmdx/attribute.rb +0 -440
- data/lib/cmdx/attribute_registry.rb +0 -185
- data/lib/cmdx/attribute_value.rb +0 -252
- data/lib/cmdx/callback_registry.rb +0 -169
- data/lib/cmdx/coercion_registry.rb +0 -138
- data/lib/cmdx/deprecator.rb +0 -77
- data/lib/cmdx/exception.rb +0 -46
- data/lib/cmdx/executor.rb +0 -378
- data/lib/cmdx/identifier.rb +0 -30
- data/lib/cmdx/locale.rb +0 -78
- data/lib/cmdx/middleware_registry.rb +0 -148
- data/lib/cmdx/middlewares/correlate.rb +0 -140
- data/lib/cmdx/middlewares/runtime.rb +0 -77
- data/lib/cmdx/middlewares/timeout.rb +0 -78
- data/lib/cmdx/parallelizer.rb +0 -100
- data/lib/cmdx/utils/call.rb +0 -53
- data/lib/cmdx/utils/condition.rb +0 -71
- data/lib/cmdx/utils/format.rb +0 -82
- data/lib/cmdx/utils/normalize.rb +0 -52
- data/lib/cmdx/utils/wrap.rb +0 -38
- data/lib/cmdx/validator_registry.rb +0 -143
- data/lib/generators/cmdx/locale_generator.rb +0 -39
- data/lib/locales/af.yml +0 -55
- data/lib/locales/ar.yml +0 -55
- data/lib/locales/az.yml +0 -55
- data/lib/locales/be.yml +0 -55
- data/lib/locales/bg.yml +0 -55
- data/lib/locales/bn.yml +0 -55
- data/lib/locales/bs.yml +0 -55
- data/lib/locales/ca.yml +0 -55
- data/lib/locales/cnr.yml +0 -55
- data/lib/locales/cs.yml +0 -55
- data/lib/locales/cy.yml +0 -55
- data/lib/locales/da.yml +0 -55
- data/lib/locales/de.yml +0 -55
- data/lib/locales/dz.yml +0 -55
- data/lib/locales/el.yml +0 -55
- data/lib/locales/eo.yml +0 -55
- data/lib/locales/es.yml +0 -55
- data/lib/locales/et.yml +0 -55
- data/lib/locales/eu.yml +0 -55
- data/lib/locales/fa.yml +0 -55
- data/lib/locales/fi.yml +0 -55
- data/lib/locales/fr.yml +0 -55
- data/lib/locales/fy.yml +0 -55
- data/lib/locales/gd.yml +0 -55
- data/lib/locales/gl.yml +0 -55
- data/lib/locales/he.yml +0 -55
- data/lib/locales/hi.yml +0 -55
- data/lib/locales/hr.yml +0 -55
- data/lib/locales/hu.yml +0 -55
- data/lib/locales/hy.yml +0 -55
- data/lib/locales/id.yml +0 -55
- data/lib/locales/is.yml +0 -55
- data/lib/locales/it.yml +0 -55
- data/lib/locales/ja.yml +0 -55
- data/lib/locales/ka.yml +0 -55
- data/lib/locales/kk.yml +0 -55
- data/lib/locales/km.yml +0 -55
- data/lib/locales/kn.yml +0 -55
- data/lib/locales/ko.yml +0 -55
- data/lib/locales/lb.yml +0 -55
- data/lib/locales/lo.yml +0 -55
- data/lib/locales/lt.yml +0 -55
- data/lib/locales/lv.yml +0 -55
- data/lib/locales/mg.yml +0 -55
- data/lib/locales/mk.yml +0 -55
- data/lib/locales/ml.yml +0 -55
- data/lib/locales/mn.yml +0 -55
- data/lib/locales/mr-IN.yml +0 -55
- data/lib/locales/ms.yml +0 -55
- data/lib/locales/nb.yml +0 -55
- data/lib/locales/ne.yml +0 -55
- data/lib/locales/nl.yml +0 -55
- data/lib/locales/nn.yml +0 -55
- data/lib/locales/oc.yml +0 -55
- data/lib/locales/or.yml +0 -55
- data/lib/locales/pa.yml +0 -55
- data/lib/locales/pl.yml +0 -55
- data/lib/locales/pt.yml +0 -55
- data/lib/locales/rm.yml +0 -55
- data/lib/locales/ro.yml +0 -55
- data/lib/locales/ru.yml +0 -55
- data/lib/locales/sc.yml +0 -55
- data/lib/locales/sk.yml +0 -55
- data/lib/locales/sl.yml +0 -55
- data/lib/locales/sq.yml +0 -55
- data/lib/locales/sr.yml +0 -55
- data/lib/locales/st.yml +0 -55
- data/lib/locales/sv.yml +0 -55
- data/lib/locales/sw.yml +0 -55
- data/lib/locales/ta.yml +0 -55
- data/lib/locales/te.yml +0 -55
- data/lib/locales/th.yml +0 -55
- data/lib/locales/tl.yml +0 -55
- data/lib/locales/tr.yml +0 -55
- data/lib/locales/tt.yml +0 -55
- data/lib/locales/ug.yml +0 -55
- data/lib/locales/uk.yml +0 -55
- data/lib/locales/ur.yml +0 -55
- data/lib/locales/uz.yml +0 -55
- data/lib/locales/vi.yml +0 -55
- data/lib/locales/wo.yml +0 -55
- data/lib/locales/zh-CN.yml +0 -55
- data/lib/locales/zh-HK.yml +0 -55
- data/lib/locales/zh-TW.yml +0 -55
- data/lib/locales/zh-YUE.yml +0 -55
data/lib/cmdx/fault.rb
CHANGED
|
@@ -1,109 +1,116 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
|
-
|
|
5
|
-
#
|
|
4
|
+
# Exception raised by `execute!` (strict mode) when a task fails. Carries
|
|
5
|
+
# the originating {Result} (deepest in any propagation chain) and exposes
|
|
6
|
+
# `task`, `signal`, `context`, and `chain` as delegators. The backtrace is
|
|
7
|
+
# cleaned through the configured `backtrace_cleaner` when present.
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# Each fault contains a reference to the result object that caused the fault.
|
|
9
|
+
# Use {.for?} or {.matches?} to build matcher subclasses suitable for
|
|
10
|
+
# `rescue` clauses.
|
|
10
11
|
class Fault < Error
|
|
11
12
|
|
|
12
|
-
extend Forwardable
|
|
13
|
-
|
|
14
|
-
# Returns the result that caused this fault.
|
|
15
|
-
#
|
|
16
|
-
# @return [Result] The result instance
|
|
17
|
-
#
|
|
18
|
-
# @example
|
|
19
|
-
# fault.result.reason # => "Validation failed"
|
|
20
|
-
#
|
|
21
|
-
# @rbs @result: Result
|
|
22
|
-
attr_reader :result
|
|
23
|
-
|
|
24
|
-
def_delegators :result, :task, :context, :chain
|
|
25
|
-
|
|
26
|
-
# Initialize a new fault with the given result.
|
|
27
|
-
#
|
|
28
|
-
# @param result [Result] the result object that caused this fault
|
|
29
|
-
#
|
|
30
|
-
# @raise [ArgumentError] if result is nil or invalid
|
|
31
|
-
#
|
|
32
|
-
# @example
|
|
33
|
-
# fault = Fault.new(task_result)
|
|
34
|
-
# fault.result.reason # => "Task validation failed"
|
|
35
|
-
#
|
|
36
|
-
# @rbs (Result result) -> void
|
|
37
|
-
def initialize(result)
|
|
38
|
-
@result = result
|
|
39
|
-
|
|
40
|
-
super(result.reason)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
13
|
class << self
|
|
44
14
|
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
15
|
+
# Returns a matcher subclass that matches Faults whose `task` is (or
|
|
16
|
+
# inherits from) any of the given task classes. Suitable for use in
|
|
17
|
+
# `rescue`.
|
|
48
18
|
#
|
|
49
|
-
# @
|
|
19
|
+
# @param tasks [Array<Class>] one or more Task classes
|
|
20
|
+
# @return [Class<Fault>] anonymous matcher subclass
|
|
21
|
+
# @raise [ArgumentError] when no tasks are given
|
|
50
22
|
#
|
|
51
23
|
# @example
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
24
|
+
# begin
|
|
25
|
+
# MyTask.execute!(ctx)
|
|
26
|
+
# rescue Fault.for?(ProcessOrder, ChargeCard) => fault
|
|
27
|
+
# Alert.for_fault(fault)
|
|
28
|
+
# end
|
|
56
29
|
def for?(*tasks)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
other.is_a?(superclass) && @tasks.any? { |task| other.task.is_a?(task) }
|
|
60
|
-
end
|
|
61
|
-
end
|
|
30
|
+
tasks = tasks.flatten
|
|
31
|
+
raise ArgumentError, "at least one task required" if tasks.empty?
|
|
62
32
|
|
|
63
|
-
|
|
33
|
+
matcher do |other|
|
|
34
|
+
tasks.any? { |task| other.task <= task }
|
|
35
|
+
end
|
|
64
36
|
end
|
|
65
37
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
# @param block [Proc] block that determines if a fault matches
|
|
38
|
+
# Returns a matcher subclass that matches Faults whose `result.reason`
|
|
39
|
+
# is equal to the given string. Suitable for use in `rescue`.
|
|
69
40
|
#
|
|
70
|
-
# @
|
|
71
|
-
#
|
|
72
|
-
# @raise [ArgumentError]
|
|
41
|
+
# @param reason [String] the reason to match
|
|
42
|
+
# @return [Class<Fault>] anonymous matcher subclass
|
|
43
|
+
# @raise [ArgumentError] when no reason is given
|
|
73
44
|
#
|
|
74
45
|
# @example
|
|
75
|
-
#
|
|
76
|
-
#
|
|
46
|
+
# begin
|
|
47
|
+
# MyTask.execute!(ctx)
|
|
48
|
+
# rescue Fault.reason?("Payment failed") => fault
|
|
49
|
+
# Alert.for_fault(fault)
|
|
50
|
+
# end
|
|
51
|
+
def reason?(reason)
|
|
52
|
+
raise ArgumentError, "reason required" unless reason
|
|
53
|
+
|
|
54
|
+
matcher do |other|
|
|
55
|
+
other.result.reason == reason
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a matcher subclass whose `===` runs `block` against the fault.
|
|
77
60
|
#
|
|
78
|
-
# @
|
|
61
|
+
# @param block [#call] `(fault) -> Boolean` matcher body
|
|
62
|
+
# @yieldparam fault [Fault]
|
|
63
|
+
# @yieldreturn [Boolean]
|
|
64
|
+
# @return [Class<Fault>] anonymous matcher subclass
|
|
65
|
+
# @raise [ArgumentError] when no block is given
|
|
79
66
|
def matches?(&block)
|
|
80
|
-
raise ArgumentError, "block required" unless
|
|
67
|
+
raise ArgumentError, "block required" unless block
|
|
81
68
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
69
|
+
matcher(&block)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def matcher(&)
|
|
75
|
+
fault_class = self
|
|
76
|
+
Class.new(fault_class) do
|
|
77
|
+
define_singleton_method(:===) do |other|
|
|
78
|
+
fault_class === other && yield(other)
|
|
85
79
|
end
|
|
86
80
|
end
|
|
87
|
-
|
|
88
|
-
temp_fault.tap { |c| c.instance_variable_set(:@block, block) }
|
|
89
81
|
end
|
|
90
82
|
|
|
91
83
|
end
|
|
92
84
|
|
|
93
|
-
|
|
85
|
+
attr_reader :result
|
|
94
86
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
# its current context or conditions. Skipped tasks are not considered failures
|
|
99
|
-
# but rather intentional bypasses of task execution logic.
|
|
100
|
-
SkipFault = Class.new(Fault)
|
|
87
|
+
# @param result [Result] the failed result this Fault represents
|
|
88
|
+
def initialize(result)
|
|
89
|
+
@result = result
|
|
101
90
|
|
|
102
|
-
|
|
103
|
-
#
|
|
104
|
-
# This fault occurs when a task encounters an error condition, validation failure,
|
|
105
|
-
# or any other condition that prevents successful completion. Failed tasks indicate
|
|
106
|
-
# that the intended operation could not be completed successfully.
|
|
107
|
-
FailFault = Class.new(Fault)
|
|
91
|
+
super(I18nProxy.tr(result.reason))
|
|
108
92
|
|
|
93
|
+
if (frames = result.backtrace || result.cause&.backtrace_locations)
|
|
94
|
+
frames = frames.map(&:to_s)
|
|
95
|
+
frames = task.settings.backtrace_cleaner&.call(frames) || frames
|
|
96
|
+
set_backtrace(frames)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Class<Task>] the failing task class
|
|
101
|
+
def task
|
|
102
|
+
@result.task
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Context] the failed task's context
|
|
106
|
+
def context
|
|
107
|
+
@result.context
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Chain] the chain the failed result belongs to
|
|
111
|
+
def chain
|
|
112
|
+
@result.chain
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
end
|
|
109
116
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Translation façade used internally for coercion, validator, and output
|
|
5
|
+
# error messages. Delegates to `I18n.translate` when the `i18n` gem is
|
|
6
|
+
# available; otherwise loads CMDx's bundled YAML locale file and performs
|
|
7
|
+
# percent-interpolation on the string itself. Results are memoized.
|
|
8
|
+
class I18nProxy
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
|
|
12
|
+
# @return [Array<String>] directories searched (in order) for bundled locale YAMLs
|
|
13
|
+
def locale_paths
|
|
14
|
+
@locale_paths ||= [File.expand_path("../locales", __dir__)]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param key [String, Symbol] dot-separated translation key
|
|
18
|
+
# @param options [Hash{Symbol => Object}] forwarded to `I18n.translate` or bundled interpolation
|
|
19
|
+
# @option options [Object] :default fallback when the bundled YAML lookup misses
|
|
20
|
+
# @option options [Hash{Symbol => Object}] extra keys interpolated via `String#%` for bundled translations
|
|
21
|
+
# @return [String, Object] the translated string (or the raw default value)
|
|
22
|
+
def translate(key, **options)
|
|
23
|
+
@proxy ||= new
|
|
24
|
+
@proxy.translate(key, **options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param (see .translate)
|
|
28
|
+
# @option (see .translate)
|
|
29
|
+
# @return (see .translate)
|
|
30
|
+
def t(key, **options)
|
|
31
|
+
translate(key, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Register an additional directory containing locale YAML files. Later
|
|
35
|
+
# registrations take precedence over earlier ones (the most recently
|
|
36
|
+
# registered path's values win during deep merge). Resets the memoized
|
|
37
|
+
# proxy so subsequent lookups see the new path.
|
|
38
|
+
#
|
|
39
|
+
# @param path [String] absolute path to a directory of `<locale>.yml` files
|
|
40
|
+
# @return [Array<String>] the updated locale paths
|
|
41
|
+
def register(path)
|
|
42
|
+
locale_paths.push(path) unless locale_paths.include?(path)
|
|
43
|
+
@proxy = nil
|
|
44
|
+
locale_paths
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Resolves a reason string through translation, falling back to either
|
|
48
|
+
# the literal reason (when present) or the `cmdx.reasons.unspecified`
|
|
49
|
+
# default (when nil).
|
|
50
|
+
#
|
|
51
|
+
# @param reason [String, Symbol, nil] reason text or translation key
|
|
52
|
+
# @return [String] translated message, literal reason, or default
|
|
53
|
+
def tr(reason)
|
|
54
|
+
translate(reason || "cmdx.reasons.unspecified", default: reason)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param key [String, Symbol] dot-separated translation key
|
|
60
|
+
# @param options [Hash{Symbol => Object}] interpolation values
|
|
61
|
+
# @option options [Object] :default fallback when the bundled YAML lookup misses
|
|
62
|
+
# @option options [Hash{Symbol => Object}] extra keys interpolated via `String#%` for bundled translations
|
|
63
|
+
# @return [String, Object] the translated/interpolated message
|
|
64
|
+
def translate(key, **options)
|
|
65
|
+
return ::I18n.translate(key, **options) if defined?(::I18n) && ::I18n.respond_to?(:translate)
|
|
66
|
+
|
|
67
|
+
message = translation_default(key) || options[:default]
|
|
68
|
+
|
|
69
|
+
case message
|
|
70
|
+
when String
|
|
71
|
+
message % options
|
|
72
|
+
when NilClass
|
|
73
|
+
"Translation missing: #{key}"
|
|
74
|
+
else
|
|
75
|
+
message
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
alias t translate
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# @param key [String, Symbol] lookup key (without locale prefix)
|
|
83
|
+
# @return [Object, nil] message template from bundled YAML, when present
|
|
84
|
+
def translation_default(key)
|
|
85
|
+
default_locale = CMDx.configuration.default_locale || "en"
|
|
86
|
+
translation_key = "#{default_locale}.#{key}"
|
|
87
|
+
|
|
88
|
+
@defaults ||= {}
|
|
89
|
+
return @defaults[translation_key] if @defaults.key?(translation_key)
|
|
90
|
+
|
|
91
|
+
@translations ||= {}
|
|
92
|
+
@translations[default_locale] ||= begin
|
|
93
|
+
file = "#{default_locale}.yml"
|
|
94
|
+
paths = self.class.locale_paths.map { |dir| File.join(dir, file) }.select { |p| File.exist?(p) }
|
|
95
|
+
raise LoadError, "unable to load #{default_locale} translations" if paths.empty?
|
|
96
|
+
|
|
97
|
+
paths.reduce({}) { |hash, path| hash.merge(YAML.safe_load_file(path)) }.freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@defaults[translation_key] = @translations[default_locale].dig(*translation_key.split("."))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/cmdx/input.rb
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# A single declared task input. Holds declaration options (`:source`,
|
|
5
|
+
# `:default`, `:required`, `:coerce`, validators, `:transform`, etc.) and
|
|
6
|
+
# owns the resolution pipeline that produces the value the task will read
|
|
7
|
+
# through the generated accessor.
|
|
8
|
+
class Input
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :children
|
|
11
|
+
|
|
12
|
+
# @param name [Symbol, String] input key (symbolized)
|
|
13
|
+
# @param children [Array<Input>] nested child inputs resolved from this one's value
|
|
14
|
+
# @param options [Hash{Symbol => Object}] declaration options
|
|
15
|
+
# @option options [String] :description (also accepts `:desc`)
|
|
16
|
+
# @option options [Symbol] :as overrides the accessor name
|
|
17
|
+
# @option options [Boolean, String] :prefix prefix for the accessor name
|
|
18
|
+
# @option options [Boolean, String] :suffix suffix for the accessor name
|
|
19
|
+
# @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from
|
|
20
|
+
# @option options [Object, Symbol, Proc, #call] :default
|
|
21
|
+
# @option options [Symbol, Proc, #call] :transform mutator applied after coercion
|
|
22
|
+
# @option options [Symbol, Proc, #call] :if
|
|
23
|
+
# @option options [Symbol, Proc, #call] :unless
|
|
24
|
+
# @option options [Boolean] :required
|
|
25
|
+
def initialize(name, children: EMPTY_ARRAY, **options)
|
|
26
|
+
@name = name.to_sym
|
|
27
|
+
@children = children.freeze
|
|
28
|
+
@options = options.freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String, nil]
|
|
32
|
+
def description
|
|
33
|
+
@options[:description] || @options[:desc]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Symbol, nil]
|
|
37
|
+
def as
|
|
38
|
+
@options[:as]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean, String, nil]
|
|
42
|
+
def prefix
|
|
43
|
+
@options[:prefix]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean, String, nil]
|
|
47
|
+
def suffix
|
|
48
|
+
@options[:suffix]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Symbol, Proc, #call]
|
|
52
|
+
def source
|
|
53
|
+
@options[:source] || :context
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Object, Symbol, Proc, #call, nil]
|
|
57
|
+
def default
|
|
58
|
+
@options[:default]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Symbol, Proc, #call, nil]
|
|
62
|
+
def transform
|
|
63
|
+
@options[:transform]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Symbol, Proc, #call, nil]
|
|
67
|
+
def condition_if
|
|
68
|
+
@options[:if]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Symbol, Proc, #call, nil]
|
|
72
|
+
def condition_unless
|
|
73
|
+
@options[:unless]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def required
|
|
78
|
+
@options.fetch(:required, false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Computed accessor/reader method name. Uses `:as` when provided,
|
|
82
|
+
# otherwise combines `:prefix`, `name`, and `:suffix` around the source.
|
|
83
|
+
#
|
|
84
|
+
# @return [Symbol]
|
|
85
|
+
def accessor_name
|
|
86
|
+
return as if as
|
|
87
|
+
|
|
88
|
+
@accessor_name ||= begin
|
|
89
|
+
prefix_str =
|
|
90
|
+
case prefix
|
|
91
|
+
when true
|
|
92
|
+
"#{source}_"
|
|
93
|
+
when ::String
|
|
94
|
+
prefix
|
|
95
|
+
end
|
|
96
|
+
suffix_str =
|
|
97
|
+
case suffix
|
|
98
|
+
when true
|
|
99
|
+
"_#{source}"
|
|
100
|
+
when ::String
|
|
101
|
+
suffix
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
:"#{prefix_str}#{name}#{suffix_str}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Symbol] backing ivar used by the generated reader method
|
|
109
|
+
def ivar_name
|
|
110
|
+
@ivar_name ||= :"@_input_#{accessor_name}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Evaluates required-ness against `task`, respecting `:if`/`:unless`.
|
|
114
|
+
# When called without a task, returns the static `:required` flag.
|
|
115
|
+
#
|
|
116
|
+
# @param task [Task, nil]
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def required?(task = nil)
|
|
119
|
+
return false unless required
|
|
120
|
+
return true if task.nil?
|
|
121
|
+
return false unless Util.satisfied?(condition_if, condition_unless, task)
|
|
122
|
+
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Fetches + coerces + transforms + validates the value from its
|
|
127
|
+
# configured `:source` on `task`. Missing-but-required inputs add a
|
|
128
|
+
# validation error to `task.errors`. Returns `nil` when coercion or any
|
|
129
|
+
# validator fails (the failure message is recorded on `task.errors`).
|
|
130
|
+
#
|
|
131
|
+
# @param task [Task]
|
|
132
|
+
# @return [Object, nil] the resolved value (`nil` on failure)
|
|
133
|
+
def resolve(task)
|
|
134
|
+
value, key_provided = resolve_with_key(task)
|
|
135
|
+
run_pipeline(value, key_provided, task)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Same as {#resolve} but fetches the value from `parent_value` (used for
|
|
139
|
+
# nested child inputs) instead of the declared `:source`.
|
|
140
|
+
#
|
|
141
|
+
# @param parent_value [#[], #key?, Object] the parent input's resolved value
|
|
142
|
+
# @param task [Task]
|
|
143
|
+
# @return [Object, nil]
|
|
144
|
+
def resolve_from_parent(parent_value, task)
|
|
145
|
+
value, key_provided = resolve_from_parent_with_key(parent_value)
|
|
146
|
+
run_pipeline(value, key_provided, task)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Hash{Symbol => Object}] serialized schema used by `inputs_schema`
|
|
150
|
+
def to_h
|
|
151
|
+
{
|
|
152
|
+
name: accessor_name,
|
|
153
|
+
description:,
|
|
154
|
+
required: required?,
|
|
155
|
+
options: @options,
|
|
156
|
+
children: children.map(&:to_h)
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
|
|
161
|
+
# callers (e.g. Rails).
|
|
162
|
+
#
|
|
163
|
+
# @return [Hash{Symbol => Object}]
|
|
164
|
+
def as_json(*)
|
|
165
|
+
to_h
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Serializes the input schema to a JSON string. Non-primitive entries in
|
|
169
|
+
# `:options` (Procs, arbitrary callables) emit via their stdlib `to_json`
|
|
170
|
+
# defaults.
|
|
171
|
+
#
|
|
172
|
+
# @param args [Array] forwarded to `Hash#to_json`
|
|
173
|
+
# @return [String]
|
|
174
|
+
def to_json(*args)
|
|
175
|
+
to_h.to_json(*args)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# @param value [Object] candidate after source resolution
|
|
181
|
+
# @param key_provided [Boolean] whether the source reported an explicit key/value pair
|
|
182
|
+
# @param task [Task]
|
|
183
|
+
# @return [Object, nil]
|
|
184
|
+
def run_pipeline(value, key_provided, task)
|
|
185
|
+
if required?(task) && !key_provided
|
|
186
|
+
task.errors.add(accessor_name, I18nProxy.t("cmdx.attributes.required"))
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
value = apply_default(task) if value.nil?
|
|
191
|
+
return if value.nil?
|
|
192
|
+
|
|
193
|
+
@coercions ||= task.class.coercions.extract(@options)
|
|
194
|
+
value = task.class.coercions.coerce(task, accessor_name, value, @coercions)
|
|
195
|
+
return if value.is_a?(Coercions::Failure)
|
|
196
|
+
|
|
197
|
+
value = apply_transform(value, task) if transform
|
|
198
|
+
@validators ||= task.class.validators.extract(@options)
|
|
199
|
+
task.class.validators.validate(task, accessor_name, value, @validators)
|
|
200
|
+
return if task.errors.for?(accessor_name)
|
|
201
|
+
|
|
202
|
+
value
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @param task [Task]
|
|
206
|
+
# @return [Array(Object, Boolean)] `[value, key_provided]`
|
|
207
|
+
def resolve_with_key(task)
|
|
208
|
+
case source
|
|
209
|
+
when :context
|
|
210
|
+
[task.context[name], task.context.key?(name)]
|
|
211
|
+
when Symbol
|
|
212
|
+
obj = task.send(source)
|
|
213
|
+
return [nil, false] if obj.nil?
|
|
214
|
+
|
|
215
|
+
fetch_by_name(obj)
|
|
216
|
+
when Proc
|
|
217
|
+
[task.instance_exec(&source), true]
|
|
218
|
+
else
|
|
219
|
+
return [source.call(task), true] if source.respond_to?(:call)
|
|
220
|
+
|
|
221
|
+
raise ArgumentError, "source must be a Symbol, Proc, or respond to #call"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @param parent_value [#[], #key?, Object]
|
|
226
|
+
# @return [Array(Object, Boolean)] `[value, key_provided]`
|
|
227
|
+
def resolve_from_parent_with_key(parent_value)
|
|
228
|
+
return [nil, false] unless parent_value.respond_to?(:[])
|
|
229
|
+
|
|
230
|
+
fetch_by_name(parent_value)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @param obj [Object] source object (`Hash`, duck-typed reader, etc.)
|
|
234
|
+
# @return [Array(Object, Boolean)] `[value, key_provided]`
|
|
235
|
+
def fetch_by_name(obj)
|
|
236
|
+
if obj.respond_to?(name, true)
|
|
237
|
+
[obj.send(name), true]
|
|
238
|
+
elsif obj.respond_to?(:key?)
|
|
239
|
+
if obj.key?(name)
|
|
240
|
+
[obj[name], true]
|
|
241
|
+
elsif obj.key?(name_str = name.to_s)
|
|
242
|
+
[obj[name_str], true]
|
|
243
|
+
else
|
|
244
|
+
[nil, false]
|
|
245
|
+
end
|
|
246
|
+
elsif obj.respond_to?(:[])
|
|
247
|
+
# Without #key? we cannot distinguish "key absent" from "value is nil",
|
|
248
|
+
# so an explicit nil is treated as not provided (triggers default/required).
|
|
249
|
+
value = obj[name] || obj[name.to_s]
|
|
250
|
+
[value, !value.nil?]
|
|
251
|
+
else
|
|
252
|
+
[nil, false]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# @param task [Task]
|
|
257
|
+
# @return [Object, nil]
|
|
258
|
+
def apply_default(task)
|
|
259
|
+
return if default.nil?
|
|
260
|
+
|
|
261
|
+
case default
|
|
262
|
+
when Symbol
|
|
263
|
+
task.send(default)
|
|
264
|
+
when Proc
|
|
265
|
+
task.instance_exec(&default)
|
|
266
|
+
else
|
|
267
|
+
return default unless default.respond_to?(:call)
|
|
268
|
+
|
|
269
|
+
default.call(task)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @param value [Object]
|
|
274
|
+
# @param task [Task]
|
|
275
|
+
# @return [Object]
|
|
276
|
+
def apply_transform(value, task)
|
|
277
|
+
case transform
|
|
278
|
+
when Symbol
|
|
279
|
+
if value.respond_to?(transform, true)
|
|
280
|
+
value.send(transform)
|
|
281
|
+
else
|
|
282
|
+
task.send(transform, value)
|
|
283
|
+
end
|
|
284
|
+
when Proc
|
|
285
|
+
task.instance_exec(value, &transform)
|
|
286
|
+
else
|
|
287
|
+
return transform.call(value, task) if transform.respond_to?(:call)
|
|
288
|
+
|
|
289
|
+
raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
end
|
|
294
|
+
end
|