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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -5
- data/README.md +29 -20
- data/lib/fast_mcp.rb +3 -14
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +2 -2
- data/lib/generators/fast_mcp/install/templates/sample_tool.rb +7 -0
- data/lib/mcp/railtie.rb +4 -0
- data/lib/mcp/server.rb +26 -18
- data/lib/mcp/tool.rb +189 -651
- data/lib/mcp/transports/rack_transport.rb +2 -2
- data/lib/mcp/version.rb +1 -1
- metadata +12 -6
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
|
-
@
|
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
|
-
|
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
|
-
|
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
|