ultimate_json_rpc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # MCP server exposing Ruby methods as AI tools
5
+ #
6
+ # Run: ruby examples/mcp_server.rb
7
+ # Use: Configure as an MCP server in Claude Desktop, Cursor, etc.
8
+ #
9
+ # claude_desktop_config.json:
10
+ # {
11
+ # "mcpServers": {
12
+ # "calculator": {
13
+ # "command": "ruby",
14
+ # "args": ["examples/mcp_server.rb"]
15
+ # }
16
+ # }
17
+ # }
18
+
19
+ require "ultimate_json_rpc"
20
+ require "ultimate_json_rpc/extras/mcp"
21
+
22
+ module MathTools
23
+ def self.add(a, b) = a + b
24
+ def self.subtract(a, b) = a - b
25
+ def self.multiply(a, b) = a * b
26
+ def self.divide(a, b)
27
+ raise ArgumentError, "Division by zero" if b.zero?
28
+
29
+ a.to_f / b
30
+ end
31
+ end
32
+
33
+ server = UltimateJsonRpc::Server.new(name: "Math Tools", version: "1.0")
34
+ server.expose(MathTools,
35
+ descriptions: { add: "Add two numbers", subtract: "Subtract b from a",
36
+ multiply: "Multiply two numbers", divide: "Divide a by b" },
37
+ params_schema: { add: { a: { "type" => "number" }, b: { "type" => "number" } },
38
+ subtract: { a: { "type" => "number" }, b: { "type" => "number" } },
39
+ multiply: { a: { "type" => "number" }, b: { "type" => "number" } },
40
+ divide: { a: { "type" => "number" }, b: { "type" => "number" } } })
41
+
42
+ UltimateJsonRpc::Extras::MCP.new(server).run
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Multi-namespace composition and API versioning
5
+ #
6
+ # Run: ruby examples/multi_namespace.rb
7
+
8
+ require "ultimate_json_rpc"
9
+ require "json"
10
+
11
+ module Auth
12
+ def self.login(username:, password:) = { token: "tok_#{username}", expires: 3600 }
13
+ def self.logout(token:) = { revoked: true }
14
+ end
15
+
16
+ module Users
17
+ def self.list = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
18
+ def self.get(id:) = { id:, name: id == 1 ? "Alice" : "Bob" }
19
+ end
20
+
21
+ # Compose multiple objects into a single endpoint
22
+ server = UltimateJsonRpc::Server.new(name: "My API", version: "1.0")
23
+ server.expose(Auth, namespace: "auth", descriptions: { login: "Authenticate user", logout: "Revoke token" })
24
+ server.expose(Users, namespace: "users", descriptions: { list: "List all users", get: "Get user by ID" })
25
+
26
+ # API versioning via namespaces
27
+ module CalcV1
28
+ def self.add(a, b) = a + b
29
+ end
30
+
31
+ module CalcV2
32
+ def self.add(a, b) = { result: a + b, version: 2 }
33
+ end
34
+
35
+ server.expose(CalcV1, namespace: "v1.calc")
36
+ server.expose(CalcV2, namespace: "v2.calc")
37
+
38
+ # Test it
39
+ puts "Methods: #{server.methods_list.join(", ")}"
40
+ puts
41
+
42
+ [
43
+ { "jsonrpc" => "2.0", "method" => "auth.login", "params" => { "username" => "alice", "password" => "secret" }, "id" => 1 },
44
+ { "jsonrpc" => "2.0", "method" => "users.list", "id" => 2 },
45
+ { "jsonrpc" => "2.0", "method" => "v1.calc.add", "params" => [2, 3], "id" => 3 },
46
+ { "jsonrpc" => "2.0", "method" => "v2.calc.add", "params" => [2, 3], "id" => 4 }
47
+ ].each do |req|
48
+ puts "#{req["method"]} => #{server.handle(JSON.generate(req))}"
49
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Basic Rack/Puma JSON-RPC server
4
+ #
5
+ # Run: bundle exec rackup examples/rack_server.ru
6
+ # Test: curl -X POST http://localhost:9292 \
7
+ # -H "Content-Type: application/json" \
8
+ # -d '{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}'
9
+
10
+ require "ultimate_json_rpc"
11
+ require "ultimate_json_rpc/transport/rack"
12
+
13
+ module Calculator
14
+ def self.add(a, b) = a + b
15
+ def self.multiply(a, b) = a * b
16
+ end
17
+
18
+ server = UltimateJsonRpc::Server.new(name: "Calculator API", version: "1.0")
19
+ server.expose(Calculator, descriptions: {
20
+ add: "Add two numbers",
21
+ multiply: "Multiply two numbers"
22
+ })
23
+
24
+ run UltimateJsonRpc::Transport::Rack.new(server)
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Testing patterns with UltimateJsonRpc
5
+ #
6
+ # Run: ruby examples/testing_patterns.rb
7
+
8
+ require "ultimate_json_rpc"
9
+ require "ultimate_json_rpc/extras/test_helpers"
10
+ require "ultimate_json_rpc/extras/recorder"
11
+ require "ultimate_json_rpc/extras/profiler"
12
+ require "json"
13
+
14
+ # === 1. Test Helpers ===
15
+ puts "=== Test Helpers ==="
16
+
17
+ module Calculator
18
+ def self.add(a, b) = a + b
19
+ end
20
+
21
+ server = UltimateJsonRpc::Server.new
22
+ server.expose(Calculator)
23
+
24
+ include UltimateJsonRpc::Extras::TestHelpers # rubocop:disable Style/MixinUsage
25
+
26
+ response = rpc_call(server, "add", params: [2, 3])
27
+ puts "rpc_call result: #{response["result"]}"
28
+
29
+ error_response = rpc_call(server, "nonexistent")
30
+ puts "rpc_call error: #{error_response["error"]["code"]}"
31
+
32
+ # === 2. Recorder ===
33
+ puts "\n=== Recorder ==="
34
+
35
+ recorder = UltimateJsonRpc::Extras::Recorder.new(server)
36
+ server.handle('{"jsonrpc":"2.0","method":"add","params":[10,20],"id":1}')
37
+ server.handle('{"jsonrpc":"2.0","method":"add","params":[5,5],"id":2}')
38
+
39
+ puts "Recorded #{recorder.size} exchanges:"
40
+ recorder.exchanges.each { |e| puts " #{e["method"]}(#{e["params"]}) => #{e["result"]} (#{e["duration"]}s)" }
41
+
42
+ # === 3. Profiler ===
43
+ puts "\n=== Profiler ==="
44
+
45
+ profiler = UltimateJsonRpc::Extras::Profiler.new(server)
46
+ 50.times { |i| server.handle(JSON.generate({ "jsonrpc" => "2.0", "method" => "add", "params" => [i, 1], "id" => i })) }
47
+
48
+ stats = profiler["add"]
49
+ puts "add: #{stats[:count]} calls, avg=#{stats[:avg]}s, p99=#{stats[:p99]}s"
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WebSocket JSON-RPC server using faye-websocket
4
+ #
5
+ # Setup: gem install faye-websocket puma
6
+ # Run: bundle exec rackup examples/websocket_server.ru
7
+ #
8
+ # Test with wscat (npm install -g wscat):
9
+ # wscat -c ws://localhost:9292
10
+ # > {"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}
11
+ # < {"jsonrpc":"2.0","result":5,"id":1}
12
+ #
13
+ # Or test with JavaScript:
14
+ # const ws = new WebSocket("ws://localhost:9292");
15
+ # ws.onmessage = (e) => console.log(e.data);
16
+ # ws.onopen = () => ws.send('{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}');
17
+
18
+ require "ultimate_json_rpc"
19
+ require "ultimate_json_rpc/transport/websocket"
20
+ require "faye/websocket"
21
+
22
+ module Calculator
23
+ def self.add(a, b) = a + b
24
+ def self.multiply(a, b) = a * b
25
+ end
26
+
27
+ server = UltimateJsonRpc::Server.new(name: "Calculator WS", version: "1.0")
28
+ server.expose(Calculator)
29
+ ws_handler = UltimateJsonRpc::Transport::WebSocket.new(server)
30
+
31
+ app = lambda do |env|
32
+ if Faye::WebSocket.websocket?(env)
33
+ ws = Faye::WebSocket.new(env)
34
+ ws_handler.call(env, ws)
35
+ ws.on(:close) { ws = nil }
36
+ ws.rack_response
37
+ else
38
+ [200, { "content-type" => "text/plain" }, ["WebSocket JSON-RPC server. Connect via ws://localhost:9292"]]
39
+ end
40
+ end
41
+
42
+ run app
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Core
5
+ PARSE_ERROR = -32_700
6
+ INVALID_REQUEST = -32_600
7
+ METHOD_NOT_FOUND = -32_601
8
+ INVALID_PARAMS = -32_602
9
+ INTERNAL_ERROR = -32_603
10
+
11
+ # Implementation-defined server error range (-32000 to -32099)
12
+ SERVER_ERROR_MIN = -32_099
13
+ SERVER_ERROR_MAX = -32_000
14
+
15
+ ERROR_MESSAGES = {
16
+ PARSE_ERROR => "Parse error",
17
+ INVALID_REQUEST => "Invalid Request",
18
+ METHOD_NOT_FOUND => "Method not found",
19
+ INVALID_PARAMS => "Invalid params",
20
+ INTERNAL_ERROR => "Internal error"
21
+ }.freeze
22
+
23
+ # Reserved range for JSON-RPC protocol errors (-32768 to -32000)
24
+ RESERVED_ERROR_MIN = -32_768
25
+ RESERVED_ERROR_MAX = -32_000
26
+
27
+ class InvalidRequest < Error; end
28
+
29
+ # Protocol-level parameter validation error (JSON-RPC -32602).
30
+ # Raised by the framework when params fail schema checks (type mismatch, enum violation).
31
+ # NOT intended for application-level business validation — use ApplicationError for that.
32
+ # Message is gated by expose_errors, same as InvalidRequest and ArgumentError.
33
+ class InvalidParams < Error; end
34
+
35
+ class MethodNotFound < Error
36
+ attr_reader :method_name
37
+
38
+ def initialize(method_name)
39
+ @method_name = method_name
40
+ super("Method not found: #{method_name}")
41
+ end
42
+ end
43
+
44
+ class ApplicationError < Error
45
+ attr_reader :code, :rpc_data
46
+
47
+ def initialize(code:, message:, data: nil)
48
+ raise ArgumentError, "error code must be an Integer, got #{code.class}" unless code.is_a?(Integer)
49
+
50
+ if code.between?(RESERVED_ERROR_MIN, RESERVED_ERROR_MAX)
51
+ hint = code.between?(SERVER_ERROR_MIN, SERVER_ERROR_MAX) ? "; use ServerError for server error codes" : ""
52
+ raise ArgumentError,
53
+ "error code #{code} is in the reserved range (#{RESERVED_ERROR_MIN}..#{RESERVED_ERROR_MAX})#{hint}"
54
+ end
55
+
56
+ @code = code
57
+ @rpc_data = data
58
+ super(message)
59
+ end
60
+ end
61
+
62
+ class ServerError < Error
63
+ attr_reader :code, :rpc_data
64
+
65
+ def initialize(code:, message:, data: nil)
66
+ unless code.is_a?(Integer) && code.between?(SERVER_ERROR_MIN, SERVER_ERROR_MAX)
67
+ raise ArgumentError,
68
+ "server error code must be in range (#{SERVER_ERROR_MIN}..#{SERVER_ERROR_MAX}), got #{code}"
69
+ end
70
+
71
+ @code = code
72
+ @rpc_data = data
73
+ super(message)
74
+ end
75
+ end
76
+
77
+ REQUEST_TIMEOUT = -32_001
78
+
79
+ class RequestTimeout < ServerError
80
+ def initialize(_message = nil)
81
+ super(code: REQUEST_TIMEOUT, message: "Request timeout")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Core
5
+ # @api private
6
+ VARIADIC_DEFAULTS = { rest: "args", keyrest: "kwargs" }.freeze
7
+ private_constant :VARIADIC_DEFAULTS
8
+
9
+ DANGEROUS_METHODS = %w[
10
+ eval instance_eval class_eval module_eval
11
+ send public_send __send__
12
+ system exec spawn fork
13
+ define_method remove_method undef_method
14
+ binding method_missing respond_to_missing?
15
+ exit exit! abort
16
+ require require_relative load
17
+ open
18
+ instance_variable_get instance_variable_set
19
+ class_variable_get class_variable_set
20
+ const_get const_set remove_const
21
+ method
22
+ include extend prepend
23
+ attr_accessor attr_reader attr_writer
24
+ public private protected
25
+ ].freeze
26
+ private_constant :DANGEROUS_METHODS
27
+
28
+ TYPE_CHECKS = {
29
+ "string" => String, "number" => Numeric, "integer" => Integer,
30
+ "boolean" => [TrueClass, FalseClass], "array" => Array,
31
+ "object" => Hash, "null" => NilClass
32
+ }.freeze
33
+ private_constant :TYPE_CHECKS
34
+
35
+ JSON_TYPE_NAMES = {
36
+ String => "string", Integer => "integer", Float => "number",
37
+ TrueClass => "boolean", FalseClass => "boolean",
38
+ Array => "array", Hash => "object", NilClass => "null"
39
+ }.freeze
40
+ private_constant :JSON_TYPE_NAMES
41
+
42
+ PARAM_FLAGS = {
43
+ req: { "required" => true }, keyreq: { "required" => true, "keyword" => true },
44
+ opt: {}, key: { "keyword" => true },
45
+ rest: { "variadic" => true }, keyrest: { "variadic" => true, "keyword" => true }
46
+ }.freeze
47
+ private_constant :PARAM_FLAGS
48
+
49
+ module ParamValidator
50
+ private
51
+
52
+ def validate_params!(callable, params, schema)
53
+ return unless schema
54
+
55
+ case params
56
+ when Array then validate_positional!(callable, params, schema)
57
+ when Hash then validate_keyword!(params, schema)
58
+ end
59
+ end
60
+
61
+ def validate_positional!(callable, params, schema)
62
+ names = callable.parameters.filter_map { |type, pname| pname&.to_s unless type == :block }
63
+ params.each_with_index do |value, index|
64
+ validate_value!(names[index], value, schema[names[index]]) if names[index] && schema[names[index]]
65
+ end
66
+ end
67
+
68
+ def validate_keyword!(params, schema)
69
+ params.each { |k, v| validate_value!(k.to_s, v, schema[k.to_s]) if schema.key?(k.to_s) }
70
+ end
71
+
72
+ def validate_value!(name, value, pschema)
73
+ check_param_type!(name, value, pschema)
74
+ check_param_enum!(name, value, pschema)
75
+ end
76
+
77
+ def check_param_type!(name, value, pschema)
78
+ type = pschema["type"]
79
+ return unless type
80
+
81
+ expected = TYPE_CHECKS[type]
82
+ return unless expected
83
+ return unless Array(expected).none? { |k| value.is_a?(k) }
84
+
85
+ raise InvalidParams, "parameter '#{name}' must be #{type}, " \
86
+ "got #{JSON_TYPE_NAMES.fetch(value.class, value.class.name)}"
87
+ end
88
+
89
+ def check_param_enum!(name, value, pschema)
90
+ return unless (enum = pschema["enum"]) && !enum.include?(value)
91
+
92
+ raise InvalidParams, "parameter '#{name}' must be one of: #{enum.map(&:inspect).join(", ")}"
93
+ end
94
+ end
95
+ private_constant :ParamValidator
96
+
97
+ class Handler
98
+ include ParamValidator
99
+
100
+ Entry = Struct.new(:callable, :description, :returns, :deprecated, :params_schema)
101
+ private_constant :Entry
102
+
103
+ def initialize
104
+ @entries = {}
105
+ end
106
+
107
+ def expose(target, namespace: nil, only: nil, except: nil, descriptions: nil, returns: nil, deprecated: nil,
108
+ params_schema: nil)
109
+ validate_expose_args!(target, only, except)
110
+ prefix = namespace.to_s.then { |ns| ns.empty? ? "" : "#{ns}." }
111
+ methods = filter_methods(callable_methods(target), only: only, except: except)
112
+ Kernel.warn "UltimateJsonRpc: expose registered 0 methods from #{target.inspect}" if methods.empty?
113
+ methods.each do |m|
114
+ register_exposed(prefix:, method_name: m, target:, descriptions:, returns:, deprecated:, params_schema:)
115
+ end
116
+ end
117
+
118
+ def expose_method(name, callable = nil, description: nil, returns: nil, deprecated: nil, params_schema: nil,
119
+ &block)
120
+ callable = resolve_callable(callable, block)
121
+ name = name.to_s
122
+ validate_method_name!(name)
123
+ @entries[name] = Entry.new(
124
+ callable: callable,
125
+ description: description&.to_s,
126
+ returns: returns,
127
+ deprecated: normalize_deprecated(deprecated),
128
+ params_schema: params_schema&.transform_keys(&:to_s)
129
+ )
130
+ end
131
+
132
+ def call(method_name, params)
133
+ entry = @entries[method_name]
134
+ raise MethodNotFound, method_name unless entry
135
+
136
+ validate_params!(entry.callable, params, entry.params_schema)
137
+ invoke_callable(entry.callable, params)
138
+ end
139
+
140
+ def method?(method_name) = @entries.key?(method_name)
141
+ def methods_list = @entries.keys.sort
142
+ def methods_info = @entries.keys.sort.map { |name| method_info(name) }
143
+ def size = @entries.size
144
+ def empty? = @entries.empty?
145
+
146
+ def freeze
147
+ @entries.each_value(&:freeze)
148
+ @entries.freeze
149
+ super
150
+ end
151
+
152
+ private
153
+
154
+ def method_info(name)
155
+ entry = @entries[name]
156
+ info = { "name" => name }
157
+ add_method_metadata(info:, entry:)
158
+ info
159
+ end
160
+
161
+ def add_method_metadata(info:, entry:)
162
+ info["description"] = entry.description if entry.description
163
+ add_params(info:, entry:)
164
+ info["result"] = build_result(entry.returns) if entry.returns
165
+ info["deprecated"] = entry.deprecated if entry.deprecated
166
+ end
167
+
168
+ def add_params(info:, entry:)
169
+ schema = entry.params_schema
170
+ params = entry.callable.parameters.filter_map { |type, pname| param_descriptor(type:, pname:, schema:) }
171
+ info["params"] = params unless params.empty?
172
+ end
173
+
174
+ def build_result(returns)
175
+ case returns
176
+ when Hash then { "name" => "result" }.merge(returns)
177
+ when String then { "name" => "result", "schema" => { "type" => returns } }
178
+ else { "name" => "result", "schema" => returns }
179
+ end
180
+ end
181
+
182
+ def param_descriptor(type:, pname:, schema:)
183
+ return if type == :block
184
+
185
+ name = pname&.to_s || VARIADIC_DEFAULTS.fetch(type, "arg")
186
+ desc = { "name" => name }.merge(PARAM_FLAGS.fetch(type, {}))
187
+ desc["schema"] = schema[name] if schema&.key?(name)
188
+ desc
189
+ end
190
+
191
+ def register_exposed(prefix:, method_name:, target:, descriptions:, returns:, deprecated:, params_schema:)
192
+ full_name = "#{prefix}#{method_name}"
193
+ validate_method_name!(full_name)
194
+ @entries[full_name] = Entry.new(
195
+ callable: target.method(method_name.to_sym),
196
+ description: extract_metadata(descriptions, method_name)&.to_s,
197
+ returns: extract_metadata(returns, method_name),
198
+ deprecated: normalize_deprecated(extract_metadata(deprecated, method_name)),
199
+ params_schema: extract_metadata(params_schema, method_name)&.then { |v| v.transform_keys(&:to_s) }
200
+ )
201
+ end
202
+
203
+ def normalize_deprecated(value)
204
+ return nil unless value
205
+
206
+ value == true ? true : value.to_s
207
+ end
208
+
209
+ def extract_metadata(source, method_name)
210
+ return nil unless source.is_a?(Hash)
211
+
212
+ sym_key = method_name.to_sym
213
+ return source[sym_key] if source.key?(sym_key)
214
+
215
+ source[method_name.to_s]
216
+ end
217
+
218
+ def callable_methods(target)
219
+ if target.is_a?(Module)
220
+ target.singleton_methods(false).map(&:to_s)
221
+ else
222
+ ((target.class.public_instance_methods(false) - Object.public_instance_methods) |
223
+ target.singleton_methods(false)).map(&:to_s)
224
+ end
225
+ end
226
+
227
+ def filter_methods(methods, only:, except:)
228
+ if only
229
+ allowed = Array(only).map(&:to_s)
230
+ methods.select { |m| allowed.include?(m) }
231
+ elsif except
232
+ blocked = Array(except).map(&:to_s)
233
+ methods.reject { |m| blocked.include?(m) }
234
+ else
235
+ methods
236
+ end
237
+ end
238
+
239
+ def validate_expose_args!(target, only, except)
240
+ raise ArgumentError, "target must not be nil" if target.nil?
241
+ raise ArgumentError, "cannot use both :only and :except" if only && except
242
+ end
243
+
244
+ def validate_method_name!(name)
245
+ raise ArgumentError, "method name must not be empty" if name.empty?
246
+ raise ArgumentError, "method names starting with 'rpc.' are reserved" if name.start_with?("rpc.")
247
+
248
+ validate_segments!(name)
249
+ raise ArgumentError, "method '#{name}' is already registered" if @entries.key?(name)
250
+ end
251
+
252
+ def validate_segments!(name)
253
+ segments = name.include?(".") ? name.split(".", -1) : [name]
254
+ segments.each do |segment|
255
+ raise ArgumentError, "method name contains empty segment" if segment.empty?
256
+ raise ArgumentError, "method name '#{segment}' is dangerous" if DANGEROUS_METHODS.include?(segment)
257
+ end
258
+ end
259
+
260
+ def resolve_callable(callable, block)
261
+ raise ArgumentError, "provide either a callable or a block, not both" if callable && block
262
+ raise ArgumentError, "a callable or block is required" unless callable || block
263
+
264
+ (callable || block).tap do |resolved|
265
+ raise ArgumentError, "callable must respond to #call" unless resolved.respond_to?(:call)
266
+ end
267
+ end
268
+
269
+ DISPATCH_ARG_ERROR = /\Awrong number of arguments\b|\Amissing keywords?: /
270
+ private_constant :DISPATCH_ARG_ERROR
271
+
272
+ def invoke_callable(callable, params)
273
+ case params
274
+ when Array then callable.call(*params)
275
+ when Hash then callable.call(**safe_symbolize_keys(callable, params))
276
+ when nil then callable.call
277
+ else raise InvalidParams, "params must be an Array, Hash, or nil"
278
+ end
279
+ rescue ArgumentError => e
280
+ raise InvalidParams, e.message if e.message.match?(DISPATCH_ARG_ERROR)
281
+
282
+ raise
283
+ end
284
+
285
+ def safe_symbolize_keys(callable, params)
286
+ return params.transform_keys(&:to_sym) if accepts_keyrest?(callable)
287
+
288
+ known = known_keyword_params(callable)
289
+ params.each_with_object({}) do |(k, v), h|
290
+ key = k.to_s
291
+ raise InvalidParams, "unknown keyword: #{key}" unless known.key?(key)
292
+
293
+ h[known[key]] = v
294
+ end
295
+ end
296
+
297
+ def known_keyword_params(callable)
298
+ callable.parameters.each_with_object({}) do |(type, name), map|
299
+ map[name.to_s] = name if name && %i[key keyreq].include?(type)
300
+ end
301
+ end
302
+
303
+ def accepts_keyrest?(callable)
304
+ callable.parameters.any? { |type, _| type == :keyrest }
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Core
5
+ class Request
6
+ MAX_NESTING = 32
7
+
8
+ attr_reader :method_name, :params, :id, :context
9
+
10
+ def initialize(data)
11
+ validate!(data)
12
+ @method_name = data["method"].dup.freeze
13
+ @params = deep_freeze(deep_dup(data["params"]))
14
+ @id = data["id"].is_a?(String) ? data["id"].dup.freeze : data["id"]
15
+ @context = {}
16
+ end
17
+
18
+ def notification?
19
+ !@has_id
20
+ end
21
+
22
+ private
23
+
24
+ def validate!(data)
25
+ validate_structure!(data)
26
+ validate_params!(data["params"]) if data.key?("params")
27
+ @has_id = data.key?("id")
28
+ validate_id!(data["id"]) if @has_id
29
+ end
30
+
31
+ def validate_structure!(data)
32
+ raise InvalidRequest, "request must be a JSON object" unless data.is_a?(Hash)
33
+ raise InvalidRequest, "jsonrpc must be \"2.0\"" unless data["jsonrpc"] == "2.0"
34
+
35
+ valid_method = data["method"].is_a?(String) && !data["method"].empty?
36
+ raise InvalidRequest, "method must be a non-empty String" unless valid_method
37
+ end
38
+
39
+ def validate_params!(params)
40
+ return if params.is_a?(Array) || params.is_a?(Hash)
41
+
42
+ raise InvalidRequest, "params must be an Array or Object"
43
+ end
44
+
45
+ def validate_id!(id)
46
+ return if id.nil? || id.is_a?(String) || id.is_a?(Numeric)
47
+
48
+ raise InvalidRequest, "id must be a String, Number, or Null"
49
+ end
50
+
51
+ def deep_dup(obj)
52
+ case obj
53
+ when Hash then obj.to_h { |k, v| [k.frozen? ? k : k.dup, deep_dup(v)] }
54
+ when Array then obj.map { |v| deep_dup(v) }
55
+ else obj.frozen? ? obj : obj.dup
56
+ end
57
+ end
58
+
59
+ def deep_freeze(obj, depth = 0)
60
+ raise InvalidRequest, "params nesting too deep (max #{MAX_NESTING})" if depth > MAX_NESTING
61
+
62
+ case obj
63
+ when Hash
64
+ obj.each do |k, v|
65
+ k.freeze
66
+ deep_freeze(v, depth + 1)
67
+ end
68
+ when Array then obj.each { |v| deep_freeze(v, depth + 1) }
69
+ end
70
+ obj.freeze
71
+ end
72
+ end
73
+ end
74
+ end