dexkit 0.9.0 → 0.11.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +63 -254
  4. data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
  5. data/guides/llm/EVENT.md +25 -26
  6. data/guides/llm/FORM.md +200 -59
  7. data/guides/llm/OPERATION.md +115 -57
  8. data/guides/llm/QUERY.md +56 -0
  9. data/guides/llm/TOOL.md +308 -0
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +79 -11
  13. data/lib/dex/event/handler.rb +18 -1
  14. data/lib/dex/event/metadata.rb +15 -20
  15. data/lib/dex/event/processor.rb +2 -16
  16. data/lib/dex/event/test_helpers.rb +1 -1
  17. data/lib/dex/event.rb +3 -10
  18. data/lib/dex/form/context.rb +27 -0
  19. data/lib/dex/form/export.rb +128 -0
  20. data/lib/dex/form/nesting.rb +2 -0
  21. data/lib/dex/form.rb +119 -3
  22. data/lib/dex/id.rb +125 -0
  23. data/lib/dex/operation/async_proxy.rb +22 -4
  24. data/lib/dex/operation/guard_wrapper.rb +1 -1
  25. data/lib/dex/operation/jobs.rb +5 -4
  26. data/lib/dex/operation/once_wrapper.rb +1 -0
  27. data/lib/dex/operation/outcome.rb +14 -0
  28. data/lib/dex/operation/record_backend.rb +2 -1
  29. data/lib/dex/operation/record_wrapper.rb +14 -4
  30. data/lib/dex/operation/result_wrapper.rb +0 -12
  31. data/lib/dex/operation/test_helpers/assertions.rb +0 -88
  32. data/lib/dex/operation/test_helpers.rb +11 -1
  33. data/lib/dex/operation/ticket.rb +268 -0
  34. data/lib/dex/operation/trace_wrapper.rb +20 -0
  35. data/lib/dex/operation.rb +3 -0
  36. data/lib/dex/operation_failed.rb +14 -0
  37. data/lib/dex/query/export.rb +64 -0
  38. data/lib/dex/query.rb +41 -0
  39. data/lib/dex/test_log.rb +62 -4
  40. data/lib/dex/timeout.rb +14 -0
  41. data/lib/dex/tool.rb +388 -5
  42. data/lib/dex/trace.rb +291 -0
  43. data/lib/dex/version.rb +1 -1
  44. data/lib/dexkit.rb +22 -3
  45. metadata +12 -3
  46. data/lib/dex/event/trace.rb +0 -56
  47. data/lib/dex/event_test_helpers.rb +0 -3
@@ -9,29 +9,30 @@ 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:, once_key: nil, once_bypass: false)
12
+ def perform(class_name:, params:, trace: nil, once_key: nil, once_bypass: false)
13
13
  klass = class_name.constantize
14
14
  op = klass.new(**klass.send(:_coerce_serialized_hash, params))
15
15
  op.once(once_key) if once_key
16
16
  op.once(nil) if once_bypass
17
- op.call
17
+ Dex::Trace.restore(trace) { op.call }
18
18
  end
19
19
  end)
20
20
  when :RecordJob
21
21
  const_set(:RecordJob, Class.new(ActiveJob::Base) do
22
- def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
22
+ def perform(class_name:, record_id:, trace: nil, once_key: nil, once_bypass: false)
23
23
  klass = class_name.constantize
24
24
  record = Dex.record_backend.find_record(record_id)
25
25
  params = klass.send(:_coerce_serialized_hash, record.params || {})
26
26
 
27
27
  op = klass.new(**params)
28
28
  op.instance_variable_set(:@_dex_record_id, record_id)
29
+ op.instance_variable_set(:@_dex_execution_id, record_id)
29
30
  op.once(once_key) if once_key
30
31
  op.once(nil) if once_bypass
31
32
 
32
33
  update_status(record_id, status: "running")
33
34
  pipeline_started = true
34
- op.call
35
+ Dex::Trace.restore(trace) { op.call }
35
36
  rescue => e
36
37
  # RecordWrapper handles failures during op.call via its own rescue.
37
38
  # This catches pre-pipeline failures (find_record, deserialization, etc.)
@@ -180,6 +180,7 @@ module Dex
180
180
  once_key: key, once_key_expires_at: expires_at)
181
181
  else
182
182
  record = Dex.record_backend.create_record(
183
+ id: @_dex_execution_id,
183
184
  name: self.class.name,
184
185
  once_key: key,
185
186
  once_key_expires_at: expires_at,
@@ -26,6 +26,10 @@ module Dex
26
26
  @value.respond_to?(method, include_private) || super
27
27
  end
28
28
 
29
+ def deconstruct
30
+ [@value]
31
+ end
32
+
29
33
  def deconstruct_keys(keys)
30
34
  return { value: @value } unless @value.respond_to?(:deconstruct_keys)
31
35
  @value.deconstruct_keys(keys)
@@ -49,6 +53,10 @@ module Dex
49
53
  def message = @error.message
50
54
  def details = @error.details
51
55
 
56
+ def deconstruct
57
+ [@error]
58
+ end
59
+
52
60
  def deconstruct_keys(keys)
53
61
  { code: @error.code, message: @error.message, details: @error.details }
54
62
  end
@@ -65,6 +73,12 @@ module Dex
65
73
  rescue Dex::Error => e
66
74
  Operation::Err.new(e)
67
75
  end
76
+
77
+ def async(*)
78
+ raise NoMethodError,
79
+ "safe and async are alternative execution strategies. " \
80
+ "For async outcome reconstruction, use wait/wait! on the ticket."
81
+ end
68
82
  end
69
83
  end
70
84
  end
@@ -156,7 +156,8 @@ module Dex
156
156
  end
157
157
 
158
158
  def has_field?(field_name)
159
- record_class.fields.key?(field_name.to_s)
159
+ field_name = field_name.to_s
160
+ record_class.fields.key?(field_name) || (field_name == "id" && record_class.fields.key?("_id"))
160
161
  end
161
162
  end
162
163
  end
@@ -88,7 +88,7 @@ module Dex
88
88
  else
89
89
  _record_success_attrs(interceptor.result)
90
90
  end
91
- Dex.record_backend.update_record(@_dex_record_id, attrs)
91
+ Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
92
92
  rescue => e
93
93
  _record_handle_error(e)
94
94
  end
@@ -108,7 +108,7 @@ module Dex
108
108
  attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
109
109
 
110
110
  if _record_has_pending_record?
111
- Dex.record_backend.update_record(@_dex_record_id, attrs)
111
+ Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
112
112
  else
113
113
  Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
114
114
  end
@@ -116,8 +116,18 @@ module Dex
116
116
  _record_handle_error(e)
117
117
  end
118
118
 
119
- def _record_base_attrs
120
- attrs = { name: self.class.name }
119
+ def _record_base_attrs(include_id: true)
120
+ trace_snapshot = Dex::Trace.current
121
+ actor_frame = Dex::Trace.actor
122
+
123
+ attrs = {
124
+ name: self.class.name,
125
+ trace_id: Dex::Trace.trace_id,
126
+ trace: trace_snapshot,
127
+ actor_type: actor_frame&.dig(:actor_type),
128
+ actor_id: actor_frame&.dig(:id)&.to_s
129
+ }
130
+ attrs[:id] = @_dex_execution_id if include_id && defined?(@_dex_execution_id) && @_dex_execution_id
121
131
  attrs[:params] = _record_params? ? _record_params : nil
122
132
  attrs
123
133
  end
@@ -57,18 +57,6 @@ module Dex
57
57
  throw(:_dex_halt, Operation::Halt.new(type: :success, value: attrs.empty? ? value : attrs))
58
58
  end
59
59
 
60
- def assert!(*args, &block)
61
- if block
62
- code = args[0]
63
- value = yield
64
- else
65
- value, code = args
66
- end
67
-
68
- error!(code) unless value
69
- value
70
- end
71
-
72
60
  private
73
61
 
74
62
  def _result_validate_success_type!(value)
@@ -52,39 +52,6 @@ module Dex
52
52
  result
53
53
  end
54
54
 
55
- # --- One-liner assertions ---
56
-
57
- def assert_operation(*args, returns: :_not_given, **params)
58
- klass = _dex_resolve_subject(args)
59
- result = klass.new(**params).safe.call
60
- assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
61
- if returns != :_not_given
62
- assert_equal returns, result.value, "Return value mismatch"
63
- end
64
- result
65
- end
66
-
67
- def assert_operation_error(*args, message: nil, details: nil, **params)
68
- klass, code = _dex_resolve_subject_and_code(args)
69
- result = klass.new(**params).safe.call
70
- assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
71
- if code
72
- assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
73
- end
74
- if message
75
- case message
76
- when Regexp
77
- assert_match message, result.message
78
- else
79
- assert_equal message, result.message
80
- end
81
- end
82
- details&.each do |key, val|
83
- assert_equal val, result.details&.dig(key)
84
- end
85
- result
86
- end
87
-
88
55
  # --- Contract assertions ---
89
56
 
90
57
  def assert_params(*args)
@@ -238,49 +205,6 @@ module Dex
238
205
  result
239
206
  end
240
207
 
241
- # --- Batch assertions ---
242
-
243
- def assert_all_succeed(*args, params_list:)
244
- klass = _dex_resolve_subject(args)
245
- results = params_list.map { |p| klass.new(**p).safe.call }
246
- failures = results.each_with_index.reject { |r, _| r.ok? }
247
- if failures.any?
248
- msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
249
- flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
250
- end
251
- results
252
- end
253
-
254
- def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
255
- klass = _dex_resolve_subject(args)
256
- results = params_list.map { |p| klass.new(**p).safe.call }
257
- failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
258
- if failures.any?
259
- msgs = failures.map { |r, i|
260
- status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
261
- " [#{i}] #{params_list[i].inspect} => #{status}"
262
- }
263
- flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
264
- end
265
- results.each_with_index do |r, i|
266
- if message
267
- case message
268
- when Regexp
269
- assert_match message, r.message,
270
- "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
271
- else
272
- assert_equal message, r.message,
273
- "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
274
- end
275
- end
276
- details&.each do |key, val|
277
- assert_equal val, r.details&.dig(key),
278
- "Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
279
- end
280
- end
281
- results
282
- end
283
-
284
208
  private
285
209
 
286
210
  def _dex_format_err(result)
@@ -298,18 +222,6 @@ module Dex
298
222
  " value: #{result.value.inspect}"
299
223
  end
300
224
 
301
- def _dex_resolve_subject_and_code(args)
302
- if args.first.is_a?(Class) && args.first < Dex::Operation
303
- klass = args.shift
304
- code = args.shift
305
- [klass, code]
306
- elsif args.first.is_a?(Symbol)
307
- [_dex_resolve_subject([]), args.shift]
308
- else
309
- [_dex_resolve_subject([]), nil]
310
- end
311
- end
312
-
313
225
  def _dex_split_class_and_symbols(args)
314
226
  if args.first.is_a?(Class) && args.first < Dex::Operation
315
227
  [args[0..0], args[1..]]
@@ -97,6 +97,12 @@ module Dex
97
97
  Dex::Operation::Ok.new(result)
98
98
  end
99
99
 
100
+ trace = Dex::Trace.current + [{
101
+ type: :operation,
102
+ id: @_dex_execution_id,
103
+ class: self.class.name || self.class.to_s
104
+ }]
105
+
100
106
  entry = Dex::TestLog::Entry.new(
101
107
  type: "Operation",
102
108
  name: self.class.name || self.class.to_s,
@@ -104,7 +110,10 @@ module Dex
104
110
  params: _test_safe_params,
105
111
  result: safe_result,
106
112
  duration: duration,
107
- caller_location: caller_locations(4, 1)&.first
113
+ caller_location: caller_locations(4, 1)&.first,
114
+ execution_id: @_dex_execution_id,
115
+ trace_id: @_dex_trace_id,
116
+ trace: trace
108
117
  )
109
118
  Dex::TestLog.record(entry)
110
119
  end
@@ -120,6 +129,7 @@ module Dex
120
129
 
121
130
  def setup
122
131
  super
132
+ Dex::Trace.clear!
123
133
  Dex::TestLog.clear!
124
134
  Dex::Operation::TestWrapper.clear_all_stubs!
125
135
  end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ class Ticket
6
+ WAIT_MAX_RECOMMENDED = 10
7
+
8
+ attr_reader :record, :job
9
+
10
+ def initialize(record:, job:)
11
+ @record = record
12
+ @job = job
13
+ end
14
+
15
+ def self.from_record(record)
16
+ raise ArgumentError, "from_record requires a record, got nil" unless record
17
+
18
+ new(record: record, job: nil)
19
+ end
20
+
21
+ # --- Delegated accessors ---
22
+
23
+ def id
24
+ _require_record!("id")
25
+ record.id
26
+ end
27
+
28
+ def operation_name
29
+ _require_record!("operation_name")
30
+ record.name
31
+ end
32
+
33
+ def status
34
+ _require_record!("status")
35
+ record.status
36
+ end
37
+
38
+ def error_code
39
+ _require_record!("error_code")
40
+ record.error_code
41
+ end
42
+
43
+ def error_message
44
+ _require_record!("error_message")
45
+ record.error_message
46
+ end
47
+
48
+ def error_details
49
+ _require_record!("error_details")
50
+ record.error_details
51
+ end
52
+
53
+ # --- Predicates ---
54
+
55
+ def completed?
56
+ status == "completed"
57
+ end
58
+
59
+ def error?
60
+ status == "error"
61
+ end
62
+
63
+ def failed?
64
+ status == "failed"
65
+ end
66
+
67
+ def pending?
68
+ status == "pending"
69
+ end
70
+
71
+ def running?
72
+ status == "running"
73
+ end
74
+
75
+ def terminal?
76
+ completed? || error? || failed?
77
+ end
78
+
79
+ def recorded?
80
+ !record.nil?
81
+ end
82
+
83
+ # --- Reload ---
84
+
85
+ def reload
86
+ _require_record!("reload")
87
+ @record = Dex.record_backend.find_record(record.id)
88
+ self
89
+ end
90
+
91
+ # --- Outcome reconstruction ---
92
+
93
+ def outcome
94
+ _require_record!("outcome")
95
+
96
+ case status
97
+ when "completed"
98
+ value = _unwrap_result(record.result)
99
+ value = _coerce_typed_result(value)
100
+ Ok.new(_symbolize_keys(value))
101
+ when "error"
102
+ code = record.error_code&.to_sym
103
+ message = record.error_message
104
+ details = record.error_details
105
+ details = _symbolize_keys(details) if details.is_a?(Hash)
106
+ Err.new(Dex::Error.new(code, message, details: details))
107
+ end
108
+ end
109
+
110
+ # --- Wait ---
111
+
112
+ def wait(timeout, interval: 0.2)
113
+ _validate_wait_args!(timeout, interval)
114
+
115
+ if timeout.to_f > WAIT_MAX_RECOMMENDED
116
+ Dex.warn(
117
+ "Ticket#wait called with #{timeout}s timeout. " \
118
+ "Speculative sync is designed for short waits (under #{WAIT_MAX_RECOMMENDED}s). " \
119
+ "Consider client-side polling for long-running operations."
120
+ )
121
+ end
122
+
123
+ interval_fn = interval.respond_to?(:call) ? interval : ->(_) { interval }
124
+ deadline = _monotonic_now + timeout.to_f
125
+ attempt = 0
126
+
127
+ loop do
128
+ if terminal?
129
+ result = outcome
130
+ raise _build_operation_failed unless result
131
+ return result
132
+ end
133
+
134
+ remaining = deadline - _monotonic_now
135
+ return nil if remaining <= 0
136
+
137
+ pause = [interval_fn.call(attempt).to_f, 0.01].max
138
+ sleep [pause, remaining].min
139
+ attempt += 1
140
+ reload
141
+ end
142
+ end
143
+
144
+ def wait!(timeout, **opts)
145
+ result = wait(timeout, **opts)
146
+ raise Dex::Timeout.new(timeout: timeout, ticket_id: id, operation_name: operation_name) unless result
147
+ return result.value if result.ok?
148
+
149
+ raise result.error
150
+ end
151
+
152
+ # --- to_param ---
153
+
154
+ def to_param
155
+ id.to_s
156
+ end
157
+
158
+ # --- as_json ---
159
+
160
+ def as_json(*)
161
+ _require_record!("as_json")
162
+
163
+ data = { "id" => id.to_s, "name" => operation_name, "status" => status }
164
+
165
+ case status
166
+ when "completed"
167
+ result = _unwrap_result(record.result)
168
+ data["result"] = result unless result.nil?
169
+ when "error"
170
+ data["error"] = {
171
+ "code" => record.error_code,
172
+ "message" => record.error_message,
173
+ "details" => record.error_details
174
+ }.compact
175
+ end
176
+
177
+ data
178
+ end
179
+
180
+ # --- inspect ---
181
+
182
+ def inspect
183
+ if record
184
+ "#<Dex::Operation::Ticket #{operation_name} id=#{id.inspect} status=#{status.inspect}>"
185
+ else
186
+ "#<Dex::Operation::Ticket (unrecorded) job=#{job&.class&.name}>"
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def _require_record!(method)
193
+ return if record
194
+
195
+ raise ArgumentError,
196
+ "#{method} requires a recorded operation. " \
197
+ "Enable recording with `record true` in your operation, or use `Ticket.from_record` with an existing record."
198
+ end
199
+
200
+ def _validate_wait_args!(timeout, interval)
201
+ unless record
202
+ raise ArgumentError,
203
+ "wait requires a recorded operation. Possible causes: " \
204
+ "(1) recording is not enabled — add `record true` to your operation; " \
205
+ "(2) `record params: false` is set — async operations store params in the record to reconstruct " \
206
+ "the operation in the background job, so params: false forces the direct (non-recorded) strategy. " \
207
+ "If you need to avoid storing params (e.g., PII), consider encrypting params at the model level instead."
208
+ end
209
+ unless _valid_duration?(timeout) && timeout.to_f > 0
210
+ raise ArgumentError, "timeout must be a positive Numeric, got: #{timeout.inspect}"
211
+ end
212
+ if !interval.respond_to?(:call) && !(_valid_duration?(interval) && interval.to_f > 0)
213
+ raise ArgumentError, "interval must be a positive number or a callable, got: #{interval.inspect}"
214
+ end
215
+ end
216
+
217
+ def _build_operation_failed
218
+ Dex::OperationFailed.new(
219
+ operation_name: record.name || "Unknown",
220
+ exception_class: record.error_code || "Unknown",
221
+ exception_message: record.error_message || "(no message recorded)"
222
+ )
223
+ end
224
+
225
+ def _valid_duration?(value)
226
+ return true if value.is_a?(Numeric)
227
+ return true if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
228
+
229
+ false
230
+ end
231
+
232
+ def _coerce_typed_result(value)
233
+ return value if value.nil?
234
+
235
+ klass = record.name&.safe_constantize
236
+ return value unless klass
237
+
238
+ success_type = klass.respond_to?(:_success_type) && klass._success_type
239
+ return value unless success_type
240
+
241
+ klass.send(:_coerce_value, success_type, value)
242
+ rescue
243
+ value
244
+ end
245
+
246
+ def _unwrap_result(result)
247
+ return result unless result.is_a?(Hash) && result.key?("_dex_value")
248
+
249
+ result["_dex_value"]
250
+ end
251
+
252
+ def _symbolize_keys(value)
253
+ case value
254
+ when Hash
255
+ value.each_with_object({}) { |(k, v), h| h[k.to_sym] = _symbolize_keys(v) }
256
+ when Array
257
+ value.map { |v| _symbolize_keys(v) }
258
+ else
259
+ value
260
+ end
261
+ end
262
+
263
+ def _monotonic_now
264
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TraceWrapper
5
+ extend Dex::Concern
6
+
7
+ def _trace_wrap
8
+ @_dex_execution_id ||= Dex::Id.generate("op_")
9
+
10
+ Dex::Trace.with_frame(
11
+ type: :operation,
12
+ id: @_dex_execution_id,
13
+ class: self.class.name
14
+ ) do
15
+ @_dex_trace_id = Dex::Trace.trace_id
16
+ yield
17
+ end
18
+ end
19
+ end
20
+ end
data/lib/dex/operation.rb CHANGED
@@ -4,6 +4,7 @@
4
4
  require_relative "operation/result_wrapper"
5
5
  require_relative "operation/once_wrapper"
6
6
  require_relative "operation/record_wrapper"
7
+ require_relative "operation/trace_wrapper"
7
8
  require_relative "operation/transaction_wrapper"
8
9
  require_relative "operation/lock_wrapper"
9
10
  require_relative "operation/async_wrapper"
@@ -139,6 +140,7 @@ module Dex
139
140
  include AsyncWrapper
140
141
  include SafeWrapper
141
142
 
143
+ use TraceWrapper, at: :outer
142
144
  use ResultWrapper
143
145
  use GuardWrapper
144
146
  use OnceWrapper
@@ -156,6 +158,7 @@ require_relative "operation/async_proxy"
156
158
  require_relative "operation/record_backend"
157
159
  require_relative "operation/transaction_adapter"
158
160
  require_relative "operation/jobs"
161
+ require_relative "operation/ticket"
159
162
  require_relative "operation/explain"
160
163
  require_relative "operation/export"
161
164
 
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class OperationFailed < StandardError
5
+ attr_reader :operation_name, :exception_class, :exception_message
6
+
7
+ def initialize(operation_name:, exception_class:, exception_message:)
8
+ @operation_name = operation_name
9
+ @exception_class = exception_class
10
+ @exception_message = exception_message
11
+ super("#{operation_name} failed with #{exception_class}: #{exception_message}")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Query
5
+ module Export
6
+ module_function
7
+
8
+ def build_hash(source)
9
+ h = {}
10
+ h[:name] = source.name if source.name
11
+ desc = source.description
12
+ h[:description] = desc if desc
13
+ h[:props] = _serialize_props(source)
14
+ ctx = _serialize_context(source)
15
+ h[:context] = ctx unless ctx.empty?
16
+ h[:filters] = source.filters unless source.filters.empty?
17
+ h[:sorts] = source.sorts unless source.sorts.empty?
18
+ h
19
+ end
20
+
21
+ def build_json_schema(source) # rubocop:disable Metrics/MethodLength
22
+ descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
23
+ properties = {}
24
+ required = []
25
+
26
+ if source.respond_to?(:literal_properties)
27
+ source.literal_properties.each do |prop|
28
+ prop_desc = descs[prop.name]
29
+ schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
30
+ properties[prop.name.to_s] = schema
31
+ required << prop.name.to_s if prop.required?
32
+ end
33
+ end
34
+
35
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
36
+ result[:title] = source.name if source.name
37
+ desc = source.description
38
+ result[:description] = desc if desc
39
+ result[:type] = "object"
40
+ result[:properties] = properties unless properties.empty?
41
+ result[:required] = required unless required.empty?
42
+ result[:additionalProperties] = false
43
+ result
44
+ end
45
+
46
+ def _serialize_props(source)
47
+ return {} unless source.respond_to?(:literal_properties)
48
+
49
+ descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
50
+ source.literal_properties.each_with_object({}) do |prop, hash|
51
+ entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
52
+ entry[:desc] = descs[prop.name] if descs[prop.name]
53
+ hash[prop.name] = entry
54
+ end
55
+ end
56
+
57
+ def _serialize_context(source)
58
+ source.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
59
+ end
60
+
61
+ private_class_method :_serialize_props, :_serialize_context
62
+ end
63
+ end
64
+ end