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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +478 -0
- data/Rakefile +12 -0
- data/examples/error_handling.rb +51 -0
- data/examples/mcp_server.rb +42 -0
- data/examples/multi_namespace.rb +49 -0
- data/examples/rack_server.ru +24 -0
- data/examples/testing_patterns.rb +49 -0
- data/examples/websocket_server.ru +42 -0
- data/lib/ultimate_json_rpc/core/errors.rb +85 -0
- data/lib/ultimate_json_rpc/core/handler.rb +308 -0
- data/lib/ultimate_json_rpc/core/request.rb +74 -0
- data/lib/ultimate_json_rpc/core/response.rb +28 -0
- data/lib/ultimate_json_rpc/extras/docs.rb +107 -0
- data/lib/ultimate_json_rpc/extras/logging.rb +21 -0
- data/lib/ultimate_json_rpc/extras/mcp.rb +144 -0
- data/lib/ultimate_json_rpc/extras/profiler.rb +87 -0
- data/lib/ultimate_json_rpc/extras/rate_limit.rb +57 -0
- data/lib/ultimate_json_rpc/extras/recorder.rb +67 -0
- data/lib/ultimate_json_rpc/extras/test_helpers.rb +53 -0
- data/lib/ultimate_json_rpc/server.rb +359 -0
- data/lib/ultimate_json_rpc/transport/rack.rb +51 -0
- data/lib/ultimate_json_rpc/transport/stdio.rb +63 -0
- data/lib/ultimate_json_rpc/transport/tcp.rb +153 -0
- data/lib/ultimate_json_rpc/transport/websocket.rb +29 -0
- data/lib/ultimate_json_rpc/version.rb +5 -0
- data/lib/ultimate_json_rpc.rb +16 -0
- data/sig/ultimate_json_rpc.rbs +208 -0
- metadata +75 -0
|
@@ -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
|