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,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Core
|
|
5
|
+
module Response
|
|
6
|
+
def self.success(result, id)
|
|
7
|
+
{
|
|
8
|
+
"jsonrpc" => "2.0",
|
|
9
|
+
"result" => result,
|
|
10
|
+
"id" => id
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.error(code, id, data: nil, message: nil)
|
|
15
|
+
err = {
|
|
16
|
+
"code" => code,
|
|
17
|
+
"message" => message || ERROR_MESSAGES.fetch(code, "Unknown error")
|
|
18
|
+
}
|
|
19
|
+
err["data"] = data unless data.nil?
|
|
20
|
+
{
|
|
21
|
+
"jsonrpc" => "2.0",
|
|
22
|
+
"error" => err,
|
|
23
|
+
"id" => id
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Extras
|
|
5
|
+
class Docs
|
|
6
|
+
def initialize(server, json: JSON)
|
|
7
|
+
@server = server
|
|
8
|
+
@json = json
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_markdown
|
|
12
|
+
lines = []
|
|
13
|
+
lines << title_section
|
|
14
|
+
lines << methods_section
|
|
15
|
+
lines << errors_section
|
|
16
|
+
lines.compact.join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def discover
|
|
22
|
+
@discover ||= begin
|
|
23
|
+
request = { "jsonrpc" => "2.0", "method" => "rpc.discover", "id" => 1 }
|
|
24
|
+
result = @json.parse(@server.handle(@json.generate(request)))
|
|
25
|
+
raise "rpc.discover returned an error: #{result["error"]&.inspect}" unless result.key?("result")
|
|
26
|
+
|
|
27
|
+
result["result"]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def title_section
|
|
32
|
+
info = discover["info"]
|
|
33
|
+
return "# API Reference\n" unless info
|
|
34
|
+
|
|
35
|
+
lines = []
|
|
36
|
+
lines << "# #{info["title"] || "API Reference"}\n"
|
|
37
|
+
lines << "#{info["description"]}\n" if info["description"]
|
|
38
|
+
lines << "**Version:** #{info["version"]}\n" if info["version"]
|
|
39
|
+
lines.join("\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def methods_section
|
|
43
|
+
methods = discover["methods"]
|
|
44
|
+
return nil if methods.nil? || methods.empty?
|
|
45
|
+
|
|
46
|
+
lines = ["## Methods\n"]
|
|
47
|
+
methods.each { |m| lines << method_entry(m) }
|
|
48
|
+
lines.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def method_entry(method)
|
|
52
|
+
lines = ["### `#{method["name"]}`\n"]
|
|
53
|
+
append_method_metadata(lines, method)
|
|
54
|
+
lines.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def append_method_metadata(lines, method)
|
|
58
|
+
lines << "#{method["description"]}\n" if method["description"]
|
|
59
|
+
lines << "*Deprecated: #{deprecation_text(method["deprecated"])}*\n" if method["deprecated"]
|
|
60
|
+
lines << params_table(method["params"]) if method["params"]&.any?
|
|
61
|
+
lines << return_section(method["result"]) if method["result"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def deprecation_text(value)
|
|
65
|
+
value == true ? "This method is deprecated." : value.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def params_table(params)
|
|
69
|
+
lines = ["**Parameters:**\n"]
|
|
70
|
+
lines << "| Name | Required | Type |"
|
|
71
|
+
lines << "|------|----------|------|"
|
|
72
|
+
params.each do |p|
|
|
73
|
+
required = p["required"] ? "Yes" : "No"
|
|
74
|
+
type = escape_cell(p.dig("schema", "type") || "-")
|
|
75
|
+
lines << "| `#{escape_cell(p["name"])}` | #{required} | #{type} |"
|
|
76
|
+
end
|
|
77
|
+
lines << ""
|
|
78
|
+
lines.join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def return_section(result)
|
|
82
|
+
type = result["type"] || result.dig("schema", "type")
|
|
83
|
+
return nil unless type
|
|
84
|
+
|
|
85
|
+
"**Returns:** `#{type}`\n"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def escape_cell(text)
|
|
89
|
+
text.to_s.gsub("|", "\\|").gsub(/\r?\n/, " ")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def errors_section
|
|
93
|
+
errors = discover.dig("components", "errors")
|
|
94
|
+
return nil unless errors&.any?
|
|
95
|
+
|
|
96
|
+
lines = ["## Error Codes\n"]
|
|
97
|
+
lines << "| Code | Name | Description |"
|
|
98
|
+
lines << "|------|------|-------------|"
|
|
99
|
+
errors.each do |e|
|
|
100
|
+
lines << "| #{e["code"]} | #{escape_cell(e["message"])} | #{escape_cell(e["data"] || "-")} |"
|
|
101
|
+
end
|
|
102
|
+
lines << ""
|
|
103
|
+
lines.join("\n")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Extras
|
|
5
|
+
class Logging
|
|
6
|
+
def initialize(server, logger, level: :info)
|
|
7
|
+
server.on(:response) do |request, _result, duration|
|
|
8
|
+
logger.public_send(level, "UltimateJsonRpc") do
|
|
9
|
+
"#{request.method_name} (#{format("%.1f", duration * 1000)}ms)"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
server.on(:error) do |request, error, duration|
|
|
14
|
+
logger.error("UltimateJsonRpc") do
|
|
15
|
+
"#{request.method_name} #{error.class} (#{format("%.1f", duration * 1000)}ms)"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../transport/stdio"
|
|
4
|
+
|
|
5
|
+
module UltimateJsonRpc
|
|
6
|
+
module Extras
|
|
7
|
+
class MCP
|
|
8
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
9
|
+
|
|
10
|
+
def initialize(server, input: $stdin, output: $stdout, json: JSON)
|
|
11
|
+
@app_server = server
|
|
12
|
+
@app_server.freeze
|
|
13
|
+
@json = json
|
|
14
|
+
@methods_cache = server.methods_info
|
|
15
|
+
@methods_index = @methods_cache.to_h { |m| [m["name"], m] }
|
|
16
|
+
@mcp_server = build_mcp_server
|
|
17
|
+
@input = input
|
|
18
|
+
@output = output
|
|
19
|
+
@stdio = nil
|
|
20
|
+
@call_id = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
@stdio = Transport::Stdio.new(@mcp_server, input: @input, output: @output)
|
|
25
|
+
@stdio.run
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
@stdio&.stop
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def running?
|
|
33
|
+
@stdio&.running? || false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_mcp_server
|
|
39
|
+
mcp = Server.new(name: @app_server.name, version: @app_server.version, json: @json)
|
|
40
|
+
mcp.expose_method("initialize") { mcp_initialize }
|
|
41
|
+
mcp.expose_method("notifications/initialized") { nil }
|
|
42
|
+
mcp.expose_method("tools/list") { mcp_tools_list }
|
|
43
|
+
mcp.expose_method("tools/call") { |name:, arguments: {}| mcp_tools_call(name, arguments) }
|
|
44
|
+
mcp
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mcp_initialize
|
|
48
|
+
{
|
|
49
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
50
|
+
"capabilities" => { "tools" => {} },
|
|
51
|
+
"serverInfo" => {
|
|
52
|
+
"name" => @app_server.name || "UltimateJsonRpc MCP Server",
|
|
53
|
+
"version" => @app_server.version || "0.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mcp_tools_list
|
|
59
|
+
{ "tools" => @methods_cache.map { |m| to_mcp_tool(m) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mcp_tools_call(name, arguments)
|
|
63
|
+
request = build_call_request(name, arguments)
|
|
64
|
+
raw = @app_server.handle_parsed(request)
|
|
65
|
+
unless raw
|
|
66
|
+
return { "content" => [{ "type" => "text", "text" => "No response from server" }], "isError" => true }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
response = @json.parse(raw)
|
|
70
|
+
format_call_response(response)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
{ "content" => [{ "type" => "text", "text" => e.message }], "isError" => true }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_mcp_tool(method_info)
|
|
76
|
+
tool = { "name" => method_info["name"] }
|
|
77
|
+
tool["description"] = method_info["description"] if method_info["description"]
|
|
78
|
+
tool["inputSchema"] = method_info["params"] ? build_input_schema(method_info["params"]) : { "type" => "object" }
|
|
79
|
+
tool
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_input_schema(params)
|
|
83
|
+
schema = { "type" => "object" }
|
|
84
|
+
properties = {}
|
|
85
|
+
required = []
|
|
86
|
+
|
|
87
|
+
params.each do |p|
|
|
88
|
+
next if p["variadic"]
|
|
89
|
+
|
|
90
|
+
properties[p["name"]] = p.fetch("schema", {})
|
|
91
|
+
required << p["name"] if p["required"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
schema["properties"] = properties unless properties.empty?
|
|
95
|
+
schema["required"] = required unless required.empty?
|
|
96
|
+
schema
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_call_request(name, arguments)
|
|
100
|
+
@call_id += 1
|
|
101
|
+
request = { "jsonrpc" => "2.0", "method" => name, "id" => "mcp-#{@call_id}" }
|
|
102
|
+
return request if arguments.nil? || arguments.empty?
|
|
103
|
+
|
|
104
|
+
method_info = @methods_index[name]
|
|
105
|
+
request["params"] = convert_arguments(arguments, method_info)
|
|
106
|
+
request
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def convert_arguments(arguments, method_info)
|
|
110
|
+
params = method_info&.dig("params")
|
|
111
|
+
return arguments unless params
|
|
112
|
+
|
|
113
|
+
arguments = arguments.transform_keys(&:to_s)
|
|
114
|
+
return arguments if params.any? { |p| p["keyword"] }
|
|
115
|
+
|
|
116
|
+
convert_to_positional(arguments, params)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def convert_to_positional(arguments, params)
|
|
120
|
+
params.reject { |p| p["variadic"] }.map do |p|
|
|
121
|
+
validate_required_argument!(arguments, p)
|
|
122
|
+
arguments[p["name"]]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_required_argument!(arguments, param)
|
|
127
|
+
name = param["name"]
|
|
128
|
+
return if name.empty? || !param["required"] || arguments.key?(name)
|
|
129
|
+
|
|
130
|
+
raise ArgumentError, "missing required argument: #{name}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def format_call_response(response)
|
|
134
|
+
if response&.key?("error")
|
|
135
|
+
{ "content" => [{ "type" => "text", "text" => response.dig("error", "message").to_s }], "isError" => true }
|
|
136
|
+
else
|
|
137
|
+
result = response&.fetch("result", nil)
|
|
138
|
+
text = result.is_a?(String) ? result : @json.generate(result)
|
|
139
|
+
{ "content" => [{ "type" => "text", "text" => text }] }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Extras
|
|
5
|
+
class Profiler
|
|
6
|
+
DEFAULT_MAX_SAMPLES = 10_000
|
|
7
|
+
|
|
8
|
+
def initialize(server, max_samples: DEFAULT_MAX_SAMPLES)
|
|
9
|
+
@data = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@max_samples = max_samples
|
|
12
|
+
attach(server)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def [](method_name)
|
|
16
|
+
entry_copy = @mutex.synchronize do
|
|
17
|
+
entry = @data[method_name]
|
|
18
|
+
return nil unless entry
|
|
19
|
+
|
|
20
|
+
snapshot_entry(entry)
|
|
21
|
+
end
|
|
22
|
+
build_stats(entry_copy)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def tracked_methods
|
|
26
|
+
@mutex.synchronize { @data.keys.sort }
|
|
27
|
+
end
|
|
28
|
+
alias method_names tracked_methods
|
|
29
|
+
|
|
30
|
+
def stats
|
|
31
|
+
entries = @mutex.synchronize { @data.to_h { |name, entry| [name, snapshot_entry(entry)] } }
|
|
32
|
+
entries.transform_values { |entry| build_stats(entry) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset
|
|
36
|
+
@mutex.synchronize { @data.clear }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def attach(server)
|
|
42
|
+
server.on(:response) { |request, _result, duration| record(request.method_name, duration) }
|
|
43
|
+
server.on(:error) { |request, _error, duration| record(request.method_name, duration) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record(method_name, duration)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
entry = (@data[method_name] ||= { count: 0, total: 0.0, min: Float::INFINITY, max: 0.0, durations: [] })
|
|
49
|
+
entry[:count] += 1
|
|
50
|
+
entry[:total] += duration
|
|
51
|
+
entry[:min] = duration if duration < entry[:min]
|
|
52
|
+
entry[:max] = duration if duration > entry[:max]
|
|
53
|
+
entry[:durations] << duration
|
|
54
|
+
entry[:durations].shift if entry[:durations].size > @max_samples
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def snapshot_entry(entry)
|
|
59
|
+
{ count: entry[:count], total: entry[:total], min: entry[:min], max: entry[:max],
|
|
60
|
+
durations: entry[:durations].dup }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_stats(entry)
|
|
64
|
+
sorted = entry[:durations].sort
|
|
65
|
+
timing_stats(entry).merge(percentile_stats(sorted))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def timing_stats(entry)
|
|
69
|
+
avg = entry[:count].zero? ? 0.0 : entry[:total] / entry[:count]
|
|
70
|
+
{ count: entry[:count], total: entry[:total].round(6),
|
|
71
|
+
min: entry[:min].round(6), max: entry[:max].round(6), avg: avg.round(6),
|
|
72
|
+
samples: entry[:durations].size }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def percentile_stats(sorted)
|
|
76
|
+
{ p50: percentile(sorted, 50), p95: percentile(sorted, 95), p99: percentile(sorted, 99) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def percentile(sorted, pct)
|
|
80
|
+
return 0.0 if sorted.empty?
|
|
81
|
+
|
|
82
|
+
k = ((pct / 100.0) * sorted.size).ceil - 1
|
|
83
|
+
sorted[[k, 0].max].round(6)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Extras
|
|
5
|
+
class RateLimiter
|
|
6
|
+
def initialize(server, max:, period:, key: nil, code: 429, message: "Rate limit exceeded", only: nil, except: nil)
|
|
7
|
+
validate_code!(code)
|
|
8
|
+
@max = max
|
|
9
|
+
@period = period
|
|
10
|
+
@key_fn = build_key_fn(key)
|
|
11
|
+
@code = code
|
|
12
|
+
@message = message
|
|
13
|
+
@windows = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
server.use(only:, except:) do |request, next_call|
|
|
16
|
+
check!(@key_fn.call(request))
|
|
17
|
+
next_call.call
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_key_fn(key)
|
|
24
|
+
case key
|
|
25
|
+
when nil then ->(_) { :global }
|
|
26
|
+
when Symbol then ->(req) { req.context.fetch(key, :unknown) }
|
|
27
|
+
when Proc then key
|
|
28
|
+
else raise ArgumentError, "key must be nil, a Symbol, or a Proc"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check!(bucket)
|
|
33
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
window = (@windows[bucket] ||= [])
|
|
36
|
+
window.reject! { |t| now - t > @period }
|
|
37
|
+
raise Core::ApplicationError.new(code: @code, message: @message) if window.size >= @max
|
|
38
|
+
|
|
39
|
+
window << now
|
|
40
|
+
evict_stale!(now)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def evict_stale!(now)
|
|
45
|
+
@windows.delete_if { |_, w| w.none? { |t| now - t <= @period } } if @windows.size > 20
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_code!(code)
|
|
49
|
+
raise ArgumentError, "code must be an Integer" unless code.is_a?(Integer)
|
|
50
|
+
return unless code.between?(Core::RESERVED_ERROR_MIN, Core::RESERVED_ERROR_MAX)
|
|
51
|
+
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"code #{code} is in the reserved JSON-RPC range (#{Core::RESERVED_ERROR_MIN}..#{Core::RESERVED_ERROR_MAX})"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Extras
|
|
5
|
+
class Recorder
|
|
6
|
+
UNSET = Object.new.freeze
|
|
7
|
+
private_constant :UNSET
|
|
8
|
+
|
|
9
|
+
def initialize(server, output: nil, max_exchanges: nil, json: JSON)
|
|
10
|
+
@exchanges = []
|
|
11
|
+
@max_exchanges = max_exchanges
|
|
12
|
+
@output = output
|
|
13
|
+
@json = json
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@output_mutex = Mutex.new
|
|
16
|
+
attach(server)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def exchanges
|
|
20
|
+
@mutex.synchronize { @exchanges.dup }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear
|
|
24
|
+
@mutex.synchronize { @exchanges.clear }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def size
|
|
28
|
+
@mutex.synchronize { @exchanges.size }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def attach(server)
|
|
34
|
+
server.on(:response) do |request, result, duration|
|
|
35
|
+
record(request, duration, result:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
server.on(:error) do |request, error, duration|
|
|
39
|
+
record(request, duration, error: { "class" => error.class.name, "message" => error.message })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def record(request, duration, result: UNSET, error: nil)
|
|
44
|
+
entry = { "method" => request.method_name, "params" => request.params,
|
|
45
|
+
"duration" => duration.round(6) }
|
|
46
|
+
entry["result"] = result unless result.equal?(UNSET)
|
|
47
|
+
entry["error"] = error if error
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@exchanges << entry
|
|
50
|
+
@exchanges.shift if @max_exchanges && @exchanges.size > @max_exchanges
|
|
51
|
+
end
|
|
52
|
+
write_output(entry)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def write_output(entry)
|
|
56
|
+
return unless @output
|
|
57
|
+
|
|
58
|
+
@output_mutex.synchronize do
|
|
59
|
+
@output.puts(@json.generate(entry))
|
|
60
|
+
@output.flush
|
|
61
|
+
end
|
|
62
|
+
rescue IOError, SystemCallError
|
|
63
|
+
# Output broken; exchange still recorded in memory
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Extras
|
|
5
|
+
module TestHelpers
|
|
6
|
+
def rpc_call(server, method, params: nil, id: 1)
|
|
7
|
+
json = rpc_json_adapter(server)
|
|
8
|
+
request = { "jsonrpc" => "2.0", "method" => method, "id" => id }
|
|
9
|
+
request["params"] = params if params
|
|
10
|
+
json.parse(server.handle(json.generate(request)))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def rpc_notify(server, method, params: nil)
|
|
14
|
+
json = rpc_json_adapter(server)
|
|
15
|
+
request = { "jsonrpc" => "2.0", "method" => method }
|
|
16
|
+
request["params"] = params if params
|
|
17
|
+
server.handle(json.generate(request))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def rpc_batch(server, *requests)
|
|
21
|
+
json = rpc_json_adapter(server)
|
|
22
|
+
result = server.handle(json.generate(requests))
|
|
23
|
+
result ? json.parse(result) : nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assert_rpc_success(response, expected: :__not_given__, msg: nil)
|
|
27
|
+
assert response.key?("result"), msg || "Expected success response but got error: #{response["error"]&.inspect}"
|
|
28
|
+
refute response.key?("error"), msg || "Expected no error but got: #{response["error"]&.inspect}"
|
|
29
|
+
return if expected == :__not_given__
|
|
30
|
+
|
|
31
|
+
expected.nil? ? assert_nil(response["result"], msg) : assert_equal(expected, response["result"], msg)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def assert_rpc_error(response, code: nil, message: nil, msg: nil)
|
|
35
|
+
assert response.key?("error"), msg || "Expected error response but got result: #{response["result"]&.inspect}"
|
|
36
|
+
refute response.key?("result"), msg || "Expected no result but got: #{response["result"]&.inspect}"
|
|
37
|
+
assert_equal code, response["error"]["code"], msg if code
|
|
38
|
+
assert_equal message, response["error"]["message"], msg if message
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def assert_rpc_notification(server, method, params: nil, msg: nil)
|
|
42
|
+
result = rpc_notify(server, method, params: params)
|
|
43
|
+
assert_nil result, msg || "Expected nil for notification but got: #{result.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def rpc_json_adapter(server)
|
|
49
|
+
server.json_adapter
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|