dexkit 0.6.0 → 0.8.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 +49 -0
- data/README.md +97 -5
- data/guides/llm/EVENT.md +74 -10
- data/guides/llm/FORM.md +1 -1
- data/guides/llm/OPERATION.md +352 -51
- data/guides/llm/QUERY.md +1 -1
- data/lib/dex/context_setup.rb +64 -0
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event.rb +28 -0
- data/lib/dex/operation/async_proxy.rb +18 -2
- data/lib/dex/operation/explain.rb +204 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +149 -0
- data/lib/dex/operation/jobs.rb +18 -11
- data/lib/dex/operation/once_wrapper.rb +240 -0
- data/lib/dex/operation/record_backend.rb +87 -0
- data/lib/dex/operation/record_wrapper.rb +87 -20
- data/lib/dex/operation.rb +62 -4
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/test_helpers/assertions.rb +23 -0
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +21 -0
- metadata +11 -1
data/lib/dex/event/handler.rb
CHANGED
|
@@ -5,15 +5,48 @@ module Dex
|
|
|
5
5
|
class Handler
|
|
6
6
|
include Dex::Executable
|
|
7
7
|
|
|
8
|
+
extend Registry
|
|
9
|
+
|
|
10
|
+
def self.deregister(klass)
|
|
11
|
+
if klass.respond_to?(:handled_events)
|
|
12
|
+
klass.handled_events.each { |ec| Bus.unsubscribe(ec, klass) }
|
|
13
|
+
end
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
attr_reader :event
|
|
9
18
|
|
|
10
19
|
def self.on(*event_classes)
|
|
11
20
|
event_classes.each do |ec|
|
|
12
21
|
Event.validate_event_class!(ec)
|
|
13
22
|
Bus.subscribe(ec, self)
|
|
23
|
+
(@_handled_events ||= []) << ec
|
|
14
24
|
end
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
def self.handled_events
|
|
28
|
+
defined?(@_handled_events) ? @_handled_events.dup.freeze : [].freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.to_h
|
|
32
|
+
h = {}
|
|
33
|
+
h[:name] = name if name
|
|
34
|
+
event_names = handled_events.filter_map(&:name)
|
|
35
|
+
h[:events] = event_names unless event_names.empty?
|
|
36
|
+
retry_config = _event_handler_retry_config
|
|
37
|
+
h[:retries] = retry_config[:count] if retry_config
|
|
38
|
+
tx_s = settings_for(:transaction)
|
|
39
|
+
h[:transaction] = tx_s.fetch(:enabled, false)
|
|
40
|
+
h[:pipeline] = pipeline.steps.map(&:name)
|
|
41
|
+
h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.export(format: :hash)
|
|
45
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash" unless format == :hash
|
|
46
|
+
|
|
47
|
+
registry.sort_by(&:name).map(&:to_h)
|
|
48
|
+
end
|
|
49
|
+
|
|
17
50
|
def self.retries(count, **opts)
|
|
18
51
|
raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
|
|
19
52
|
|
data/lib/dex/event.rb
CHANGED
|
@@ -15,6 +15,33 @@ module Dex
|
|
|
15
15
|
|
|
16
16
|
include PropsSetup
|
|
17
17
|
include TypeCoercion
|
|
18
|
+
include ContextSetup
|
|
19
|
+
|
|
20
|
+
extend Registry
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def to_h
|
|
24
|
+
Export.build_hash(self)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_json_schema
|
|
28
|
+
Export.build_json_schema(self)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def export(format: :hash)
|
|
32
|
+
unless %i[hash json_schema].include?(format)
|
|
33
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sorted = registry.sort_by(&:name)
|
|
37
|
+
sorted.map do |klass|
|
|
38
|
+
case format
|
|
39
|
+
when :hash then klass.to_h
|
|
40
|
+
when :json_schema then klass.to_json_schema
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
18
45
|
|
|
19
46
|
def self._warn(message)
|
|
20
47
|
Dex.warn("Event: #{message}")
|
|
@@ -83,3 +110,4 @@ end
|
|
|
83
110
|
require_relative "event/bus"
|
|
84
111
|
require_relative "event/handler"
|
|
85
112
|
require_relative "event/processor"
|
|
113
|
+
require_relative "event/export"
|
|
@@ -21,7 +21,9 @@ module Dex
|
|
|
21
21
|
|
|
22
22
|
def enqueue_direct_job
|
|
23
23
|
job = apply_options(Operation::DirectJob)
|
|
24
|
-
|
|
24
|
+
payload = { class_name: operation_class_name, params: serialized_params }
|
|
25
|
+
apply_once_payload!(payload)
|
|
26
|
+
job.perform_later(**payload)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def enqueue_record_job
|
|
@@ -32,7 +34,9 @@ module Dex
|
|
|
32
34
|
)
|
|
33
35
|
begin
|
|
34
36
|
job = apply_options(Operation::RecordJob)
|
|
35
|
-
|
|
37
|
+
payload = { class_name: operation_class_name, record_id: record.id.to_s }
|
|
38
|
+
apply_once_payload!(payload)
|
|
39
|
+
job.perform_later(**payload)
|
|
36
40
|
rescue => e
|
|
37
41
|
begin
|
|
38
42
|
record.destroy
|
|
@@ -68,6 +72,18 @@ module Dex
|
|
|
68
72
|
raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
|
|
69
73
|
end
|
|
70
74
|
|
|
75
|
+
def apply_once_payload!(payload)
|
|
76
|
+
return unless @operation.instance_variable_defined?(:@_once_key_explicit) &&
|
|
77
|
+
@operation.instance_variable_get(:@_once_key_explicit)
|
|
78
|
+
|
|
79
|
+
once_key = @operation.instance_variable_get(:@_once_key)
|
|
80
|
+
if once_key
|
|
81
|
+
payload[:once_key] = once_key
|
|
82
|
+
else
|
|
83
|
+
payload[:once_bypass] = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
71
87
|
def merged_options
|
|
72
88
|
@operation.class.settings_for(:async).merge(@runtime_options)
|
|
73
89
|
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module Explain
|
|
6
|
+
def explain(**kwargs)
|
|
7
|
+
error = nil
|
|
8
|
+
instance = begin
|
|
9
|
+
new(**kwargs)
|
|
10
|
+
rescue Literal::TypeError, ArgumentError => e
|
|
11
|
+
error = e
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
info = {}
|
|
16
|
+
active = pipeline.steps.map(&:name).to_set
|
|
17
|
+
|
|
18
|
+
info[:operation] = name || "(anonymous)"
|
|
19
|
+
desc = description
|
|
20
|
+
info[:description] = desc if desc
|
|
21
|
+
info[:error] = "#{error.class}: #{error.message}" if error
|
|
22
|
+
info[:props] = _explain_props(instance)
|
|
23
|
+
info[:context] = _explain_context(instance, kwargs)
|
|
24
|
+
info[:guards] = active.include?(:guard) ? _explain_guards(instance) : { passed: true, results: [] }
|
|
25
|
+
info[:once] = active.include?(:once) ? _explain_once(instance) : { active: false }
|
|
26
|
+
info[:lock] = active.include?(:lock) ? _explain_lock(instance) : { active: false }
|
|
27
|
+
info[:record] = active.include?(:record) ? _explain_record : { enabled: false }
|
|
28
|
+
info[:transaction] = active.include?(:transaction) ? _explain_transaction : { enabled: false }
|
|
29
|
+
info[:rescue_from] = active.include?(:rescue) ? _explain_rescue : {}
|
|
30
|
+
info[:callbacks] = active.include?(:callback) ? _explain_callbacks : { before: 0, after: 0, around: 0 }
|
|
31
|
+
|
|
32
|
+
if instance
|
|
33
|
+
pipeline.steps.each do |step|
|
|
34
|
+
method_name = :"_#{step.name}_explain"
|
|
35
|
+
send(method_name, instance, info) if respond_to?(method_name, true)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
info[:pipeline] = pipeline.steps.map(&:name)
|
|
40
|
+
info[:callable] = instance ? _explain_callable?(info) : false
|
|
41
|
+
info.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def _explain_callable?(info)
|
|
47
|
+
return false unless info[:guards][:passed]
|
|
48
|
+
|
|
49
|
+
if info[:once][:active]
|
|
50
|
+
return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ONCE_BLOCKING_STATUSES = %i[invalid pending misconfigured unavailable].freeze
|
|
57
|
+
|
|
58
|
+
def _explain_props(instance)
|
|
59
|
+
return {} unless respond_to?(:literal_properties)
|
|
60
|
+
return {} unless instance
|
|
61
|
+
|
|
62
|
+
literal_properties.each_with_object({}) do |prop, hash|
|
|
63
|
+
hash[prop.name] = instance.public_send(prop.name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def _explain_context(instance, explicit_kwargs)
|
|
68
|
+
mappings = respond_to?(:context_mappings) ? context_mappings : {}
|
|
69
|
+
return { resolved: {}, mappings: {}, source: {} } if mappings.empty?
|
|
70
|
+
|
|
71
|
+
ambient = Dex.context
|
|
72
|
+
resolved = {}
|
|
73
|
+
source = {}
|
|
74
|
+
|
|
75
|
+
mappings.each do |prop_name, context_key|
|
|
76
|
+
resolved[prop_name] = instance.public_send(prop_name) if instance
|
|
77
|
+
source[prop_name] = if explicit_kwargs.key?(prop_name)
|
|
78
|
+
:explicit
|
|
79
|
+
elsif ambient.key?(context_key)
|
|
80
|
+
:ambient
|
|
81
|
+
elsif instance || _explain_prop_has_default?(prop_name)
|
|
82
|
+
:default
|
|
83
|
+
else
|
|
84
|
+
:missing
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{ resolved: resolved, mappings: mappings, source: source }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def _explain_guards(instance)
|
|
92
|
+
return { passed: false, results: [] } unless instance
|
|
93
|
+
|
|
94
|
+
all_results = instance.send(:_guard_evaluate_all)
|
|
95
|
+
results = all_results.map do |r|
|
|
96
|
+
entry = { name: r[:name], passed: r[:passed] }
|
|
97
|
+
entry[:message] = r[:message] if r[:message]
|
|
98
|
+
entry[:skipped] = true if r[:skipped]
|
|
99
|
+
entry
|
|
100
|
+
end
|
|
101
|
+
{ passed: results.all? { |r| r[:passed] }, results: results }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def _explain_once(instance)
|
|
105
|
+
settings = settings_for(:once)
|
|
106
|
+
return { active: false } unless settings.fetch(:defined, false)
|
|
107
|
+
|
|
108
|
+
key = instance&.send(:_once_derive_key)
|
|
109
|
+
{
|
|
110
|
+
active: true,
|
|
111
|
+
key: key,
|
|
112
|
+
status: _explain_once_status(key),
|
|
113
|
+
expires_in: settings[:expires_in]
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def _explain_once_status(key)
|
|
118
|
+
return :invalid if key.nil?
|
|
119
|
+
return :misconfigured if name.nil?
|
|
120
|
+
return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
|
|
121
|
+
return :unavailable unless Dex.record_backend
|
|
122
|
+
return :misconfigured unless Dex.record_backend.has_field?("once_key")
|
|
123
|
+
|
|
124
|
+
settings = settings_for(:once)
|
|
125
|
+
if settings[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
|
|
126
|
+
return :misconfigured
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
existing = Dex.record_backend.find_by_once_key(key)
|
|
130
|
+
return :exists if existing
|
|
131
|
+
|
|
132
|
+
if Dex.record_backend.has_field?("once_key_expires_at")
|
|
133
|
+
expired = Dex.record_backend.find_expired_once_key(key)
|
|
134
|
+
return :expired if expired
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
pending = Dex.record_backend.find_pending_once_key(key)
|
|
138
|
+
return :pending if pending
|
|
139
|
+
|
|
140
|
+
:fresh
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def _explain_lock(instance)
|
|
144
|
+
settings = settings_for(:advisory_lock)
|
|
145
|
+
return { active: false } unless settings.fetch(:enabled, false)
|
|
146
|
+
|
|
147
|
+
key = if instance
|
|
148
|
+
instance.send(:_lock_key)
|
|
149
|
+
else
|
|
150
|
+
case settings[:key]
|
|
151
|
+
when String then settings[:key]
|
|
152
|
+
when nil then name
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
{ active: true, key: key, timeout: settings[:timeout] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def _explain_prop_has_default?(prop_name)
|
|
160
|
+
return false unless respond_to?(:literal_properties)
|
|
161
|
+
|
|
162
|
+
literal_properties.any? { |p| p.name == prop_name && p.default? }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def _explain_record
|
|
166
|
+
settings = settings_for(:record)
|
|
167
|
+
enabled = settings.fetch(:enabled, true) && !!Dex.record_backend && !!name
|
|
168
|
+
return { enabled: false } unless enabled
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
enabled: true,
|
|
172
|
+
params: settings.fetch(:params, true),
|
|
173
|
+
result: settings.fetch(:result, true)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def _explain_transaction
|
|
178
|
+
settings = settings_for(:transaction)
|
|
179
|
+
return { enabled: false } unless settings.fetch(:enabled, true)
|
|
180
|
+
|
|
181
|
+
adapter_name = settings.fetch(:adapter, Dex.transaction_adapter)
|
|
182
|
+
adapter = Operation::TransactionAdapter.for(adapter_name)
|
|
183
|
+
{ enabled: !adapter.nil? }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def _explain_rescue
|
|
187
|
+
handlers = respond_to?(:_rescue_handlers) ? _rescue_handlers : []
|
|
188
|
+
handlers.each_with_object({}) do |h, hash|
|
|
189
|
+
hash[h[:exception_class].name] = h[:code]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def _explain_callbacks
|
|
194
|
+
return { before: 0, after: 0, around: 0 } unless respond_to?(:_callback_list)
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
before: _callback_list(:before).size,
|
|
198
|
+
after: _callback_list(:after).size,
|
|
199
|
+
around: _callback_list(:around).size
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source, contract) # rubocop:disable Metrics/MethodLength
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source&.name
|
|
11
|
+
desc = source&.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:params] = _serialize_params(source, contract.params)
|
|
14
|
+
h[:success] = TypeSerializer.to_string(contract.success) if contract.success
|
|
15
|
+
h[:errors] = contract.errors unless contract.errors.empty?
|
|
16
|
+
h[:guards] = contract.guards unless contract.guards.empty?
|
|
17
|
+
ctx = _serialize_context(source)
|
|
18
|
+
h[:context] = ctx unless ctx.empty?
|
|
19
|
+
h[:pipeline] = source.pipeline.steps.map(&:name) if source
|
|
20
|
+
h[:settings] = _serialize_settings(source) if source
|
|
21
|
+
h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_json_schema(source, contract, section: :params) # rubocop:disable Metrics/MethodLength
|
|
25
|
+
case section
|
|
26
|
+
when :params then _params_schema(source, contract)
|
|
27
|
+
when :success then _success_schema(source, contract)
|
|
28
|
+
when :errors then _errors_schema(source, contract)
|
|
29
|
+
when :full then _full_schema(source, contract)
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"unknown section: #{section.inspect}. Known: :params, :success, :errors, :full"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def _serialize_params(source, params)
|
|
37
|
+
descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
38
|
+
params.each_with_object({}) do |(name, type), hash|
|
|
39
|
+
entry = { type: TypeSerializer.to_string(type), required: _required?(source, name) }
|
|
40
|
+
entry[:desc] = descs[name] if descs[name]
|
|
41
|
+
hash[name] = entry
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def _required?(source, prop_name)
|
|
46
|
+
return true unless source&.respond_to?(:literal_properties)
|
|
47
|
+
|
|
48
|
+
prop = source.literal_properties.find { |p| p.name == prop_name }
|
|
49
|
+
return true unless prop
|
|
50
|
+
|
|
51
|
+
prop.required?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def _serialize_context(source)
|
|
55
|
+
source&.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def _serialize_settings(source) # rubocop:disable Metrics/MethodLength
|
|
59
|
+
settings = {}
|
|
60
|
+
|
|
61
|
+
record_s = source.settings_for(:record)
|
|
62
|
+
settings[:record] = {
|
|
63
|
+
enabled: record_s.fetch(:enabled, true),
|
|
64
|
+
params: record_s.fetch(:params, true),
|
|
65
|
+
result: record_s.fetch(:result, true)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
tx_s = source.settings_for(:transaction)
|
|
69
|
+
settings[:transaction] = { enabled: tx_s.fetch(:enabled, true) }
|
|
70
|
+
|
|
71
|
+
once_s = source.settings_for(:once)
|
|
72
|
+
settings[:once] = { defined: once_s.fetch(:defined, false) }
|
|
73
|
+
|
|
74
|
+
settings
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def _params_schema(source, contract) # rubocop:disable Metrics/MethodLength
|
|
78
|
+
descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
79
|
+
properties = {}
|
|
80
|
+
required = []
|
|
81
|
+
|
|
82
|
+
contract.params.each do |name, type|
|
|
83
|
+
prop_desc = descs[name]
|
|
84
|
+
schema = TypeSerializer.to_json_schema(type, desc: prop_desc)
|
|
85
|
+
properties[name.to_s] = schema
|
|
86
|
+
required << name.to_s if _required?(source, name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
|
|
90
|
+
result[:title] = source.name if source&.name
|
|
91
|
+
desc = source&.description
|
|
92
|
+
result[:description] = desc if desc
|
|
93
|
+
result[:properties] = properties unless properties.empty?
|
|
94
|
+
result[:required] = required unless required.empty?
|
|
95
|
+
result[:additionalProperties] = false
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def _success_schema(source, contract)
|
|
100
|
+
return {} unless contract.success
|
|
101
|
+
|
|
102
|
+
schema = TypeSerializer.to_json_schema(contract.success)
|
|
103
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
104
|
+
result[:title] = "#{source&.name} success" if source&.name
|
|
105
|
+
result.merge(schema)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def _errors_schema(source, contract)
|
|
109
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
110
|
+
result[:title] = "#{source&.name} errors" if source&.name
|
|
111
|
+
result[:type] = "object"
|
|
112
|
+
|
|
113
|
+
properties = {}
|
|
114
|
+
contract.errors.each do |code|
|
|
115
|
+
properties[code.to_s] = {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
code: { const: code.to_s },
|
|
119
|
+
message: { type: "string" },
|
|
120
|
+
details: { type: "object" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
result[:properties] = properties unless properties.empty?
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def _full_schema(source, contract)
|
|
129
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
130
|
+
result[:title] = source.name if source&.name
|
|
131
|
+
result[:description] = "Operation contract"
|
|
132
|
+
result[:properties] = {
|
|
133
|
+
params: _params_schema(source, contract).except(:$schema),
|
|
134
|
+
success: _success_schema(source, contract).except(:$schema),
|
|
135
|
+
errors: _errors_schema(source, contract).except(:$schema)
|
|
136
|
+
}
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private_class_method :_serialize_params, :_required?, :_serialize_context, :_serialize_settings,
|
|
141
|
+
:_params_schema, :_success_schema, :_errors_schema, :_full_schema
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module GuardWrapper
|
|
5
|
+
extend Dex::Concern
|
|
6
|
+
|
|
7
|
+
GuardDefinition = Data.define(:name, :message, :requires, :block)
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def guard(code, message = nil, requires: nil, &block)
|
|
11
|
+
raise ArgumentError, "guard code must be a Symbol, got: #{code.inspect}" unless code.is_a?(Symbol)
|
|
12
|
+
raise ArgumentError, "guard requires a block" unless block
|
|
13
|
+
|
|
14
|
+
requires = _guard_normalize_requires!(code, requires)
|
|
15
|
+
_guard_validate_unique!(code)
|
|
16
|
+
|
|
17
|
+
_guard_own << GuardDefinition.new(name: code, message: message, requires: requires, block: block)
|
|
18
|
+
|
|
19
|
+
error(code) if respond_to?(:error)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def _guard_list
|
|
23
|
+
parent = superclass.respond_to?(:_guard_list) ? superclass._guard_list : []
|
|
24
|
+
parent + _guard_own
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def callable(**kwargs)
|
|
28
|
+
instance = new(**kwargs)
|
|
29
|
+
failures = instance.send(:_guard_evaluate)
|
|
30
|
+
if failures.empty?
|
|
31
|
+
Operation::Ok.new(nil)
|
|
32
|
+
else
|
|
33
|
+
first = failures.first
|
|
34
|
+
error = Dex::Error.new(first[:guard], first[:message], details: failures)
|
|
35
|
+
Operation::Err.new(error)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def callable?(*args, **kwargs)
|
|
40
|
+
if args.size > 1
|
|
41
|
+
raise ArgumentError, "callable? accepts at most one guard name, got #{args.size} arguments"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if args.first
|
|
45
|
+
guard_name = args.first
|
|
46
|
+
unless guard_name.is_a?(Symbol)
|
|
47
|
+
raise ArgumentError, "guard name must be a Symbol, got: #{guard_name.inspect}"
|
|
48
|
+
end
|
|
49
|
+
unless _guard_list.any? { |g| g.name == guard_name }
|
|
50
|
+
raise ArgumentError, "unknown guard :#{guard_name}. Declared: #{_guard_list.map(&:name).map(&:inspect).join(", ")}"
|
|
51
|
+
end
|
|
52
|
+
instance = new(**kwargs)
|
|
53
|
+
failures = instance.send(:_guard_evaluate)
|
|
54
|
+
failures.none? { |f| f[:guard] == guard_name }
|
|
55
|
+
else
|
|
56
|
+
callable(**kwargs).ok?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def _guard_own
|
|
63
|
+
@_guards ||= []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def _guard_normalize_requires!(code, requires)
|
|
67
|
+
return [] if requires.nil?
|
|
68
|
+
|
|
69
|
+
deps = Array(requires)
|
|
70
|
+
invalid = deps.reject { |d| d.is_a?(Symbol) }
|
|
71
|
+
if invalid.any?
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"guard :#{code} requires: must be Symbol(s), got: #{invalid.map(&:inspect).join(", ")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
all_names = _guard_list.map(&:name)
|
|
77
|
+
deps.each do |dep|
|
|
78
|
+
unless all_names.include?(dep)
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"guard :#{code} requires :#{dep}, but no guard with that name has been declared"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
deps.freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def _guard_validate_unique!(code)
|
|
88
|
+
all_names = _guard_list.map(&:name)
|
|
89
|
+
if all_names.include?(code)
|
|
90
|
+
raise ArgumentError, "duplicate guard name :#{code}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def _guard_wrap
|
|
96
|
+
guards = self.class._guard_list
|
|
97
|
+
return yield if guards.empty?
|
|
98
|
+
|
|
99
|
+
failures = _guard_evaluate
|
|
100
|
+
unless failures.empty?
|
|
101
|
+
first = failures.first
|
|
102
|
+
error!(first[:guard], first[:message], details: failures)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
yield
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def _guard_evaluate_all
|
|
111
|
+
guards = self.class._guard_list
|
|
112
|
+
return [] if guards.empty?
|
|
113
|
+
|
|
114
|
+
blocked_names = Set.new
|
|
115
|
+
results = []
|
|
116
|
+
|
|
117
|
+
guards.each do |guard|
|
|
118
|
+
if guard.requires.any? { |dep| blocked_names.include?(dep) }
|
|
119
|
+
blocked_names << guard.name
|
|
120
|
+
results << { name: guard.name, passed: false, skipped: true }
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
threat = catch(:_dex_halt) { instance_exec(&guard.block) }
|
|
125
|
+
if threat.is_a?(Operation::Halt)
|
|
126
|
+
raise ArgumentError,
|
|
127
|
+
"guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if threat
|
|
131
|
+
blocked_names << guard.name
|
|
132
|
+
results << { name: guard.name, passed: false, message: guard.message || guard.name.to_s }
|
|
133
|
+
else
|
|
134
|
+
results << { name: guard.name, passed: true }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def _guard_evaluate
|
|
142
|
+
_guard_evaluate_all.filter_map do |r|
|
|
143
|
+
next if r[:passed] || r[:skipped]
|
|
144
|
+
|
|
145
|
+
{ guard: r[:name], message: r[:message] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/dex/operation/jobs.rb
CHANGED
|
@@ -9,25 +9,33 @@ module Dex
|
|
|
9
9
|
case name
|
|
10
10
|
when :DirectJob
|
|
11
11
|
const_set(:DirectJob, Class.new(ActiveJob::Base) do
|
|
12
|
-
def perform(class_name:, params:)
|
|
12
|
+
def perform(class_name:, params:, once_key: nil, once_bypass: false)
|
|
13
13
|
klass = class_name.constantize
|
|
14
|
-
klass.new(**klass.send(:_coerce_serialized_hash, params))
|
|
14
|
+
op = klass.new(**klass.send(:_coerce_serialized_hash, params))
|
|
15
|
+
op.once(once_key) if once_key
|
|
16
|
+
op.once(nil) if once_bypass
|
|
17
|
+
op.call
|
|
15
18
|
end
|
|
16
19
|
end)
|
|
17
20
|
when :RecordJob
|
|
18
21
|
const_set(:RecordJob, Class.new(ActiveJob::Base) do
|
|
19
|
-
def perform(class_name:, record_id:)
|
|
22
|
+
def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
|
|
20
23
|
klass = class_name.constantize
|
|
21
24
|
record = Dex.record_backend.find_record(record_id)
|
|
22
25
|
params = klass.send(:_coerce_serialized_hash, record.params || {})
|
|
23
26
|
|
|
24
27
|
op = klass.new(**params)
|
|
25
28
|
op.instance_variable_set(:@_dex_record_id, record_id)
|
|
29
|
+
op.once(once_key) if once_key
|
|
30
|
+
op.once(nil) if once_bypass
|
|
26
31
|
|
|
27
32
|
update_status(record_id, status: "running")
|
|
33
|
+
pipeline_started = true
|
|
28
34
|
op.call
|
|
29
35
|
rescue => e
|
|
30
|
-
|
|
36
|
+
# RecordWrapper handles failures during op.call via its own rescue.
|
|
37
|
+
# This catches pre-pipeline failures (find_record, deserialization, etc.)
|
|
38
|
+
mark_failed(record_id, e) unless pipeline_started
|
|
31
39
|
raise
|
|
32
40
|
end
|
|
33
41
|
|
|
@@ -39,13 +47,12 @@ module Dex
|
|
|
39
47
|
Dex.warn("Failed to update record status: #{e.message}")
|
|
40
48
|
end
|
|
41
49
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
exception.
|
|
47
|
-
|
|
48
|
-
update_status(record_id, status: "failed", error: error_value)
|
|
50
|
+
def mark_failed(record_id, exception)
|
|
51
|
+
update_status(record_id,
|
|
52
|
+
status: "failed",
|
|
53
|
+
error_code: exception.class.name,
|
|
54
|
+
error_message: exception.message,
|
|
55
|
+
performed_at: Time.respond_to?(:current) ? Time.current : Time.now)
|
|
49
56
|
end
|
|
50
57
|
end)
|
|
51
58
|
when :Job
|