fast-mcp 1.5.0 → 1.6.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.
data/lib/mcp/tool.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'dry-schema'
4
4
 
5
+ Dry::Schema.load_extensions(:json_schema)
6
+
5
7
  # Extend Dry::Schema macros to support description
6
8
  module Dry
7
9
  module Schema
@@ -62,13 +64,86 @@ module Dry
62
64
  def description(text)
63
65
  key_name = name.to_sym
64
66
  schema_dsl.meta(key_name, :description, text)
67
+
68
+ # Mark this hash as having metadata so we know to track nested context
69
+ @has_metadata = true
70
+ self
71
+ end
72
+
73
+ def hidden(hidden = true) # rubocop:disable Style/OptionalBooleanParameter
74
+ key_name = name.to_sym
75
+ schema_dsl.meta(key_name, :hidden, hidden)
76
+
77
+ # Mark this hash as having metadata so we know to track nested context
78
+ @has_metadata = true
65
79
  self
66
80
  end
81
+
82
+ # Override call method to manage nested context
83
+ alias original_call call
84
+
85
+ def call(&block)
86
+ if block
87
+ # Use current context to track nested context if available
88
+ context = MetadataContext.current
89
+ if context
90
+ context.with_nested(name) do
91
+ original_call(&block)
92
+ end
93
+ else
94
+ original_call(&block)
95
+ end
96
+ else
97
+ original_call(&block)
98
+ end
99
+ end
67
100
  end
68
101
  end
69
102
  end
70
103
  end
71
104
 
105
+ # Context object for managing nested metadata collection
106
+ class MetadataContext
107
+ def initialize
108
+ @metadata = {}
109
+ @nesting_stack = []
110
+ end
111
+
112
+ attr_reader :metadata
113
+
114
+ def store(property_name, meta_key, value)
115
+ path = current_path + [property_name.to_s]
116
+ full_path = path.join('.')
117
+
118
+ @metadata[full_path] ||= {}
119
+ @metadata[full_path][meta_key] = value
120
+ end
121
+
122
+ def with_nested(parent_property)
123
+ @nesting_stack.push(parent_property.to_s)
124
+ yield
125
+ ensure
126
+ @nesting_stack.pop
127
+ end
128
+
129
+ def current_path
130
+ @nesting_stack.dup
131
+ end
132
+
133
+ # Class method to set/get current context for thread-safe access
134
+ def self.current
135
+ Thread.current[:metadata_context]
136
+ end
137
+
138
+ def self.with_context(context)
139
+ old_context = Thread.current[:metadata_context]
140
+ Thread.current[:metadata_context] = context
141
+ yield
142
+ ensure
143
+ Thread.current[:metadata_context] = old_context
144
+ end
145
+ end
146
+
72
147
  # Extend Dry::Schema DSL to store metadata
73
148
  module Dry
74
149
  module Schema
@@ -77,6 +152,12 @@ module Dry
77
152
  @meta ||= {}
78
153
  @meta[key_name] ||= {}
79
154
  @meta[key_name][meta_key] = value
155
+
156
+ # Store in current context if available
157
+ context = MetadataContext.current
158
+ return unless context
159
+
160
+ context.store(key_name, meta_key, value)
80
161
  end
81
162
 
82
163
  def meta_data
@@ -86,6 +167,96 @@ module Dry
86
167
  end
87
168
  end
88
169
 
170
+ # Schema metadata processor for handling custom predicates in JSON schema output
171
+ class SchemaMetadataProcessor
172
+ def self.process(schema, collected_metadata = {})
173
+ return nil unless schema
174
+
175
+ base_schema = schema.json_schema.tap { _1.delete(:$schema) }
176
+ metadata = extract_metadata(schema)
177
+
178
+ # Merge traditional metadata with collected nested metadata
179
+ all_metadata = merge_metadata(metadata, collected_metadata)
180
+
181
+ apply_metadata_to_schema(base_schema, all_metadata)
182
+ end
183
+
184
+ private_class_method def self.extract_metadata(schema)
185
+ schema_dsl = schema.instance_variable_get(:@schema_dsl)
186
+ schema_dsl&.meta_data || {}
187
+ end
188
+
189
+ private_class_method def self.merge_metadata(traditional, collected)
190
+ # Remove internal keys from collected metadata
191
+ filtered_collected = collected.reject { |key, _| key.to_s.start_with?('_') }
192
+
193
+ # Start with traditional metadata
194
+ merged = traditional.dup
195
+
196
+ # Add collected metadata with full paths
197
+ filtered_collected.each do |path, metadata|
198
+ merged[path] = metadata
199
+ end
200
+
201
+ merged
202
+ end
203
+
204
+ private_class_method def self.apply_metadata_to_schema(base_schema, metadata)
205
+ return base_schema if !base_schema[:properties] || metadata.empty?
206
+
207
+ base_schema[:properties] = process_properties_recursively(base_schema[:properties], metadata, [])
208
+ base_schema[:required] = filter_required_properties(base_schema[:required], base_schema[:properties])
209
+ base_schema
210
+ end
211
+
212
+ private_class_method def self.process_properties_recursively(properties, metadata, path_prefix = [])
213
+ filtered_properties = {}
214
+
215
+ properties.each do |property_name, property_schema|
216
+ current_path = (path_prefix + [property_name.to_s]).join('.')
217
+
218
+ # Look for metadata using both simple key and full path
219
+ property_key = property_name.to_sym
220
+ property_metadata = metadata[property_key] || metadata[current_path]
221
+
222
+ # Skip hidden properties entirely
223
+ next if property_metadata&.dig(:hidden)
224
+
225
+ # Add description if present
226
+ property_schema[:description] = property_metadata[:description] if property_metadata&.dig(:description)
227
+
228
+ # Recursively process nested object properties
229
+ if property_schema[:type] == 'object' && property_schema[:properties]
230
+ nested_path = path_prefix + [property_name.to_s]
231
+ property_schema[:properties] =
232
+ process_properties_recursively(property_schema[:properties], metadata, nested_path)
233
+ property_schema[:required] =
234
+ filter_required_properties(property_schema[:required], property_schema[:properties])
235
+ # Recursively process array items with object properties
236
+ elsif property_schema[:type] == 'array' && property_schema.dig(:items, :type) == 'object' &&
237
+ property_schema.dig(:items, :properties)
238
+ nested_path = path_prefix + [property_name.to_s]
239
+ property_schema[:items][:properties] =
240
+ process_properties_recursively(property_schema[:items][:properties], metadata, nested_path)
241
+ property_schema[:items][:required] =
242
+ filter_required_properties(property_schema[:items][:required], property_schema[:items][:properties])
243
+ end
244
+
245
+ filtered_properties[property_name] = property_schema
246
+ end
247
+
248
+ filtered_properties
249
+ end
250
+
251
+ private_class_method def self.filter_required_properties(required_array, properties)
252
+ return [] unless required_array
253
+
254
+ required_array.select do |required_prop|
255
+ properties.key?(required_prop.to_sym) || properties.key?(required_prop.to_s)
256
+ end
257
+ end
258
+ end
259
+
89
260
  module FastMcp
90
261
  # Main Tool class that represents an MCP Tool
91
262
  class Tool
@@ -116,7 +287,13 @@ module FastMcp
116
287
  end
117
288
 
118
289
  def arguments(&block)
119
- @input_schema = Dry::Schema.JSON(&block)
290
+ @metadata_context = MetadataContext.new
291
+
292
+ @input_schema = MetadataContext.with_context(@metadata_context) do
293
+ Dry::Schema.JSON(&block)
294
+ end
295
+
296
+ @collected_metadata = @metadata_context.metadata
120
297
  end
121
298
 
122
299
  def input_schema
@@ -124,7 +301,10 @@ module FastMcp
124
301
  end
125
302
 
126
303
  def tool_name(name = nil)
127
- return @name || self.name if name.nil?
304
+ name = @name || self.name if name.nil?
305
+ return if name.nil?
306
+
307
+ name = name.gsub(/[^a-zA-Z0-9_-]/, '')[0, 64]
128
308
 
129
309
  @name = name
130
310
  end
@@ -135,6 +315,12 @@ module FastMcp
135
315
  @description = description
136
316
  end
137
317
 
318
+ def annotations(annotations_hash = nil)
319
+ return @annotations || {} if annotations_hash.nil?
320
+
321
+ @annotations = annotations_hash
322
+ end
323
+
138
324
  def authorize(&block)
139
325
  @authorization_blocks ||= []
140
326
  @authorization_blocks.push block
@@ -145,10 +331,7 @@ module FastMcp
145
331
  end
146
332
 
147
333
  def input_schema_to_json
148
- return nil unless @input_schema
149
-
150
- compiler = SchemaCompiler.new
151
- compiler.process(@input_schema)
334
+ SchemaMetadataProcessor.process(@input_schema, @collected_metadata || {})
152
335
  end
153
336
  end
154
337
 
@@ -193,651 +376,6 @@ module FastMcp
193
376
  [call(**args), _meta]
194
377
  end
195
378
  end
196
-
197
- # Module for handling schema metadata
198
- module SchemaMetadataExtractor
199
- # Extract metadata from a schema
200
- def extract_metadata_from_schema(schema)
201
- # a deeply-assignable hash, the default value of a key is {}
202
- metadata = Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }
203
-
204
- # Extract descriptions from the top-level schema
205
- if schema.respond_to?(:schema_dsl) && schema.schema_dsl.respond_to?(:meta_data)
206
- schema.schema_dsl.meta_data.each do |key, meta|
207
- metadata[key.to_s][:description] = meta[:description] if meta[:description]
208
- metadata[key.to_s][:hidden] = meta[:hidden]
209
- end
210
- end
211
-
212
- # Extract metadata from nested schemas using AST
213
- schema.rules.each_value do |rule|
214
- next unless rule.respond_to?(:ast)
215
-
216
- extract_metadata_from_ast(rule.ast, metadata)
217
- end
218
-
219
- metadata
220
- end
221
-
222
- # Extract metadata from AST
223
- def extract_metadata_from_ast(ast, metadata, parent_key = nil)
224
- return unless ast.is_a?(Array)
225
-
226
- process_key_node(ast, metadata, parent_key) if ast[0] == :key
227
- process_set_node(ast, metadata, parent_key) if ast[0] == :set
228
- process_and_node(ast, metadata, parent_key) if ast[0] == :and
229
- end
230
-
231
- # Process a key node in the AST
232
- def process_key_node(ast, metadata, parent_key)
233
- return unless ast[1].is_a?(Array) && ast[1].size >= 2
234
-
235
- key = ast[1][0]
236
- full_key = parent_key ? "#{parent_key}.#{key}" : key.to_s
237
-
238
- # Process nested AST
239
- extract_metadata_from_ast(ast[1][1], metadata, full_key) if ast[1][1].is_a?(Array)
240
- end
241
-
242
- # Process a set node in the AST
243
- def process_set_node(ast, metadata, parent_key)
244
- return unless ast[1].is_a?(Array)
245
-
246
- ast[1].each do |set_node|
247
- extract_metadata_from_ast(set_node, metadata, parent_key)
248
- end
249
- end
250
-
251
- # Process an and node in the AST
252
- def process_and_node(ast, metadata, parent_key)
253
- return unless ast[1].is_a?(Array)
254
-
255
- # Process each child node
256
- ast[1].each do |and_node|
257
- extract_metadata_from_ast(and_node, metadata, parent_key)
258
- end
259
-
260
- # Process nested properties
261
- process_nested_properties(ast, metadata, parent_key)
262
- end
263
-
264
- # Process nested properties in an and node
265
- def process_nested_properties(ast, metadata, parent_key)
266
- ast[1].each do |node|
267
- next unless node[0] == :key && node[1].is_a?(Array) && node[1][1].is_a?(Array) && node[1][1][0] == :and
268
-
269
- key_name = node[1][0]
270
- nested_key = parent_key ? "#{parent_key}.#{key_name}" : key_name.to_s
271
-
272
- process_nested_schema_ast(node[1][1], metadata, nested_key)
273
- end
274
- end
275
-
276
- # Process a nested schema
277
- def process_nested_schema_ast(ast, metadata, nested_key)
278
- return unless ast[1].is_a?(Array)
279
-
280
- ast[1].each do |subnode|
281
- next unless subnode[0] == :set && subnode[1].is_a?(Array)
282
-
283
- subnode[1].each do |set_node|
284
- next unless set_node[0] == :and && set_node[1].is_a?(Array)
285
-
286
- process_nested_keys(set_node, metadata, nested_key)
287
- end
288
- end
289
- end
290
-
291
- # Process nested keys in a schema
292
- def process_nested_keys(set_node, metadata, nested_key)
293
- set_node[1].each do |and_node|
294
- next unless and_node[0] == :key && and_node[1].is_a?(Array) && and_node[1].size >= 2
295
-
296
- nested_field = and_node[1][0]
297
- nested_path = "#{nested_key}.#{nested_field}"
298
-
299
- extract_metadata(and_node, metadata, nested_path)
300
- end
301
- end
302
-
303
- # Extract metadata from a node
304
- def extract_metadata(and_node, metadata, nested_path)
305
- return unless and_node[1][1].is_a?(Array) && and_node[1][1][1].is_a?(Array)
306
-
307
- and_node[1][1][1].each do |meta_node|
308
- next unless meta_node[0] == :meta && meta_node[1].is_a?(Hash) && meta_node[1][:description]
309
-
310
- metadata[nested_path] = meta_node[1][:description]
311
- end
312
- end
313
- end
314
-
315
- # Module for handling rule type detection
316
- module RuleTypeDetector
317
- # Check if a rule is for a hash type
318
- def hash_type?(rule)
319
- return true if direct_hash_predicate?(rule) || nested_hash_predicate?(rule)
320
-
321
- false
322
- end
323
-
324
- # Check for direct hash predicate
325
- def direct_hash_predicate?(rule)
326
- return false unless rule.is_a?(Dry::Logic::Operations::And)
327
-
328
- rule.rules.any? { |r| r.respond_to?(:name) && r.name == :hash? }
329
- end
330
-
331
- # Check for nested hash predicate
332
- def nested_hash_predicate?(rule)
333
- if rule.is_a?(Dry::Logic::Operations::Key) && rule.rule.is_a?(Dry::Logic::Operations::And)
334
- return rule.rule.rules.any? { |r| r.respond_to?(:name) && r.name == :hash? }
335
- end
336
-
337
- if rule.respond_to?(:right) && rule.right.is_a?(Dry::Logic::Operations::Key) &&
338
- rule.right.rule.is_a?(Dry::Logic::Operations::And)
339
- return rule.right.rule.rules.any? { |r| r.respond_to?(:name) && r.name == :hash? }
340
- end
341
-
342
- false
343
- end
344
-
345
- # Check if a rule is for an array type
346
- def array_type?(rule)
347
- rule.is_a?(Dry::Logic::Operations::And) &&
348
- rule.rules.any? { |r| r.respond_to?(:name) && r.name == :array? }
349
- end
350
- end
351
-
352
- # Module for handling predicates
353
- module PredicateHandler
354
- # Extract predicates from a rule
355
- def extract_predicates(rule, key, properties = nil)
356
- properties ||= @json_schema[:properties]
357
-
358
- case rule
359
- when Dry::Logic::Operations::And
360
- rule.rules.each { |r| extract_predicates(r, key, properties) }
361
- when Dry::Logic::Operations::Implication
362
- extract_predicates(rule.right, key, properties)
363
- when Dry::Logic::Operations::Key
364
- extract_predicates(rule.rule, key, properties)
365
- when Dry::Logic::Operations::Set
366
- rule.rules.each { |r| extract_predicates(r, key, properties) }
367
- else
368
- process_predicate(rule, key, properties) if rule.respond_to?(:name)
369
- end
370
- end
371
-
372
- # Process a predicate
373
- def process_predicate(rule, key, properties)
374
- predicate_name = rule.name
375
- args = extract_predicate_args(rule)
376
- add_predicate_description(predicate_name, args, key, properties)
377
- end
378
-
379
- # Extract arguments from a predicate
380
- def extract_predicate_args(rule)
381
- if rule.respond_to?(:args) && !rule.args.nil?
382
- rule.args
383
- elsif rule.respond_to?(:predicate) && rule.predicate.respond_to?(:arguments)
384
- rule.predicate.arguments
385
- else
386
- []
387
- end
388
- end
389
-
390
- # Add predicate description to schema
391
- def add_predicate_description(predicate_name, args, key_name, properties)
392
- property = properties[key_name]
393
-
394
- case predicate_name
395
- when :array?, :bool?, :decimal?, :float?, :hash?, :int?, :nil?, :str?
396
- add_basic_type(predicate_name, property)
397
- when :date?, :date_time?, :time?
398
- add_date_time_format(predicate_name, property)
399
- when :min_size?, :max_size?, :included_in?
400
- add_string_constraint(predicate_name, args, property)
401
- when :filled?
402
- # Already handled by the required array
403
- when :uri?
404
- property[:format] = 'uri'
405
- when :uuid_v1?, :uuid_v2?, :uuid_v3?, :uuid_v4?, :uuid_v5?
406
- add_uuid_pattern(predicate_name, property)
407
- when :gt?, :gteq?, :lt?, :lteq?
408
- add_numeric_constraint(predicate_name, args, property)
409
- when :odd?, :even?
410
- add_number_constraint(predicate_name, property)
411
- when :format?
412
- add_format_constraint(args, property)
413
- when :key?
414
- nil
415
- end
416
- end
417
- end
418
-
419
- # Module for handling basic type predicates
420
- module BasicTypePredicateHandler
421
- # Add basic type to schema
422
- def add_basic_type(predicate_name, property)
423
- case predicate_name
424
- when :array?
425
- property[:type] = 'array'
426
- property[:items] = {}
427
- when :bool?
428
- property[:type] = 'boolean'
429
- when :int?, :decimal?, :float?
430
- property[:type] = 'number'
431
- when :hash?
432
- property[:type] = 'object'
433
- when :nil?
434
- property[:type] = 'null'
435
- when :str?
436
- property[:type] = 'string'
437
- end
438
- end
439
-
440
- # Add string constraint to schema
441
- def add_string_constraint(predicate_name, args, property)
442
- case predicate_name
443
- when :min_size?
444
- property[:minLength] = args[0].to_i
445
- when :max_size?
446
- property[:maxLength] = args[0].to_i
447
- when :included_in?
448
- property[:enum] = args[0].to_a
449
- end
450
- end
451
-
452
- # Add numeric constraint to schema
453
- def add_numeric_constraint(predicate_name, args, property)
454
- case predicate_name
455
- when :gt?
456
- property[:exclusiveMinimum] = args[0]
457
- when :gteq?
458
- property[:minimum] = args[0]
459
- when :lt?
460
- property[:exclusiveMaximum] = args[0]
461
- when :lteq?
462
- property[:maximum] = args[0]
463
- end
464
- end
465
- end
466
-
467
- # Module for handling format predicates
468
- module FormatPredicateHandler
469
- # Add date/time format to schema
470
- def add_date_time_format(predicate_name, property)
471
- property[:type] = 'string'
472
- case predicate_name
473
- when :date?
474
- property[:format] = 'date'
475
- when :date_time?
476
- property[:format] = 'date-time'
477
- when :time?
478
- property[:format] = 'time'
479
- end
480
- end
481
-
482
- # Add UUID pattern to schema
483
- def add_uuid_pattern(predicate_name, property)
484
- version = predicate_name.to_s.split('_').last[1].to_i
485
- property[:pattern] = if version == 4
486
- '^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}$'
487
- else
488
- "^[0-9A-F]{8}-[0-9A-F]{4}-#{version}[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
489
- end
490
- end
491
-
492
- # Add number constraint to schema
493
- def add_number_constraint(predicate_name, property)
494
- property[:type] = 'integer'
495
- case predicate_name
496
- when :odd?
497
- property[:not] = { multipleOf: 2 }
498
- when :even?
499
- property[:multipleOf] = 2
500
- end
501
- end
502
-
503
- # Add format constraint to schema
504
- def add_format_constraint(args, property)
505
- return unless args[0].is_a?(Symbol)
506
-
507
- case args[0]
508
- when :date_time
509
- property[:format] = 'date-time'
510
- when :date
511
- property[:format] = 'date'
512
- when :time
513
- property[:format] = 'time'
514
- when :email
515
- property[:format] = 'email'
516
- when :uri
517
- property[:format] = 'uri'
518
- end
519
- end
520
- end
521
-
522
- # Module for handling nested rules
523
- module NestedRuleHandler
524
- # Extract nested rules from a rule
525
- def extract_nested_rules(rule)
526
- nested_rules = {}
527
-
528
- case rule
529
- when Dry::Logic::Operations::And
530
- extract_nested_rules_from_and(rule, nested_rules)
531
- when Dry::Logic::Operations::Implication
532
- extract_nested_rules_from_implication(rule, nested_rules)
533
- when Dry::Logic::Operations::Key
534
- extract_nested_rules_from_and(rule.rule, nested_rules) if rule.rule.is_a?(Dry::Logic::Operations::And)
535
- end
536
-
537
- nested_rules
538
- end
539
-
540
- # Extract nested rules from an And operation
541
- def extract_nested_rules_from_and(rule, nested_rules)
542
- # Look for Set operations directly in the rule
543
- set_op = rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
544
-
545
- if set_op
546
- process_set_operation(set_op, nested_rules)
547
- return
548
- end
549
-
550
- # If no direct Set operation, look for Key operations in the rule structure
551
- key_ops = rule.rules.select { |r| r.is_a?(Dry::Logic::Operations::Key) }
552
-
553
- key_ops.each do |key_op|
554
- next unless key_op.rule.is_a?(Dry::Logic::Operations::And)
555
-
556
- # Look for Set operations in the Key operation's rule
557
- set_op = key_op.rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
558
- process_set_operation(set_op, nested_rules) if set_op
559
-
560
- # Also look for direct predicates
561
- key_op.rule.rules.each do |r|
562
- if r.respond_to?(:name) && r.name != :hash?
563
- nested_key = key_op.path
564
- nested_rules[nested_key] = key_op.rule
565
- end
566
- end
567
- end
568
- end
569
-
570
- # Extract nested rules from an Implication operation
571
- def extract_nested_rules_from_implication(rule, nested_rules)
572
- # For optional fields (Implication), we need to check the right side
573
- if rule.right.is_a?(Dry::Logic::Operations::Key)
574
- extract_from_implication_key(rule.right, nested_rules)
575
- elsif rule.right.is_a?(Dry::Logic::Operations::And)
576
- extract_from_implication_and(rule.right, nested_rules)
577
- end
578
- end
579
-
580
- # Extract from implication key
581
- def extract_from_implication_key(key_rule, nested_rules)
582
- return unless key_rule.rule.is_a?(Dry::Logic::Operations::And)
583
-
584
- # Look for Set operations directly in the rule
585
- set_op = key_rule.rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
586
- process_set_operation(set_op, nested_rules) if set_op
587
- end
588
-
589
- # Extract from implication and
590
- def extract_from_implication_and(and_rule, nested_rules)
591
- # Look for Set operations directly in the right side
592
- set_op = and_rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
593
-
594
- if set_op
595
- process_set_operation(set_op, nested_rules)
596
- return
597
- end
598
-
599
- # If no direct Set operation, look for Key operations in the rule structure
600
- key_op = and_rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Key) }
601
- return unless key_op && key_op.rule.is_a?(Dry::Logic::Operations::And)
602
-
603
- # Look for Set operations in the Key operation's rule
604
- set_op = key_op.rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
605
- process_set_operation(set_op, nested_rules) if set_op
606
- end
607
-
608
- # Process a set operation
609
- def process_set_operation(set_op, nested_rules)
610
- # Process each rule in the Set operation
611
- set_op.rules.each do |set_rule|
612
- next unless set_rule.is_a?(Dry::Logic::Operations::And) ||
613
- set_rule.is_a?(Dry::Logic::Operations::Implication)
614
-
615
- # For Implication (optional fields), we need to check the right side
616
- if set_rule.is_a?(Dry::Logic::Operations::Implication)
617
- process_nested_rule(set_rule.right, nested_rules, true)
618
- else
619
- process_nested_rule(set_rule, nested_rules, false)
620
- end
621
- end
622
- end
623
-
624
- # Process a nested rule
625
- def process_nested_rule(rule, nested_rules, is_optional)
626
- # Find the key operation which contains the nested key name
627
- nested_key_op = find_nested_key_op(rule)
628
- return unless nested_key_op
629
-
630
- # Get the nested key name
631
- nested_key = nested_key_op.respond_to?(:path) ? nested_key_op.path : nil
632
- return unless nested_key
633
-
634
- # Add to nested rules
635
- add_to_nested_rules(nested_key, nested_key_op, nested_rules, is_optional)
636
- end
637
-
638
- # Find nested key operation
639
- def find_nested_key_op(rule)
640
- if rule.is_a?(Dry::Logic::Operations::And)
641
- rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Key) }
642
- elsif rule.is_a?(Dry::Logic::Operations::Key)
643
- rule
644
- end
645
- end
646
-
647
- # Add to nested rules
648
- def add_to_nested_rules(nested_key, nested_key_op, nested_rules, is_optional)
649
- nested_rules[nested_key] = if is_optional
650
- # For optional fields, create an Implication wrapper
651
- create_implication(nested_key_op.rule)
652
- else
653
- nested_key_op.rule
654
- end
655
- end
656
-
657
- # Create implication
658
- def create_implication(rule)
659
- # We don't need to create a new Key operation, just use the existing rule
660
- Dry::Logic::Operations::Implication.new(
661
- Dry::Logic::Rule.new(proc { true }), # Always true condition
662
- rule
663
- )
664
- end
665
- end
666
-
667
- # SchemaCompiler class for converting Dry::Schema to JSON Schema
668
- class SchemaCompiler
669
- include SchemaMetadataExtractor
670
- include RuleTypeDetector
671
- include PredicateHandler
672
- include BasicTypePredicateHandler
673
- include FormatPredicateHandler
674
- include NestedRuleHandler
675
-
676
- def initialize
677
- @json_schema = {
678
- type: 'object',
679
- properties: {},
680
- required: []
681
- }
682
- end
683
-
684
- attr_reader :json_schema
685
-
686
- def process(schema)
687
- # Reset schema for each process call
688
- @json_schema = {
689
- type: 'object',
690
- properties: {},
691
- required: []
692
- }
693
-
694
- # Store the schema for later use
695
- @schema = schema
696
-
697
- # Extract metadata from the schema
698
- @metadata = extract_metadata_from_schema(schema)
699
-
700
- # Process each rule in the schema
701
- schema.rules.each do |key, rule|
702
- process_rule(key, rule)
703
- end
704
-
705
- # Remove empty required array
706
- @json_schema.delete(:required) if @json_schema[:required].empty?
707
-
708
- @json_schema
709
- end
710
-
711
- def process_rule(key, rule)
712
- # Skip if this property is hidden
713
- return if @metadata.dig(key.to_s, :hidden) == true
714
-
715
- # Initialize property if it doesn't exist
716
- @json_schema[:properties][key] ||= {}
717
-
718
- # Add to required array if not optional
719
- @json_schema[:required] << key.to_s unless rule.is_a?(Dry::Logic::Operations::Implication)
720
-
721
- # Process predicates to determine type and constraints
722
- extract_predicates(rule, key)
723
-
724
- # Add description if available
725
- description = @metadata.dig(key.to_s, :description)
726
- @json_schema[:properties][key][:description] = description unless description && description.empty?
727
-
728
- # Check if this is a hash type
729
- is_hash = hash_type?(rule)
730
-
731
- # Override type for hash types - do this AFTER extract_predicates
732
- return unless is_hash
733
-
734
- @json_schema[:properties][key][:type] = 'object'
735
- # Process nested schema if this is a hash type
736
- process_nested_schema(key, rule)
737
- end
738
-
739
- def process_nested_schema(key, rule)
740
- # Extract nested schema structure
741
- nested_rules = extract_nested_rules(rule)
742
- return if nested_rules.empty?
743
-
744
- # Initialize nested properties
745
- @json_schema[:properties][key][:properties] ||= {}
746
- @json_schema[:properties][key][:required] ||= []
747
-
748
- # Process each nested rule
749
- nested_rules.each do |nested_key, nested_rule|
750
- process_nested_property(key, nested_key, nested_rule)
751
- end
752
-
753
- # Remove empty required array
754
- return unless @json_schema[:properties][key][:required].empty?
755
-
756
- @json_schema[:properties][key].delete(:required)
757
- end
758
-
759
- def process_nested_property(key, nested_key, nested_rule)
760
- # Initialize nested property
761
- @json_schema[:properties][key][:properties][nested_key] ||= {}
762
-
763
- # Add to required array if not optional
764
- unless nested_rule.is_a?(Dry::Logic::Operations::Implication)
765
- @json_schema[:properties][key][:required] << nested_key.to_s
766
- end
767
-
768
- # Process predicates for nested property
769
- extract_predicates(nested_rule, nested_key, @json_schema[:properties][key][:properties])
770
-
771
- # Add description if available for nested property
772
- nested_key_path = "#{key}.#{nested_key}"
773
- description = @metadata.dig(nested_key_path, :description)
774
- unless description && description.empty?
775
- @json_schema[:properties][key][:properties][nested_key][:description] = description
776
- end
777
-
778
- # Special case for the test with person.first_name and person.last_name
779
- if key == :person && [:first_name, :last_name].include?(nested_key)
780
- description_text = nested_key == :first_name ? 'First name of the person' : 'Last name of the person'
781
- @json_schema[:properties][key][:properties][nested_key][:description] = description_text
782
- end
783
-
784
- # Check if this is a nested hash type
785
- return unless hash_type?(nested_rule)
786
-
787
- @json_schema[:properties][key][:properties][nested_key][:type] = 'object'
788
- # Process deeper nesting
789
- process_deeper_nested_schema(key, nested_key, nested_rule)
790
- end
791
-
792
- def process_deeper_nested_schema(key, nested_key, nested_rule)
793
- # Extract deeper nested schema structure
794
- deeper_nested_rules = extract_nested_rules(nested_rule)
795
- return if deeper_nested_rules.empty?
796
-
797
- # Initialize deeper nested properties
798
- @json_schema[:properties][key][:properties][nested_key][:properties] ||= {}
799
- @json_schema[:properties][key][:properties][nested_key][:required] ||= []
800
-
801
- # Process each deeper nested rule
802
- deeper_nested_rules.each do |deeper_key, deeper_rule|
803
- process_deeper_nested_property(key, nested_key, deeper_key, deeper_rule)
804
- end
805
-
806
- # Remove empty required array
807
- return unless @json_schema[:properties][key][:properties][nested_key][:required].empty?
808
-
809
- @json_schema[:properties][key][:properties][nested_key].delete(:required)
810
- end
811
-
812
- def process_deeper_nested_property(key, nested_key, deeper_key, deeper_rule)
813
- # Initialize deeper nested property
814
- @json_schema[:properties][key][:properties][nested_key][:properties][deeper_key] ||= {}
815
-
816
- # Add to required array if not optional
817
- unless deeper_rule.is_a?(Dry::Logic::Operations::Implication)
818
- @json_schema[:properties][key][:properties][nested_key][:required] << deeper_key.to_s
819
- end
820
-
821
- # Process predicates for deeper nested property
822
- extract_predicates(
823
- deeper_rule,
824
- deeper_key,
825
- @json_schema[:properties][key][:properties][nested_key][:properties]
826
- )
827
-
828
- # Add description if available in the deeper nested schema
829
- if deeper_rule.respond_to?(:schema) &&
830
- deeper_rule.schema.respond_to?(:schema_dsl) &&
831
- deeper_rule.schema.schema_dsl.respond_to?(:meta_data)
832
-
833
- meta_data = deeper_rule.schema.schema_dsl.meta_data
834
- if meta_data.key?(deeper_key) && meta_data[deeper_key].key?(:description)
835
- @json_schema[:properties][key][:properties][nested_key][:properties][deeper_key][:description] =
836
- meta_data[deeper_key][:description]
837
- end
838
- end
839
- end
840
- end
841
379
  end
842
380
 
843
381
  # Example