dexkit 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ module Export
6
+ module_function
7
+
8
+ TYPE_MAP = {
9
+ string: { type: "string" },
10
+ integer: { type: "integer" },
11
+ float: { type: "number" },
12
+ decimal: { type: "number" },
13
+ boolean: { type: "boolean" },
14
+ date: { type: "string", format: "date" },
15
+ datetime: { type: "string", format: "date-time" },
16
+ time: { type: "string", format: "time" }
17
+ }.freeze
18
+
19
+ def build_hash(source)
20
+ h = _serialize_form_definition(source)
21
+ h[:name] = source.name if source.name
22
+ desc = source.description
23
+ h[:description] = desc if desc
24
+ h
25
+ end
26
+
27
+ def build_json_schema(source) # rubocop:disable Metrics/MethodLength
28
+ properties = {}
29
+ required = []
30
+
31
+ source._field_registry.each do |name, field_def|
32
+ schema = _field_to_schema(field_def)
33
+ properties[name.to_s] = schema
34
+ required << name.to_s if field_def.required
35
+ end
36
+
37
+ _add_nested_properties(source, properties, required)
38
+
39
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
40
+ result[:title] = source.name if source.name
41
+ desc = source.description
42
+ result[:description] = desc if desc
43
+ result[:properties] = properties unless properties.empty?
44
+ result[:required] = required unless required.empty?
45
+ result[:additionalProperties] = false
46
+ result
47
+ end
48
+
49
+ def _serialize_fields(source)
50
+ source._field_registry.each_with_object({}) do |(name, field_def), hash|
51
+ entry = { type: field_def.type, required: field_def.required }
52
+ entry[:desc] = field_def.desc if field_def.desc
53
+ entry[:default] = field_def.default if field_def.default?
54
+ hash[name] = entry
55
+ end
56
+ end
57
+
58
+ def _serialize_form_definition(source)
59
+ h = { fields: _serialize_fields(source) }
60
+ nested = _serialize_nested(source)
61
+ h[:nested] = nested unless nested.empty?
62
+ h
63
+ end
64
+
65
+ def _serialize_nested(source)
66
+ nested = {}
67
+ source._nested_ones.each do |name, klass|
68
+ nested[name] = { type: :one }.merge(_serialize_form_definition(klass))
69
+ end
70
+ source._nested_manys.each do |name, klass|
71
+ nested[name] = { type: :many }.merge(_serialize_form_definition(klass))
72
+ end
73
+ nested
74
+ end
75
+
76
+ def _field_to_schema(field_def)
77
+ schema = TYPE_MAP[field_def.type]&.dup || {}
78
+ schema[:description] = field_def.desc if field_def.desc
79
+ schema[:default] = _coerce_default(field_def) if field_def.default?
80
+ schema
81
+ end
82
+
83
+ def _coerce_default(field_def)
84
+ val = field_def.default
85
+ case field_def.type
86
+ when :integer then val.is_a?(Integer) ? val : val.to_i
87
+ when :float, :decimal then val.is_a?(Float) ? val : val.to_f
88
+ when :boolean then !!val
89
+ when :string, :date, :datetime, :time then val.to_s
90
+ else val
91
+ end
92
+ end
93
+
94
+ def _nested_json_schema(klass)
95
+ properties = {}
96
+ required = []
97
+
98
+ klass._field_registry.each do |name, field_def|
99
+ schema = _field_to_schema(field_def)
100
+ properties[name.to_s] = schema
101
+ required << name.to_s if field_def.required
102
+ end
103
+
104
+ _add_nested_properties(klass, properties, required)
105
+
106
+ result = { type: "object" }
107
+ result[:properties] = properties unless properties.empty?
108
+ result[:required] = required unless required.empty?
109
+ result[:additionalProperties] = false
110
+ result
111
+ end
112
+
113
+ def _add_nested_properties(source, properties, required)
114
+ source._nested_ones.each do |name, klass|
115
+ properties[name.to_s] = _nested_json_schema(klass)
116
+ required << name.to_s
117
+ end
118
+
119
+ source._nested_manys.each do |name, klass|
120
+ properties[name.to_s] = { type: "array", items: _nested_json_schema(klass) }
121
+ end
122
+ end
123
+
124
+ private_class_method :_serialize_fields, :_serialize_form_definition, :_serialize_nested, :_field_to_schema,
125
+ :_coerce_default, :_nested_json_schema, :_add_nested_properties
126
+ end
127
+ end
128
+ end
@@ -70,6 +70,8 @@ module Dex
70
70
 
71
71
  def _build_nested_class(name, class_name, &block)
72
72
  klass = Class.new(Dex::Form, &block)
73
+ klass.instance_variable_set(:@_dex_nested_form, true)
74
+ Dex::Form.deregister(klass)
73
75
  const_name = class_name || name.to_s.singularize.camelize
74
76
  const_set(const_name, klass)
75
77
  klass
data/lib/dex/form.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "active_model"
4
4
 
5
5
  require_relative "form/nesting"
6
+ require_relative "form/context"
6
7
 
7
8
  module Dex
8
9
  class Form
@@ -15,8 +16,17 @@ module Dex
15
16
  end
16
17
 
17
18
  include Nesting
19
+ include Context
18
20
  include Match
19
21
 
22
+ extend Registry
23
+
24
+ FIELD_DEFAULT_UNSET = Object.new.freeze
25
+
26
+ FieldDef = Data.define(:name, :type, :desc, :required, :default) do
27
+ def default? = !default.equal?(Dex::Form::FIELD_DEFAULT_UNSET)
28
+ end
29
+
20
30
  class ValidationError < StandardError
21
31
  attr_reader :form
22
32
 
@@ -27,6 +37,37 @@ module Dex
27
37
  end
28
38
 
29
39
  class << self
40
+ def _field_registry
41
+ @_field_registry ||= {}
42
+ end
43
+
44
+ def _required_fields
45
+ _field_registry.each_with_object([]) do |(name, f), list|
46
+ list << name if f.required
47
+ end
48
+ end
49
+
50
+ def field(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
51
+ raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
52
+ raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
53
+ raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
54
+
55
+ am_options = options.dup
56
+ am_options[:default] = default unless default.equal?(FIELD_DEFAULT_UNSET)
57
+ attribute name, type, **am_options
58
+ _field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: true, default: default)
59
+ end
60
+
61
+ def field?(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
62
+ raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
63
+ raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
64
+ raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
65
+
66
+ actual_default = default.equal?(FIELD_DEFAULT_UNSET) ? nil : default
67
+ attribute name, type, default: actual_default, **options
68
+ _field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: false, default: default)
69
+ end
70
+
30
71
  def model(klass = nil)
31
72
  if klass
32
73
  raise ArgumentError, "model must be a Class, got #{klass.inspect}" unless klass.is_a?(Class)
@@ -42,9 +83,38 @@ module Dex
42
83
 
43
84
  def inherited(subclass)
44
85
  super
86
+ subclass.instance_variable_set(:@_field_registry, _field_registry.dup)
45
87
  subclass.instance_variable_set(:@_nested_ones, _nested_ones.dup)
46
88
  subclass.instance_variable_set(:@_nested_manys, _nested_manys.dup)
47
89
  end
90
+
91
+ def _dex_nested_form?
92
+ !!instance_variable_get(:@_dex_nested_form)
93
+ end
94
+
95
+ # Export
96
+
97
+ def to_h
98
+ Export.build_hash(self)
99
+ end
100
+
101
+ def to_json_schema
102
+ Export.build_json_schema(self)
103
+ end
104
+
105
+ def export(format: :hash)
106
+ unless %i[hash json_schema].include?(format)
107
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
108
+ end
109
+
110
+ sorted = registry.reject { |klass| klass._dex_nested_form? }.sort_by(&:name)
111
+ sorted.map do |klass|
112
+ case format
113
+ when :hash then klass.to_h
114
+ when :json_schema then klass.to_json_schema
115
+ end
116
+ end
117
+ end
48
118
  end
49
119
 
50
120
  silence_redefinition_of_method :model_name
@@ -66,6 +136,18 @@ module Dex
66
136
  # setters are assignable; everything else is silently dropped.
67
137
  attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
68
138
  attrs = (attributes || {}).transform_keys(&:to_s)
139
+
140
+ # Context injection — fill ambient values for unmapped keys
141
+ mappings = self.class.context_mappings
142
+ unless mappings.empty?
143
+ ambient = Dex.context
144
+ mappings.each do |attr_name, context_key|
145
+ str_name = attr_name.to_s
146
+ next if attrs.key?(str_name)
147
+ attrs[str_name] = ambient[context_key] if ambient.key?(context_key)
148
+ end
149
+ end
150
+
69
151
  record = attrs.delete("record")
70
152
  @record = record if record.nil? || record.respond_to?(:persisted?)
71
153
  provided_keys = attrs.keys
@@ -95,9 +177,10 @@ module Dex
95
177
  end
96
178
 
97
179
  def valid?(context = nil)
98
- super_result = super
99
- nested_result = _validate_nested(context)
100
- super_result && nested_result
180
+ super
181
+ _fix_boolean_presence_errors
182
+ nested_valid = _validate_nested(context)
183
+ errors.empty? && nested_valid
101
184
  end
102
185
 
103
186
  def to_h
@@ -111,8 +194,40 @@ module Dex
111
194
 
112
195
  alias_method :to_hash, :to_h
113
196
 
197
+ validate :_validate_required_fields
198
+
114
199
  private
115
200
 
201
+ def _validate_required_fields
202
+ explicit = self.class.validators
203
+ .select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
204
+ .reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
205
+ .flat_map(&:attributes).map(&:to_sym).to_set
206
+
207
+ self.class._required_fields.each do |name|
208
+ next if explicit.include?(name)
209
+ field_def = self.class._field_registry[name]
210
+ value = public_send(name)
211
+ blank = (field_def.type == :boolean) ? value.nil? : value.blank?
212
+ errors.add(name, :blank) if blank
213
+ end
214
+ end
215
+
216
+ def _fix_boolean_presence_errors
217
+ explicit = self.class.validators
218
+ .select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
219
+ .reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
220
+ .flat_map(&:attributes).map(&:to_sym)
221
+
222
+ explicit.each do |name|
223
+ field_def = self.class._field_registry[name]
224
+ next unless field_def&.type == :boolean
225
+ next if public_send(name).nil?
226
+
227
+ errors.delete(name, :blank)
228
+ end
229
+ end
230
+
116
231
  def _extract_nested_attributes(attrs)
117
232
  nested_keys = self.class._nested_ones.keys.map(&:to_s) +
118
233
  self.class._nested_manys.keys.map(&:to_s)
@@ -140,3 +255,4 @@ module Dex
140
255
  end
141
256
 
142
257
  require_relative "form/uniqueness_validator"
258
+ require_relative "form/export"
data/lib/dex/id.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dex
6
+ module Id
7
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
8
+ TIMESTAMP_WIDTH = 8
9
+ RANDOM_WIDTH = 12
10
+
11
+ module_function
12
+
13
+ def generate(prefix)
14
+ "#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(RANDOM_WIDTH)}"
15
+ end
16
+
17
+ def base58_encode(number, width = nil)
18
+ encoded = +""
19
+ value = number.to_i
20
+
21
+ loop do
22
+ value, remainder = value.divmod(ALPHABET.length)
23
+ encoded.prepend(ALPHABET[remainder])
24
+ break unless value.positive?
25
+ end
26
+
27
+ width ? encoded.rjust(width, ALPHABET[0]) : encoded
28
+ end
29
+
30
+ def random_suffix(width)
31
+ Array.new(width) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
32
+ end
33
+
34
+ def current_milliseconds
35
+ Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
36
+ end
37
+ end
38
+ end
@@ -21,21 +21,31 @@ module Dex
21
21
 
22
22
  def enqueue_direct_job
23
23
  job = apply_options(Operation::DirectJob)
24
- payload = { class_name: operation_class_name, params: serialized_params }
24
+ payload = {
25
+ class_name: operation_class_name,
26
+ params: serialized_params,
27
+ trace: Dex::Trace.dump
28
+ }
25
29
  apply_once_payload!(payload)
26
30
  job.perform_later(**payload)
27
31
  end
28
32
 
29
33
  def enqueue_record_job
30
34
  @operation.send(:_record_validate_backend!, async: true)
35
+ execution_id = Dex::Id.generate("op_")
31
36
  record = Dex.record_backend.create_record(
37
+ id: execution_id,
32
38
  name: operation_class_name,
33
39
  params: serialized_params,
34
40
  status: "pending"
35
41
  )
36
42
  begin
37
43
  job = apply_options(Operation::RecordJob)
38
- payload = { class_name: operation_class_name, record_id: record.id.to_s }
44
+ payload = {
45
+ class_name: operation_class_name,
46
+ record_id: record.id.to_s,
47
+ trace: Dex::Trace.dump
48
+ }
39
49
  apply_once_payload!(payload)
40
50
  job.perform_later(**payload)
41
51
  rescue => e
@@ -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,
@@ -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
@@ -199,6 +199,30 @@ module Dex
199
199
  "Expected no operations to be enqueued, but #{after_count - before_count} were"
200
200
  end
201
201
 
202
+ # --- Trace assertions ---
203
+
204
+ def assert_trace_includes(operation_class, msg: nil)
205
+ expected = operation_class.is_a?(Class) ? operation_class.name : operation_class.to_s
206
+ trace_classes = Dex::Trace.current.map { |frame| frame[:class] }.compact
207
+
208
+ assert_includes trace_classes, expected,
209
+ msg || "Expected trace to include #{expected.inspect}, got #{trace_classes.inspect}"
210
+ end
211
+
212
+ def assert_trace_actor(type:, id: :_not_given, msg: nil)
213
+ actor = Dex::Trace.actor
214
+ refute_nil actor, msg || "Expected a trace actor, but no actor is set"
215
+ assert_equal type.to_s, actor[:actor_type], msg || "Trace actor type mismatch"
216
+ assert_equal id.to_s, actor[:id], msg || "Trace actor id mismatch" unless id == :_not_given
217
+ actor
218
+ end
219
+
220
+ def assert_trace_depth(expected, msg: nil)
221
+ actual = Dex::Trace.current.size
222
+ assert_equal expected, actual,
223
+ msg || "Expected trace depth #{expected}, got #{actual}.\nTrace: #{Dex::Trace.current.inspect}"
224
+ end
225
+
202
226
  # --- Transaction assertions ---
203
227
 
204
228
  def assert_rolls_back(model_class, &block)
@@ -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,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
@@ -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