mcp_authorization 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +552 -0
- data/app/controllers/mcp_authorization/mcp_controller.rb +37 -0
- data/lib/mcp_authorization/configuration.rb +81 -0
- data/lib/mcp_authorization/dsl.rb +64 -0
- data/lib/mcp_authorization/engine.rb +68 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +1015 -0
- data/lib/mcp_authorization/tool.rb +245 -0
- data/lib/mcp_authorization/tool_registry.rb +89 -0
- data/lib/mcp_authorization/version.rb +3 -0
- data/lib/mcp_authorization.rb +57 -0
- data/lib/tasks/mcp_authorization.rake +99 -0
- metadata +112 -0
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
module McpAuthorization
|
|
2
|
+
# Compiles RBS-style type annotations in Ruby source files into JSON Schema,
|
|
3
|
+
# with per-request filtering based on +@requires+ permission tags.
|
|
4
|
+
#
|
|
5
|
+
# This is the heart of the schema-shaping authorization approach. Rather
|
|
6
|
+
# than defining JSON Schema separately, handler authors annotate their
|
|
7
|
+
# Ruby source files with RBS-style comments:
|
|
8
|
+
#
|
|
9
|
+
# # @rbs type output = success | admin_detail @requires(:admin)
|
|
10
|
+
#
|
|
11
|
+
# #: (name: String, ?force: bool @requires(:admin)) -> Hash[Symbol, untyped]
|
|
12
|
+
# def call(name:, force: false)
|
|
13
|
+
#
|
|
14
|
+
# The compiler parses these annotations *once* and caches the result.
|
|
15
|
+
# On each request, only the +@requires+ filtering runs — checking which
|
|
16
|
+
# fields/variants the current user can see and building a tailored schema.
|
|
17
|
+
#
|
|
18
|
+
# == Two-phase design
|
|
19
|
+
#
|
|
20
|
+
# *Parse phase* (cached, runs once per handler class):
|
|
21
|
+
# - Locate the handler's source file via +Method#source_location+
|
|
22
|
+
# - Load shared types from +# @rbs import+ statements
|
|
23
|
+
# - Parse local +# @rbs type+ definitions into a type map
|
|
24
|
+
# - Parse the +#:+ annotation above +def call+ into parameter descriptors
|
|
25
|
+
#
|
|
26
|
+
# *Compile phase* (per-request):
|
|
27
|
+
# - Filter parameters/variants by +@requires+ tags against +current_user.can?+
|
|
28
|
+
# - Apply constraint tags (+@min+, +@format+, etc.) to JSON Schema keywords
|
|
29
|
+
# - Inject +$ref/$defs+ when named types appear more than once (saves space)
|
|
30
|
+
#
|
|
31
|
+
# == Supported annotation tags
|
|
32
|
+
#
|
|
33
|
+
# See +extract_tags+ for the full list. Key tags:
|
|
34
|
+
# - +@requires(:flag)+ — field is omitted from schema if user lacks this permission
|
|
35
|
+
# - +@min(n)+, +@max(n)+ — type-aware: becomes minLength/maxLength on strings,
|
|
36
|
+
# minimum/maximum on numbers, minItems/maxItems on arrays
|
|
37
|
+
# - +@format(name)+ — JSON Schema format (email, uri, date-time, etc.)
|
|
38
|
+
# - +@default(value)+ / +@default_for(:key)+ — static or user-specific defaults
|
|
39
|
+
# - +@desc(text)+, +@title(text)+ — JSON Schema annotation keywords
|
|
40
|
+
#
|
|
41
|
+
class RbsSchemaCompiler
|
|
42
|
+
class << self
|
|
43
|
+
# ---------------------------------------------------------------
|
|
44
|
+
# Public API
|
|
45
|
+
# ---------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
# Compile the input JSON Schema for a handler class, filtered for the
|
|
48
|
+
# current user's permissions.
|
|
49
|
+
#
|
|
50
|
+
# Supports two annotation styles:
|
|
51
|
+
# 1. +# @rbs type input = { ... }+ — an explicit record type
|
|
52
|
+
# 2. +#:+ annotation above +def call+ — inferred from method signature
|
|
53
|
+
#
|
|
54
|
+
#: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
|
|
55
|
+
def compile_input(handler_class, server_context:)
|
|
56
|
+
cached = cache_for(handler_class)
|
|
57
|
+
|
|
58
|
+
schema = if cached[:raw_input]&.dig(:kind) == :record
|
|
59
|
+
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
|
|
60
|
+
else
|
|
61
|
+
build_input_schema(
|
|
62
|
+
filter_call_signature(cached[:call_params], cached[:type_map], server_context)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
schema = with_ref_injection(schema, cached[:type_map])
|
|
67
|
+
McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compile the output JSON Schema for a handler class, filtered for
|
|
71
|
+
# the current user's permissions.
|
|
72
|
+
#
|
|
73
|
+
#: (untyped, server_context: untyped) -> Hash[Symbol, untyped]?
|
|
74
|
+
def compile_output(handler_class, server_context:)
|
|
75
|
+
cached = cache_for(handler_class)
|
|
76
|
+
|
|
77
|
+
if cached[:raw_output]&.dig(:kind) == :union
|
|
78
|
+
schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
|
|
79
|
+
schema = with_ref_injection(schema, cached[:type_map])
|
|
80
|
+
return McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Strip JSON Schema keywords unsupported by Anthropic's strict tool
|
|
85
|
+
# use mode, and add additionalProperties: false to all objects.
|
|
86
|
+
# Converts oneOf to anyOf (strict mode supports anyOf but not oneOf).
|
|
87
|
+
#
|
|
88
|
+
#: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
89
|
+
def strict_sanitize(schema)
|
|
90
|
+
return schema unless schema.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
# Keywords that cause 400 in strict mode
|
|
93
|
+
unsupported = %i[
|
|
94
|
+
minLength maxLength minimum maximum
|
|
95
|
+
exclusiveMinimum exclusiveMaximum multipleOf
|
|
96
|
+
maxItems uniqueItems
|
|
97
|
+
dependentRequired deprecated readOnly writeOnly
|
|
98
|
+
title examples contentMediaType contentEncoding
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
result = {}
|
|
102
|
+
schema.each do |key, value|
|
|
103
|
+
next if unsupported.include?(key)
|
|
104
|
+
|
|
105
|
+
result[key] = case key
|
|
106
|
+
when :properties
|
|
107
|
+
value.transform_values { |v| strict_sanitize(v) }
|
|
108
|
+
when :items
|
|
109
|
+
strict_sanitize(value)
|
|
110
|
+
when :oneOf
|
|
111
|
+
# strict mode supports anyOf but not oneOf
|
|
112
|
+
result.delete(key)
|
|
113
|
+
result[:anyOf] = value.map { |s| strict_sanitize(s) }
|
|
114
|
+
next
|
|
115
|
+
when :anyOf, :allOf
|
|
116
|
+
value.map { |s| strict_sanitize(s) }
|
|
117
|
+
when :minItems
|
|
118
|
+
# strict mode only supports 0 and 1
|
|
119
|
+
value <= 1 ? value : nil
|
|
120
|
+
when :"$defs"
|
|
121
|
+
value.transform_values { |v| strict_sanitize(v) }
|
|
122
|
+
else
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Strict mode requires additionalProperties: false on objects
|
|
128
|
+
if result[:type] == "object" && result[:properties] && !result.key?(:additionalProperties)
|
|
129
|
+
result[:additionalProperties] = false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result.compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Global cache for parsed shared +.rbs+ files. Keyed by file path;
|
|
136
|
+
# each entry stores the file's mtime so stale entries are recompiled
|
|
137
|
+
# when the file changes on disk.
|
|
138
|
+
#
|
|
139
|
+
#: () -> Hash[String, untyped]
|
|
140
|
+
def shared_type_cache
|
|
141
|
+
@shared_type_cache ||= {}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Clear all cached type maps and shared type caches. Called by the
|
|
145
|
+
# Engine's reloader on code change in development so that modified
|
|
146
|
+
# annotations are re-parsed on the next request.
|
|
147
|
+
#: () -> void
|
|
148
|
+
def reset_cache!
|
|
149
|
+
@cache = {}
|
|
150
|
+
@shared_type_cache = {}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------
|
|
156
|
+
# Tag extraction — unified parser for all @tag(...) annotations
|
|
157
|
+
# ---------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# Extract all +@tag(value)+ annotations from a type string.
|
|
160
|
+
#
|
|
161
|
+
# Annotations are parsed right-to-left from the end of the string,
|
|
162
|
+
# peeling off one +@tag(...)+ at a time until none remain. This
|
|
163
|
+
# returns the clean RBS type (without tags) and a hash of parsed
|
|
164
|
+
# tag values.
|
|
165
|
+
#
|
|
166
|
+
# @example
|
|
167
|
+
# extract_tags("String @min(1) @max(100)")
|
|
168
|
+
# #=> ["String", { min: 1, max: 100 }]
|
|
169
|
+
#
|
|
170
|
+
# extract_tags("bool @requires(:admin)")
|
|
171
|
+
# #=> ["bool", { requires: :admin }]
|
|
172
|
+
#
|
|
173
|
+
# @param type_str [String] An RBS type string, possibly with trailing tags.
|
|
174
|
+
# @return [Array(String, Hash)] +[clean_type, tags_hash]+
|
|
175
|
+
#
|
|
176
|
+
# Supported tags:
|
|
177
|
+
# @requires(:symbol) -> { requires: :symbol }
|
|
178
|
+
# @depends_on(:field) -> { depends_on: "field" }
|
|
179
|
+
# @min(n) -> { min: n }
|
|
180
|
+
# @max(n) -> { max: n }
|
|
181
|
+
# @exclusive_min(n) -> { exclusive_min: n }
|
|
182
|
+
# @exclusive_max(n) -> { exclusive_max: n }
|
|
183
|
+
# @multiple_of(n) -> { multiple_of: n }
|
|
184
|
+
# @pattern(regex) -> { pattern: "regex" }
|
|
185
|
+
# @format(name) -> { format: "name" }
|
|
186
|
+
# @default(value) -> { default: value }
|
|
187
|
+
# @default_for(:key) -> { default_for: :key } (resolved via current_user.default_for)
|
|
188
|
+
# @desc(text) -> { desc: "text" }
|
|
189
|
+
# @title(text) -> { title: "text" }
|
|
190
|
+
# @example(value) -> { examples: [value, ...] }
|
|
191
|
+
# @deprecated() -> { deprecated: true }
|
|
192
|
+
# @read_only() -> { read_only: true }
|
|
193
|
+
# @write_only() -> { write_only: true }
|
|
194
|
+
# @unique() -> { unique: true }
|
|
195
|
+
# @closed() / @strict() -> { closed: true }
|
|
196
|
+
# @media_type(type) -> { media_type: "type" }
|
|
197
|
+
# @encoding(enc) -> { encoding: "enc" }
|
|
198
|
+
#: (String) -> [String, Hash[Symbol, untyped]]
|
|
199
|
+
def extract_tags(type_str)
|
|
200
|
+
tags = {}
|
|
201
|
+
|
|
202
|
+
# Extract all @tag(...) annotations from right to left
|
|
203
|
+
while type_str =~ /\A(.+?)\s+@(\w+)\(([^)]*)\)\s*\z/
|
|
204
|
+
type_str, tag_name, tag_value = $1.to_s.strip, $2.to_s, $3.to_s
|
|
205
|
+
|
|
206
|
+
case tag_name
|
|
207
|
+
when "requires"
|
|
208
|
+
tags[:requires] = tag_value.delete_prefix(":").to_sym
|
|
209
|
+
when "depends_on"
|
|
210
|
+
tags[:depends_on] = tag_value.delete_prefix(":")
|
|
211
|
+
when "min"
|
|
212
|
+
tags[:min] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
|
|
213
|
+
when "max"
|
|
214
|
+
tags[:max] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
|
|
215
|
+
when "exclusive_min"
|
|
216
|
+
tags[:exclusive_min] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
|
|
217
|
+
when "exclusive_max"
|
|
218
|
+
tags[:exclusive_max] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
|
|
219
|
+
when "multiple_of"
|
|
220
|
+
tags[:multiple_of] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
|
|
221
|
+
when "pattern"
|
|
222
|
+
tags[:pattern] = tag_value
|
|
223
|
+
when "format"
|
|
224
|
+
tags[:format] = tag_value
|
|
225
|
+
when "default"
|
|
226
|
+
tags[:default] = parse_default_value(tag_value)
|
|
227
|
+
when "default_for"
|
|
228
|
+
tags[:default_for] = tag_value.delete_prefix(":").to_sym
|
|
229
|
+
when "desc"
|
|
230
|
+
tags[:desc] = tag_value
|
|
231
|
+
when "title"
|
|
232
|
+
tags[:title] = tag_value
|
|
233
|
+
when "example"
|
|
234
|
+
(tags[:examples] ||= []) << parse_default_value(tag_value)
|
|
235
|
+
when "deprecated"
|
|
236
|
+
tags[:deprecated] = true
|
|
237
|
+
when "read_only"
|
|
238
|
+
tags[:read_only] = true
|
|
239
|
+
when "write_only"
|
|
240
|
+
tags[:write_only] = true
|
|
241
|
+
when "unique"
|
|
242
|
+
tags[:unique] = true
|
|
243
|
+
when "closed", "strict"
|
|
244
|
+
tags[:closed] = true
|
|
245
|
+
when "media_type"
|
|
246
|
+
tags[:media_type] = tag_value
|
|
247
|
+
when "encoding"
|
|
248
|
+
tags[:encoding] = tag_value
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
[type_str, tags]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Coerce a default value string from an annotation into its Ruby type.
|
|
256
|
+
# Handles booleans, nil/null, integers, floats, and bare strings.
|
|
257
|
+
#
|
|
258
|
+
# @param value [String] Raw value from +@default(...)+ or +@example(...)+.
|
|
259
|
+
# @return [Object] Coerced Ruby value.
|
|
260
|
+
#: (String) -> untyped
|
|
261
|
+
def parse_default_value(value)
|
|
262
|
+
case value
|
|
263
|
+
when "true" then true
|
|
264
|
+
when "false" then false
|
|
265
|
+
when "nil", "null" then nil
|
|
266
|
+
when /\A-?\d+\z/ then value.to_i
|
|
267
|
+
when /\A-?\d+\.\d+\z/ then value.to_f
|
|
268
|
+
else value.delete('"').delete("'")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Map a parsed tag hash onto JSON Schema keywords in a schema hash.
|
|
273
|
+
#
|
|
274
|
+
# This is *type-aware*: +@min(5)+ becomes +minLength: 5+ on a string,
|
|
275
|
+
# +minimum: 5+ on an integer, and +minItems: 5+ on an array. This lets
|
|
276
|
+
# handler authors use a single annotation vocabulary regardless of the
|
|
277
|
+
# underlying JSON Schema type.
|
|
278
|
+
#
|
|
279
|
+
# @param schema [Hash] JSON Schema hash (must already have +:type+ set).
|
|
280
|
+
# @param tags [Hash] Parsed tags from +extract_tags+.
|
|
281
|
+
# @param server_context [Object, nil] Needed to resolve +@default_for+ tags.
|
|
282
|
+
# @return [Hash] The same schema hash, mutated with additional keywords.
|
|
283
|
+
#: (Hash[Symbol, untyped], Hash[Symbol, untyped], ?server_context: untyped?) -> Hash[Symbol, untyped]
|
|
284
|
+
def apply_tags(schema, tags, server_context: nil)
|
|
285
|
+
# Type-aware min/max
|
|
286
|
+
if tags[:min]
|
|
287
|
+
case schema[:type]
|
|
288
|
+
when "string" then schema[:minLength] = tags[:min]
|
|
289
|
+
when "integer", "number" then schema[:minimum] = tags[:min]
|
|
290
|
+
when "array" then schema[:minItems] = tags[:min]
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
if tags[:max]
|
|
294
|
+
case schema[:type]
|
|
295
|
+
when "string" then schema[:maxLength] = tags[:max]
|
|
296
|
+
when "integer", "number" then schema[:maximum] = tags[:max]
|
|
297
|
+
when "array" then schema[:maxItems] = tags[:max]
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Numeric constraints
|
|
302
|
+
schema[:exclusiveMinimum] = tags[:exclusive_min] if tags[:exclusive_min]
|
|
303
|
+
schema[:exclusiveMaximum] = tags[:exclusive_max] if tags[:exclusive_max]
|
|
304
|
+
schema[:multipleOf] = tags[:multiple_of] if tags[:multiple_of]
|
|
305
|
+
|
|
306
|
+
# String constraints
|
|
307
|
+
schema[:pattern] = tags[:pattern] if tags[:pattern]
|
|
308
|
+
schema[:format] = tags[:format] if tags[:format]
|
|
309
|
+
|
|
310
|
+
# Array constraints
|
|
311
|
+
schema[:uniqueItems] = true if tags[:unique]
|
|
312
|
+
|
|
313
|
+
# Annotation keywords
|
|
314
|
+
schema[:title] = tags[:title] if tags[:title]
|
|
315
|
+
schema[:description] = tags[:desc] if tags[:desc]
|
|
316
|
+
schema[:examples] = tags[:examples] if tags[:examples]
|
|
317
|
+
if tags[:default_for] && server_context
|
|
318
|
+
val = server_context.current_user.default_for(tags[:default_for])
|
|
319
|
+
schema[:default] = val unless val.nil?
|
|
320
|
+
elsif tags.key?(:default)
|
|
321
|
+
schema[:default] = tags[:default]
|
|
322
|
+
end
|
|
323
|
+
schema[:deprecated] = true if tags[:deprecated]
|
|
324
|
+
schema[:readOnly] = true if tags[:read_only]
|
|
325
|
+
schema[:writeOnly] = true if tags[:write_only]
|
|
326
|
+
|
|
327
|
+
# Niche constraints
|
|
328
|
+
schema[:additionalProperties] = false if tags[:closed]
|
|
329
|
+
schema[:contentMediaType] = tags[:media_type] if tags[:media_type]
|
|
330
|
+
schema[:contentEncoding] = tags[:encoding] if tags[:encoding]
|
|
331
|
+
|
|
332
|
+
schema
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------
|
|
336
|
+
# Cache — source files are parsed once; per-request work is only
|
|
337
|
+
# the @requires filtering and tag application.
|
|
338
|
+
# ---------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
# Return the cached parse result for a handler class, building it
|
|
341
|
+
# on first access.
|
|
342
|
+
#
|
|
343
|
+
# @param handler_class [Class]
|
|
344
|
+
# @return [Hash] with keys +:type_map+, +:raw_input+, +:raw_output+,
|
|
345
|
+
# +:call_params+, +:source_file+.
|
|
346
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
347
|
+
def cache_for(handler_class)
|
|
348
|
+
cache = (@cache ||= {})
|
|
349
|
+
cache[handler_class] ||= build_cache(handler_class)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Parse a handler class's source file and build the type map and
|
|
353
|
+
# parameter descriptors that the compile phase uses.
|
|
354
|
+
#
|
|
355
|
+
# The type map is built in two layers:
|
|
356
|
+
# 1. Shared types from +# @rbs import+ statements (e.g. common enums)
|
|
357
|
+
# 2. Local +# @rbs type+ definitions in the handler file (override shared)
|
|
358
|
+
#
|
|
359
|
+
# @param handler_class [Class]
|
|
360
|
+
# @return [Hash]
|
|
361
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
362
|
+
def build_cache(handler_class)
|
|
363
|
+
source_file = find_source_file(handler_class)
|
|
364
|
+
content = source_file && File.exist?(source_file) ? File.read(source_file) : ""
|
|
365
|
+
|
|
366
|
+
# Build type map: shared imports first, then handler's own types override
|
|
367
|
+
imported = load_imports(content)
|
|
368
|
+
local = parse_type_aliases(content)
|
|
369
|
+
type_map = imported.merge(local)
|
|
370
|
+
|
|
371
|
+
{
|
|
372
|
+
type_map: type_map,
|
|
373
|
+
raw_input: find_raw_type_body(content, "input"),
|
|
374
|
+
raw_output: find_raw_type_body(content, "output"),
|
|
375
|
+
call_params: parse_call_params(content),
|
|
376
|
+
source_file: source_file
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------
|
|
381
|
+
# @requires filtering — the per-request compile phase
|
|
382
|
+
# ---------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
# Compile a record-style input type (+# @rbs type input = { ... }+)
|
|
385
|
+
# with field-level +@requires+ filtering.
|
|
386
|
+
#
|
|
387
|
+
# Fields whose +@requires+ flag the current user lacks are silently
|
|
388
|
+
# omitted from the resulting JSON Schema, so the LLM never sees them.
|
|
389
|
+
#
|
|
390
|
+
# @param raw_body [String] The raw record body, e.g. +"{name: String, force: bool @requires(:admin)}"+.
|
|
391
|
+
# @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
|
|
392
|
+
# @param server_context [Object] Per-request context with +current_user+.
|
|
393
|
+
# @return [Hash] JSON Schema object with +properties+, +required+, etc.
|
|
394
|
+
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
395
|
+
def compile_tagged_record(raw_body, type_map, server_context)
|
|
396
|
+
properties = {}
|
|
397
|
+
required = []
|
|
398
|
+
dependent_required = {}
|
|
399
|
+
|
|
400
|
+
inner = raw_body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
401
|
+
|
|
402
|
+
inner.scan(/(\w+\??)\s*:\s*([^,}]+)/) do |match|
|
|
403
|
+
key, type_str = match[0].to_s, match[1].to_s
|
|
404
|
+
type_str, tags = extract_tags(type_str.strip)
|
|
405
|
+
|
|
406
|
+
next if tags[:requires] && !server_context.current_user.can?(tags[:requires])
|
|
407
|
+
|
|
408
|
+
optional = key.end_with?("?")
|
|
409
|
+
clean_key = key.delete_suffix("?")
|
|
410
|
+
|
|
411
|
+
schema = rbs_type_to_json_schema(type_str, type_map)
|
|
412
|
+
properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
|
|
413
|
+
required << clean_key unless optional
|
|
414
|
+
|
|
415
|
+
if tags[:depends_on] && properties.key?(tags[:depends_on].to_sym)
|
|
416
|
+
dependent_required[tags[:depends_on]] ||= []
|
|
417
|
+
dependent_required[tags[:depends_on]] << clean_key
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
|
|
422
|
+
schema[:required] = required if required.any?
|
|
423
|
+
schema[:dependentRequired] = dependent_required if dependent_required.any?
|
|
424
|
+
schema
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
|
|
428
|
+
# with variant-level +@requires+ filtering.
|
|
429
|
+
#
|
|
430
|
+
# Each union variant (separated by +|+) can carry its own +@requires+
|
|
431
|
+
# tag. Variants the user lacks permission for are dropped entirely.
|
|
432
|
+
# If only one variant remains, it's returned directly (no +oneOf+
|
|
433
|
+
# wrapper). If zero remain, a bare +{type: "object"}+ fallback is used.
|
|
434
|
+
#
|
|
435
|
+
# @param raw_expr [String] The raw union expression.
|
|
436
|
+
# @param type_map [Hash] Resolved type definitions.
|
|
437
|
+
# @param server_context [Object] Per-request context.
|
|
438
|
+
# @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
|
|
439
|
+
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
440
|
+
def compile_tagged_union(raw_expr, type_map, server_context)
|
|
441
|
+
parts = raw_expr.split("|").map(&:strip).reject(&:empty?)
|
|
442
|
+
|
|
443
|
+
filtered = parts.filter_map do |part|
|
|
444
|
+
part, tags = extract_tags(part)
|
|
445
|
+
next nil if tags[:requires] && !server_context.current_user.can?(tags[:requires])
|
|
446
|
+
resolve_type(part, type_map)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
case filtered.size
|
|
450
|
+
when 0 then { type: "object" }
|
|
451
|
+
when 1 then filtered.first
|
|
452
|
+
else { type: "object", oneOf: filtered }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Filter method-signature parameters by +@requires+ tags and build
|
|
457
|
+
# the input JSON Schema. This is the path used when the handler defines
|
|
458
|
+
# its schema via a +#:+ annotation above +def call+ rather than an
|
|
459
|
+
# explicit +# @rbs type input = { ... }+.
|
|
460
|
+
#
|
|
461
|
+
# @param call_params [Array<Hash>] Parsed parameter descriptors from +parse_call_params+.
|
|
462
|
+
# @param type_map [Hash] Resolved type definitions.
|
|
463
|
+
# @param server_context [Object] Per-request context.
|
|
464
|
+
# @return [Hash] Partial JSON Schema (+properties+, +required+, etc.).
|
|
465
|
+
#: (Array[Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
466
|
+
def filter_call_signature(call_params, type_map, server_context)
|
|
467
|
+
properties = {}
|
|
468
|
+
required = []
|
|
469
|
+
dependent_required = {}
|
|
470
|
+
|
|
471
|
+
call_params.each do |param|
|
|
472
|
+
if param[:tags][:requires] && server_context
|
|
473
|
+
next unless server_context.current_user.can?(param[:tags][:requires])
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
schema = rbs_type_to_json_schema(param[:type], type_map)
|
|
477
|
+
properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
|
|
478
|
+
required << param[:name] if param[:required]
|
|
479
|
+
|
|
480
|
+
if param[:tags][:depends_on] && properties.key?(param[:tags][:depends_on].to_sym)
|
|
481
|
+
dependent_required[param[:tags][:depends_on]] ||= []
|
|
482
|
+
dependent_required[param[:tags][:depends_on]] << param[:name]
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
schema = { properties: properties } #: Hash[Symbol, untyped]
|
|
487
|
+
schema[:required] = required if required.any?
|
|
488
|
+
schema[:dependentRequired] = dependent_required if dependent_required.any?
|
|
489
|
+
schema
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# ---------------------------------------------------------------
|
|
493
|
+
# Source parsing — cached, runs once per handler class
|
|
494
|
+
# ---------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
# Scan handler source for +# @rbs import+ lines and load the
|
|
497
|
+
# referenced shared +.rbs+ files into a merged type map.
|
|
498
|
+
#
|
|
499
|
+
# Import paths are resolved relative to the configured
|
|
500
|
+
# +shared_type_paths+ directories (default: +sig/shared/+).
|
|
501
|
+
#
|
|
502
|
+
# @example In a handler file:
|
|
503
|
+
# # @rbs import common_types
|
|
504
|
+
# # → loads sig/shared/common_types.rbs
|
|
505
|
+
#
|
|
506
|
+
# @param content [String] Full source file contents.
|
|
507
|
+
# @return [Hash{String => Hash}] Type name → JSON Schema map.
|
|
508
|
+
#: (String) -> Hash[String, Hash[Symbol, untyped]]
|
|
509
|
+
def load_imports(content)
|
|
510
|
+
return {} if content.empty?
|
|
511
|
+
|
|
512
|
+
imports = content.scan(/# @rbs import (\S+)/).flatten
|
|
513
|
+
return {} if imports.empty?
|
|
514
|
+
|
|
515
|
+
type_map = {}
|
|
516
|
+
imports.each do |import_path|
|
|
517
|
+
rbs_file = resolve_import_path(import_path)
|
|
518
|
+
next unless rbs_file && File.exist?(rbs_file)
|
|
519
|
+
type_map.merge!(cached_parse_rbs_file(rbs_file))
|
|
520
|
+
end
|
|
521
|
+
type_map
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Parse a shared +.rbs+ file with mtime-based caching. If the file
|
|
525
|
+
# hasn't changed since the last parse, the cached result is returned.
|
|
526
|
+
#
|
|
527
|
+
# @param path [String] Absolute path to the +.rbs+ file.
|
|
528
|
+
# @return [Hash{String => Hash}] Type name → JSON Schema map.
|
|
529
|
+
#: (String) -> Hash[String, Hash[Symbol, untyped]]
|
|
530
|
+
def cached_parse_rbs_file(path)
|
|
531
|
+
mtime = File.mtime(path)
|
|
532
|
+
cached = shared_type_cache[path]
|
|
533
|
+
|
|
534
|
+
if cached && cached[:mtime] == mtime
|
|
535
|
+
return cached[:result]
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
result = parse_rbs_file(path)
|
|
539
|
+
shared_type_cache[path] = { mtime: mtime, result: result }
|
|
540
|
+
result
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Resolve a bare import name (e.g. +"common_types"+) to an absolute
|
|
544
|
+
# +.rbs+ file path by searching +shared_type_paths+.
|
|
545
|
+
#
|
|
546
|
+
# @param import_path [String] Bare import name without extension.
|
|
547
|
+
# @return [String, nil] Absolute file path, or nil if not found.
|
|
548
|
+
#: (String) -> String?
|
|
549
|
+
def resolve_import_path(import_path)
|
|
550
|
+
return nil unless defined?(Rails)
|
|
551
|
+
|
|
552
|
+
McpAuthorization.config.shared_type_paths.each do |base|
|
|
553
|
+
candidate = Rails.root.join(base, "#{import_path}.rbs")
|
|
554
|
+
return candidate.to_s if File.exist?(candidate)
|
|
555
|
+
end
|
|
556
|
+
nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Parse type definitions from a standalone +.rbs+ file (shared types).
|
|
560
|
+
#
|
|
561
|
+
# Unlike +parse_type_aliases+, this parses bare RBS syntax (no +#+ comment
|
|
562
|
+
# markers) — the format used in +sig/shared/*.rbs+ files:
|
|
563
|
+
#
|
|
564
|
+
# type success = { status: String, data: String }
|
|
565
|
+
# type priority = "low"
|
|
566
|
+
# | "medium"
|
|
567
|
+
# | "high"
|
|
568
|
+
#
|
|
569
|
+
# Record types are parsed into JSON Schema objects; string literal
|
|
570
|
+
# unions become +{type: "string", enum: [...]}+.
|
|
571
|
+
#
|
|
572
|
+
# @param path [String] Absolute path to the +.rbs+ file.
|
|
573
|
+
# @return [Hash{String => Hash}] Type name → resolved JSON Schema.
|
|
574
|
+
#: (String) -> Hash[String, Hash[Symbol, untyped]]
|
|
575
|
+
def parse_rbs_file(path)
|
|
576
|
+
content = File.read(path)
|
|
577
|
+
aliases = {}
|
|
578
|
+
current_name = nil #: String?
|
|
579
|
+
current_body = +""
|
|
580
|
+
|
|
581
|
+
content.each_line do |line|
|
|
582
|
+
stripped = line.strip
|
|
583
|
+
|
|
584
|
+
if stripped =~ /\Atype (\w+) = \{/
|
|
585
|
+
current_name = $1.to_s
|
|
586
|
+
current_body = "{"
|
|
587
|
+
elsif stripped =~ /\Atype (\w+) = "([^"]+)"/
|
|
588
|
+
aliases[$1.to_s] = parse_rbs_string_union($2.to_s, line, content)
|
|
589
|
+
elsif current_name
|
|
590
|
+
current_body << stripped
|
|
591
|
+
if brace_balanced?(current_body)
|
|
592
|
+
aliases[current_name] = current_body
|
|
593
|
+
current_name = nil
|
|
594
|
+
current_body = +""
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
resolved = {}
|
|
600
|
+
aliases.each do |name, value|
|
|
601
|
+
resolved[name] = if value.is_a?(String)
|
|
602
|
+
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
|
|
603
|
+
else
|
|
604
|
+
value
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
resolved
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Parse a multi-line string literal union from an .rbs file:
|
|
611
|
+
# type priority = "low"
|
|
612
|
+
# | "medium"
|
|
613
|
+
# | "high"
|
|
614
|
+
#
|
|
615
|
+
# @return [Hash] +{type: "string", enum: ["low", "medium", "high"]}+
|
|
616
|
+
#: (String, String, String) -> Hash[Symbol, untyped]
|
|
617
|
+
def parse_rbs_string_union(first_value, line, content)
|
|
618
|
+
values = [first_value]
|
|
619
|
+
content.each_line.drop_while { |l| l != line }.drop(1).each do |next_line|
|
|
620
|
+
if next_line =~ /^\s*\|\s*"([^"]+)"/
|
|
621
|
+
values << $1.to_s
|
|
622
|
+
else
|
|
623
|
+
break
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
{ type: "string", enum: values }
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# Extract the raw body of a named +# @rbs type+ definition from
|
|
630
|
+
# handler source, preserving any +@tag(...)+ annotations for later
|
|
631
|
+
# filtering.
|
|
632
|
+
#
|
|
633
|
+
# Handles both record types (+{ ... }+) and union types (+a | b | c+),
|
|
634
|
+
# including multi-line continuation with +# |+.
|
|
635
|
+
#
|
|
636
|
+
# @param content [String] Full source file contents.
|
|
637
|
+
# @param type_name [String] Type name to find (e.g. +"input"+, +"output"+).
|
|
638
|
+
# @return [Hash, nil] +{kind: :record, body: "..."}+, +{kind: :union, body: "..."}+, or nil.
|
|
639
|
+
#: (String, String) -> Hash[Symbol, untyped]?
|
|
640
|
+
def find_raw_type_body(content, type_name)
|
|
641
|
+
return nil if content.empty?
|
|
642
|
+
|
|
643
|
+
lines = content.lines
|
|
644
|
+
pattern = Regexp.escape(type_name)
|
|
645
|
+
|
|
646
|
+
lines.each_with_index do |line, idx|
|
|
647
|
+
rest = lines[(idx + 1)..] || []
|
|
648
|
+
|
|
649
|
+
if line =~ /# @rbs type #{pattern} = \{/
|
|
650
|
+
body = "{"
|
|
651
|
+
rest.each do |next_line|
|
|
652
|
+
stripped = next_line.strip.sub(/^#\s*/, "")
|
|
653
|
+
body << stripped
|
|
654
|
+
return { kind: :record, body: body } if brace_balanced?(body)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
elsif line =~ /# @rbs type #{pattern} = ([^{].+)/
|
|
658
|
+
expr = $1.to_s.strip
|
|
659
|
+
rest.each do |next_line|
|
|
660
|
+
if next_line =~ /^\s*#\s*\|\s*(.+)/
|
|
661
|
+
expr += " | " + $1.to_s.strip
|
|
662
|
+
else
|
|
663
|
+
break
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
return { kind: :union, body: expr }
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
nil
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Parse +# @rbs type+ definitions from handler source into resolved
|
|
674
|
+
# JSON Schema. These are the handler's local type definitions (as
|
|
675
|
+
# opposed to shared types loaded via +# @rbs import+).
|
|
676
|
+
#
|
|
677
|
+
# Handles record types and string literal unions:
|
|
678
|
+
#
|
|
679
|
+
# # @rbs type success = { status: String, data: String }
|
|
680
|
+
# # @rbs type priority = "low"
|
|
681
|
+
# # | "medium"
|
|
682
|
+
# # | "high"
|
|
683
|
+
#
|
|
684
|
+
# @param content [String] Full source file contents.
|
|
685
|
+
# @return [Hash{String => Hash}] Type name → resolved JSON Schema.
|
|
686
|
+
#: (String) -> Hash[String, Hash[Symbol, untyped]]
|
|
687
|
+
def parse_type_aliases(content)
|
|
688
|
+
return {} if content.empty?
|
|
689
|
+
|
|
690
|
+
aliases = {}
|
|
691
|
+
current_name = nil #: String?
|
|
692
|
+
current_body = +""
|
|
693
|
+
|
|
694
|
+
content.each_line do |line|
|
|
695
|
+
if line =~ /# @rbs type (\w+) = \{/
|
|
696
|
+
current_name = $1.to_s
|
|
697
|
+
current_body = "{"
|
|
698
|
+
elsif line =~ /# @rbs type (\w+) = "([^"]+)"/
|
|
699
|
+
aliases[$1.to_s] = parse_string_union($2.to_s, line, content)
|
|
700
|
+
elsif current_name
|
|
701
|
+
stripped = line.strip.sub(/^#\s*/, "")
|
|
702
|
+
current_body << stripped
|
|
703
|
+
if brace_balanced?(current_body)
|
|
704
|
+
aliases[current_name] = current_body
|
|
705
|
+
current_name = nil
|
|
706
|
+
current_body = +""
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
resolved = {}
|
|
712
|
+
aliases.each do |name, value|
|
|
713
|
+
resolved[name] = if value.is_a?(String)
|
|
714
|
+
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
|
|
715
|
+
else
|
|
716
|
+
value
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
resolved
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Parse the +#:+ method signature annotation above +def call+ into
|
|
723
|
+
# an array of parameter descriptors.
|
|
724
|
+
#
|
|
725
|
+
# The annotation looks like:
|
|
726
|
+
#
|
|
727
|
+
# #: (name: String @min(1), ?limit: Integer @requires(:admin)) -> Hash[Symbol, untyped]
|
|
728
|
+
#
|
|
729
|
+
# Each parameter becomes a hash with +:name+, +:type+, +:required+,
|
|
730
|
+
# and +:tags+ (parsed via +extract_tags+). The +?+ prefix marks a
|
|
731
|
+
# parameter as optional.
|
|
732
|
+
#
|
|
733
|
+
# @param content [String] Full source file contents.
|
|
734
|
+
# @return [Array<Hash>] Parameter descriptors.
|
|
735
|
+
#: (String) -> Array[Hash[Symbol, untyped]]
|
|
736
|
+
def parse_call_params(content)
|
|
737
|
+
return [] if content.empty?
|
|
738
|
+
|
|
739
|
+
lines = content.lines
|
|
740
|
+
call_idx = lines.index { |l| l =~ /\s*def (self\.)?call\(/ }
|
|
741
|
+
return [] unless call_idx
|
|
742
|
+
|
|
743
|
+
annotation = +""
|
|
744
|
+
i = call_idx - 1
|
|
745
|
+
while i >= 0 && lines[i] =~ /^\s*#:/
|
|
746
|
+
annotation.prepend(lines[i].sub(/^\s*#:\s*/, "").strip + " ")
|
|
747
|
+
i -= 1
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
params = []
|
|
751
|
+
if annotation =~ /\((.+)\)\s*->/m
|
|
752
|
+
$1.to_s.split(",").each do |param|
|
|
753
|
+
param = param.strip
|
|
754
|
+
next unless param =~ /\A(\?)?([\w]+):\s*(.+)\z/
|
|
755
|
+
opt, name, type = $1, $2.to_s, $3.to_s.strip
|
|
756
|
+
next if name == "server_context"
|
|
757
|
+
|
|
758
|
+
type, tags = extract_tags(type)
|
|
759
|
+
|
|
760
|
+
params << {
|
|
761
|
+
name: name,
|
|
762
|
+
type: type,
|
|
763
|
+
required: opt.nil? && !type.end_with?("?"),
|
|
764
|
+
tags: tags
|
|
765
|
+
}
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
params
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# ---------------------------------------------------------------
|
|
773
|
+
# Type resolution helpers
|
|
774
|
+
# ---------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
# Convert unresolved aliases into placeholder schemas so that
|
|
777
|
+
# forward references work during record parsing.
|
|
778
|
+
#: (Hash[String, untyped], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
|
|
779
|
+
def aliases_to_schemas(aliases, already_resolved)
|
|
780
|
+
result = {}
|
|
781
|
+
aliases.each do |name, value|
|
|
782
|
+
next if already_resolved.key?(name)
|
|
783
|
+
result[name] = value.is_a?(Hash) ? value : { type: "string" }
|
|
784
|
+
end
|
|
785
|
+
result
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Parse a bare record type body (e.g. +"{name: String, age: Integer}"+)
|
|
789
|
+
# into a JSON Schema object. Used for both shared .rbs files and
|
|
790
|
+
# inline +# @rbs type+ definitions.
|
|
791
|
+
#
|
|
792
|
+
# @param body [String] Record body including surrounding braces.
|
|
793
|
+
# @param type_map [Hash] Resolved types for reference lookups.
|
|
794
|
+
# @return [Hash] JSON Schema object with +properties+ and +required+.
|
|
795
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
796
|
+
def parse_record_type(body, type_map = {})
|
|
797
|
+
properties = {}
|
|
798
|
+
required = []
|
|
799
|
+
|
|
800
|
+
inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
801
|
+
|
|
802
|
+
inner.scan(/(\w+):\s*([^,}]+)/) do |match|
|
|
803
|
+
key, type_str = match[0].to_s, match[1].to_s
|
|
804
|
+
type_str, tags = extract_tags(type_str.strip)
|
|
805
|
+
optional = key.end_with?("?")
|
|
806
|
+
clean_key = key.delete_suffix("?")
|
|
807
|
+
|
|
808
|
+
schema = rbs_type_to_json_schema(type_str, type_map)
|
|
809
|
+
properties[clean_key.to_sym] = apply_tags(schema, tags)
|
|
810
|
+
required << clean_key unless optional
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
|
|
814
|
+
schema[:required] = required if required.any?
|
|
815
|
+
schema
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Convert a single RBS type expression into its JSON Schema equivalent.
|
|
819
|
+
#
|
|
820
|
+
# Handles:
|
|
821
|
+
# - Primitives: +String+ → +{type: "string"}+, +Integer+ → +{type: "integer"}+, etc.
|
|
822
|
+
# - Arrays: +Array[String]+ → +{type: "array", items: {type: "string"}}+
|
|
823
|
+
# - Optionals: +String?+ → +{type: "string"}+ (nullability is handled at the field level)
|
|
824
|
+
# - Inline records: +{name: String}+ → nested object schema
|
|
825
|
+
# - Unions: +"a" | "b"+ → string enum; +A | B+ → +oneOf+
|
|
826
|
+
# - Named types: looked up in +type_map+, falling back to +{type: "string"}+
|
|
827
|
+
#
|
|
828
|
+
# @param rbs_type [String] RBS type expression.
|
|
829
|
+
# @param type_map [Hash] Resolved type definitions for named type lookups.
|
|
830
|
+
# @return [Hash] JSON Schema hash.
|
|
831
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
832
|
+
def rbs_type_to_json_schema(rbs_type, type_map = {})
|
|
833
|
+
stripped = rbs_type.strip
|
|
834
|
+
case stripped
|
|
835
|
+
when "String"
|
|
836
|
+
{ type: "string" }
|
|
837
|
+
when "Integer"
|
|
838
|
+
{ type: "integer" }
|
|
839
|
+
when "Float"
|
|
840
|
+
{ type: "number" }
|
|
841
|
+
when "bool", "TrueClass | FalseClass"
|
|
842
|
+
{ type: "boolean" }
|
|
843
|
+
when "true"
|
|
844
|
+
{ type: "boolean", const: true }
|
|
845
|
+
when "false"
|
|
846
|
+
{ type: "boolean", const: false }
|
|
847
|
+
when /\AArray\[(.+)\]\z/
|
|
848
|
+
{ type: "array", items: rbs_type_to_json_schema($1.to_s, type_map) }
|
|
849
|
+
when /\A(\w+)\?\z/
|
|
850
|
+
rbs_type_to_json_schema($1.to_s, type_map)
|
|
851
|
+
when /\A\{/
|
|
852
|
+
parse_record_type(stripped, type_map)
|
|
853
|
+
when /\|/
|
|
854
|
+
parts = stripped.split("|").map(&:strip)
|
|
855
|
+
if parts.all? { |p| p.start_with?('"') && p.end_with?('"') }
|
|
856
|
+
{ type: "string", enum: parts.map { |p| p.delete('"') } }
|
|
857
|
+
else
|
|
858
|
+
{ oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map) } }
|
|
859
|
+
end
|
|
860
|
+
else
|
|
861
|
+
type_map[stripped] || { type: "string" }
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# Look up a named type in the type map. Returns a bare +{type: "object"}+
|
|
866
|
+
# if the name is not found (defensive fallback).
|
|
867
|
+
#: (String, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
868
|
+
def resolve_type(name, type_map)
|
|
869
|
+
type_map[name] || { type: "object" }
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# Wrap a partial schema (with +properties+, +required+, etc.) in a
|
|
873
|
+
# top-level +{type: "object", ...}+ envelope.
|
|
874
|
+
#: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
875
|
+
def build_input_schema(types)
|
|
876
|
+
{ type: "object" }.merge(types)
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Locate the source file for a handler class by inspecting
|
|
880
|
+
# +Method#source_location+ on its +#call+ method. This is how the
|
|
881
|
+
# compiler finds the RBS annotations to parse.
|
|
882
|
+
#
|
|
883
|
+
# @param handler_class [Class]
|
|
884
|
+
# @return [String, nil] Absolute file path, or nil.
|
|
885
|
+
#: (untyped) -> String?
|
|
886
|
+
def find_source_file(handler_class)
|
|
887
|
+
if handler_class.method_defined?(:call)
|
|
888
|
+
handler_class.instance_method(:call).source_location&.first
|
|
889
|
+
elsif handler_class.respond_to?(:call)
|
|
890
|
+
handler_class.method(:call).source_location&.first
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# Check whether a string has balanced curly braces. Used to detect
|
|
895
|
+
# the end of multi-line record type definitions.
|
|
896
|
+
#: (String) -> bool
|
|
897
|
+
def brace_balanced?(str)
|
|
898
|
+
str.count("{") == str.count("}")
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# Parse a multi-line string literal union from handler source comments:
|
|
902
|
+
# # @rbs type priority = "low"
|
|
903
|
+
# # | "medium"
|
|
904
|
+
# # | "high"
|
|
905
|
+
#
|
|
906
|
+
# @return [Hash] +{type: "string", enum: ["low", "medium", "high"]}+
|
|
907
|
+
#: (String, String, String) -> Hash[Symbol, untyped]
|
|
908
|
+
def parse_string_union(first_value, line, content)
|
|
909
|
+
values = [first_value]
|
|
910
|
+
content.each_line.drop_while { |l| l != line }.drop(1).each do |next_line|
|
|
911
|
+
if next_line =~ /^\s*#\s*\|\s*"([^"]+)"/
|
|
912
|
+
values << $1.to_s
|
|
913
|
+
else
|
|
914
|
+
break
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
{ type: "string", enum: values }
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# ---------------------------------------------------------------
|
|
921
|
+
# $ref / $defs optimization
|
|
922
|
+
#
|
|
923
|
+
# When a named type (e.g. "address") appears in multiple places in
|
|
924
|
+
# the compiled schema, inlining it everywhere wastes tokens. This
|
|
925
|
+
# pass detects multi-use types, hoists them into a top-level $defs
|
|
926
|
+
# block, and replaces inline occurrences with $ref pointers:
|
|
927
|
+
#
|
|
928
|
+
# { "$ref": "#/$defs/address" }
|
|
929
|
+
#
|
|
930
|
+
# Single-use types are left inlined — the $ref overhead isn't worth
|
|
931
|
+
# it for types that only appear once.
|
|
932
|
+
# ---------------------------------------------------------------
|
|
933
|
+
|
|
934
|
+
# Wrap a compiled schema with +$defs+ for named types that appear
|
|
935
|
+
# more than once. Returns the schema unchanged if no deduplication
|
|
936
|
+
# is worthwhile.
|
|
937
|
+
#
|
|
938
|
+
# @param schema [Hash] Compiled JSON Schema.
|
|
939
|
+
# @param type_map [Hash] Named type definitions.
|
|
940
|
+
# @return [Hash] Schema, possibly with +$defs+ added.
|
|
941
|
+
#: (Hash[Symbol, untyped], Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
942
|
+
def with_ref_injection(schema, type_map)
|
|
943
|
+
return schema unless schema.is_a?(Hash)
|
|
944
|
+
|
|
945
|
+
# Build lookup of named types that are non-trivial schemas (worth deduplicating)
|
|
946
|
+
type_schemas = {}
|
|
947
|
+
type_map.each do |name, type_schema|
|
|
948
|
+
next unless type_schema.is_a?(Hash) && type_schema.size > 1
|
|
949
|
+
type_schemas[name] = type_schema
|
|
950
|
+
end
|
|
951
|
+
return schema if type_schemas.empty?
|
|
952
|
+
|
|
953
|
+
usage = Hash.new(0)
|
|
954
|
+
count_usages(schema, type_schemas, usage)
|
|
955
|
+
|
|
956
|
+
multi = usage.select { |_, c| c > 1 }
|
|
957
|
+
return schema if multi.empty?
|
|
958
|
+
|
|
959
|
+
defs = {}
|
|
960
|
+
multi.each_key { |name| defs[name] = type_schemas[name] }
|
|
961
|
+
|
|
962
|
+
replaced = deep_replace(schema, multi, type_schemas)
|
|
963
|
+
replaced[:"$defs"] = defs
|
|
964
|
+
replaced
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# Walk the schema tree and count how many times each named type's
|
|
968
|
+
# schema appears as a value. Only types with count > 1 are worth
|
|
969
|
+
# extracting into +$defs+.
|
|
970
|
+
#: (Hash[Symbol, untyped], Hash[String, Hash[Symbol, untyped]], Hash[String, Integer]) -> void
|
|
971
|
+
def count_usages(schema, type_schemas, usage)
|
|
972
|
+
return unless schema.is_a?(Hash)
|
|
973
|
+
|
|
974
|
+
type_schemas.each do |name, ts|
|
|
975
|
+
usage[name] += 1 if schema == ts
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
schema[:properties]&.each_value { |v| count_usages(v, type_schemas, usage) }
|
|
979
|
+
count_usages(schema[:items], type_schemas, usage) if schema[:items].is_a?(Hash)
|
|
980
|
+
[:oneOf, :anyOf, :allOf].each do |k|
|
|
981
|
+
schema[k]&.each { |s| count_usages(s, type_schemas, usage) }
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# Recursively replace occurrences of multi-use named type schemas
|
|
986
|
+
# with +{"$ref": "#/$defs/<name>"}+ pointers. Walks +properties+,
|
|
987
|
+
# +items+, +oneOf+, +anyOf+, and +allOf+.
|
|
988
|
+
#: (Hash[Symbol, untyped], Hash[String, Integer], Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
989
|
+
def deep_replace(schema, targets, type_schemas)
|
|
990
|
+
return schema unless schema.is_a?(Hash)
|
|
991
|
+
|
|
992
|
+
type_schemas.each do |name, ts|
|
|
993
|
+
if targets.key?(name) && schema == ts
|
|
994
|
+
return { "$ref": "#/$defs/#{name}" }
|
|
995
|
+
end
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
result = {}
|
|
999
|
+
schema.each do |key, value|
|
|
1000
|
+
result[key] = case key
|
|
1001
|
+
when :properties
|
|
1002
|
+
value.transform_values { |v| deep_replace(v, targets, type_schemas) }
|
|
1003
|
+
when :items
|
|
1004
|
+
deep_replace(value, targets, type_schemas)
|
|
1005
|
+
when :oneOf, :anyOf, :allOf
|
|
1006
|
+
value.map { |s| deep_replace(s, targets, type_schemas) }
|
|
1007
|
+
else
|
|
1008
|
+
value
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
result
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
end
|