jsonrpc-middleware 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/.claude/settings.local.json +9 -0
- data/.editorconfig +11 -0
- data/.overcommit.yml +31 -0
- data/.rspec +3 -0
- data/.rubocop.yml +74 -0
- data/.tool-versions +1 -0
- data/.yardstick.yml +22 -0
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Guardfile +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/Rakefile +41 -0
- data/Steepfile +7 -0
- data/docs/JSON-RPC-2.0-Specification.md +278 -0
- data/examples/procedures.rb +55 -0
- data/examples/rack/Gemfile +8 -0
- data/examples/rack/Gemfile.lock +68 -0
- data/examples/rack/README.md +7 -0
- data/examples/rack/app.rb +48 -0
- data/examples/rack/config.ru +19 -0
- data/examples/rack-echo/Gemfile +8 -0
- data/examples/rack-echo/Gemfile.lock +68 -0
- data/examples/rack-echo/README.md +7 -0
- data/examples/rack-echo/app.rb +43 -0
- data/examples/rack-echo/config.ru +18 -0
- data/lib/jsonrpc/batch_request.rb +102 -0
- data/lib/jsonrpc/batch_response.rb +85 -0
- data/lib/jsonrpc/configuration.rb +85 -0
- data/lib/jsonrpc/error.rb +96 -0
- data/lib/jsonrpc/errors/internal_error.rb +27 -0
- data/lib/jsonrpc/errors/invalid_params_error.rb +27 -0
- data/lib/jsonrpc/errors/invalid_request_error.rb +31 -0
- data/lib/jsonrpc/errors/method_not_found_error.rb +31 -0
- data/lib/jsonrpc/errors/parse_error.rb +29 -0
- data/lib/jsonrpc/helpers.rb +83 -0
- data/lib/jsonrpc/middleware.rb +190 -0
- data/lib/jsonrpc/notification.rb +94 -0
- data/lib/jsonrpc/parser.rb +176 -0
- data/lib/jsonrpc/request.rb +112 -0
- data/lib/jsonrpc/response.rb +127 -0
- data/lib/jsonrpc/validator.rb +140 -0
- data/lib/jsonrpc/version.rb +5 -0
- data/lib/jsonrpc.rb +25 -0
- data/sig/jsonrpc/middleware.rbs +6 -0
- data/sig/jsonrpc/parser.rbs +7 -0
- data/sig/jsonrpc.rbs +164 -0
- metadata +120 -0
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack'
|
4
|
+
|
5
|
+
module JSONRPC
|
6
|
+
# Middleware for JSON-RPC compliance
|
7
|
+
class Middleware
|
8
|
+
DEFAULT_PATH = '/'
|
9
|
+
|
10
|
+
def initialize(app, options = {})
|
11
|
+
@app = app
|
12
|
+
@parser = Parser.new
|
13
|
+
@validator = Validator.new
|
14
|
+
@path = options.fetch(:path, DEFAULT_PATH)
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(env)
|
18
|
+
@req = Rack::Request.new(env)
|
19
|
+
|
20
|
+
if jsonrpc_request?
|
21
|
+
handle_jsonrpc_request
|
22
|
+
else
|
23
|
+
@app.call(env)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def jsonrpc_request?
|
30
|
+
@req.path == @path && @req.post?
|
31
|
+
end
|
32
|
+
|
33
|
+
def handle_jsonrpc_request
|
34
|
+
parsed_request = parse_request
|
35
|
+
return parsed_request if parsed_request.is_a?(Array) # Early return for parse errors
|
36
|
+
|
37
|
+
validation_result = validate_request(parsed_request)
|
38
|
+
return validation_result if validation_result.is_a?(Array) # Early return for validation errors
|
39
|
+
|
40
|
+
# Set parsed request in environment and call app
|
41
|
+
store_request_in_env(parsed_request)
|
42
|
+
@app.call(@req.env)
|
43
|
+
rescue StandardError
|
44
|
+
error = InternalError.new(request_id: parsed_request.is_a?(Request) ? parsed_request.id : nil)
|
45
|
+
@req.env['jsonrpc.error'] = error
|
46
|
+
json_response(200, error.to_response)
|
47
|
+
end
|
48
|
+
|
49
|
+
def parse_request
|
50
|
+
body = read_request_body
|
51
|
+
parsed = @parser.parse(body)
|
52
|
+
|
53
|
+
# Handle batch requests with parse errors separately
|
54
|
+
return handle_mixed_batch_errors(parsed) if parsed.is_a?(BatchRequest) && parse_errors?(parsed)
|
55
|
+
|
56
|
+
parsed
|
57
|
+
rescue ParseError, InvalidRequestError => e
|
58
|
+
@req.env['jsonrpc.error'] = e
|
59
|
+
json_response(200, e.to_response)
|
60
|
+
end
|
61
|
+
|
62
|
+
def validate_request(parsed_request)
|
63
|
+
validation_errors = @validator.validate(parsed_request)
|
64
|
+
|
65
|
+
case validation_errors
|
66
|
+
when Array
|
67
|
+
handle_batch_validation_errors(parsed_request, validation_errors)
|
68
|
+
when Error
|
69
|
+
json_response(200, validation_errors.to_response)
|
70
|
+
when nil
|
71
|
+
nil # No errors, continue processing
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def store_request_in_env(parsed_request)
|
76
|
+
case parsed_request
|
77
|
+
when Request
|
78
|
+
@req.env['jsonrpc.request'] = parsed_request
|
79
|
+
when Notification
|
80
|
+
@req.env['jsonrpc.notification'] = parsed_request
|
81
|
+
when BatchRequest
|
82
|
+
@req.env['jsonrpc.batch'] = parsed_request
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Batch handling methods
|
87
|
+
|
88
|
+
def parse_errors?(batch_request)
|
89
|
+
batch_request.requests.any?(Error)
|
90
|
+
end
|
91
|
+
|
92
|
+
def handle_mixed_batch_errors(batch_request)
|
93
|
+
error_responses = collect_parse_error_responses(batch_request)
|
94
|
+
valid_requests = collect_valid_requests(batch_request)
|
95
|
+
|
96
|
+
if valid_requests.any?
|
97
|
+
success_responses = process_valid_batch_requests(valid_requests)
|
98
|
+
error_responses.concat(success_responses)
|
99
|
+
end
|
100
|
+
|
101
|
+
json_response(200, BatchResponse.new(error_responses).to_h)
|
102
|
+
end
|
103
|
+
|
104
|
+
def handle_batch_validation_errors(batch_request, validation_errors)
|
105
|
+
responses = build_ordered_responses(batch_request, validation_errors)
|
106
|
+
valid_requests, indices = extract_valid_requests(batch_request, validation_errors)
|
107
|
+
|
108
|
+
if valid_requests.any?
|
109
|
+
success_responses = process_valid_batch_requests(valid_requests)
|
110
|
+
merge_success_responses(responses, success_responses, indices)
|
111
|
+
end
|
112
|
+
|
113
|
+
json_response(200, BatchResponse.new(responses.compact).to_h)
|
114
|
+
end
|
115
|
+
|
116
|
+
def collect_parse_error_responses(batch_request)
|
117
|
+
batch_request.requests.filter_map do |item|
|
118
|
+
Response.new(id: item.request_id, error: item) if item.is_a?(Error)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def collect_valid_requests(batch_request)
|
123
|
+
batch_request.requests.reject { |item| item.is_a?(Error) }
|
124
|
+
end
|
125
|
+
|
126
|
+
def build_ordered_responses(batch_request, validation_errors)
|
127
|
+
responses = Array.new(batch_request.requests.size)
|
128
|
+
|
129
|
+
batch_request.requests.each_with_index do |request, index|
|
130
|
+
responses[index] = Response.new(id: request.id, error: validation_errors[index]) if validation_errors[index]
|
131
|
+
end
|
132
|
+
|
133
|
+
responses
|
134
|
+
end
|
135
|
+
|
136
|
+
def extract_valid_requests(batch_request, validation_errors)
|
137
|
+
valid_requests = []
|
138
|
+
valid_indices = []
|
139
|
+
|
140
|
+
batch_request.requests.each_with_index do |request, index|
|
141
|
+
unless validation_errors[index]
|
142
|
+
valid_requests << request
|
143
|
+
valid_indices << index
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
[valid_requests, valid_indices]
|
148
|
+
end
|
149
|
+
|
150
|
+
def process_valid_batch_requests(valid_requests)
|
151
|
+
valid_batch = BatchRequest.new(valid_requests)
|
152
|
+
|
153
|
+
# For mixed error scenarios, validate the remaining requests
|
154
|
+
validation_errors = @validator.validate(valid_batch)
|
155
|
+
return [] if validation_errors
|
156
|
+
|
157
|
+
@req.env['jsonrpc.batch'] = valid_batch
|
158
|
+
status, _headers, body = @app.call(@req.env)
|
159
|
+
|
160
|
+
return [] unless status == 200 && !body.empty?
|
161
|
+
|
162
|
+
app_responses = JSON.parse(body.join)
|
163
|
+
app_responses.map do |resp|
|
164
|
+
Response.new(id: resp['id'], result: resp['result'], error: resp['error'])
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def merge_success_responses(responses, success_responses, valid_indices)
|
169
|
+
success_responses.each_with_index do |response, app_index|
|
170
|
+
original_index = valid_indices[app_index]
|
171
|
+
responses[original_index] = response
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Utility methods
|
176
|
+
|
177
|
+
def json_response(status, body)
|
178
|
+
[status, { 'content-type' => 'application/json' }, [body.is_a?(String) ? body : JSON.generate(body)]]
|
179
|
+
end
|
180
|
+
|
181
|
+
def read_request_body
|
182
|
+
body = @req.env[Rack::RACK_INPUT]
|
183
|
+
return unless body
|
184
|
+
|
185
|
+
body_content = body.read
|
186
|
+
body.rewind if body.respond_to?(:rewind)
|
187
|
+
body_content
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JSONRPC
|
4
|
+
# A JSON-RPC 2.0 Notification object
|
5
|
+
#
|
6
|
+
# A Notification is a Request object without an "id" member.
|
7
|
+
# Notifications are not confirmable by definition since they do not have a Response object.
|
8
|
+
#
|
9
|
+
# @example Create a notification with parameters
|
10
|
+
# notification = JSONRPC::Notification.new(method: "update", params: [1, 2, 3, 4, 5])
|
11
|
+
#
|
12
|
+
# @example Create a notification without parameters
|
13
|
+
# notification = JSONRPC::Notification.new(method: "heartbeat")
|
14
|
+
#
|
15
|
+
class Notification
|
16
|
+
# JSON-RPC protocol version
|
17
|
+
# @return [String]
|
18
|
+
#
|
19
|
+
attr_reader :jsonrpc
|
20
|
+
|
21
|
+
# The method name to invoke
|
22
|
+
# @return [String]
|
23
|
+
#
|
24
|
+
attr_reader :method
|
25
|
+
|
26
|
+
# Parameters to pass to the method
|
27
|
+
# @return [Hash, Array, nil]
|
28
|
+
#
|
29
|
+
attr_reader :params
|
30
|
+
|
31
|
+
# Creates a new JSON-RPC 2.0 Notification object
|
32
|
+
#
|
33
|
+
# @param method [String] the name of the method to be invoked
|
34
|
+
# @param params [Hash, Array, nil] the parameters to be used during method invocation
|
35
|
+
# @raise [ArgumentError] if method is not a String or is reserved
|
36
|
+
# @raise [ArgumentError] if params is not a Hash, Array, or nil
|
37
|
+
#
|
38
|
+
def initialize(method:, params: nil)
|
39
|
+
@jsonrpc = '2.0'
|
40
|
+
|
41
|
+
validate_method(method)
|
42
|
+
validate_params(params)
|
43
|
+
|
44
|
+
@method = method
|
45
|
+
@params = params
|
46
|
+
end
|
47
|
+
|
48
|
+
# Converts the notification to a JSON-compatible Hash
|
49
|
+
#
|
50
|
+
# @return [Hash] the notification as a JSON-compatible Hash
|
51
|
+
#
|
52
|
+
def to_h
|
53
|
+
hash = {
|
54
|
+
jsonrpc: jsonrpc,
|
55
|
+
method: method
|
56
|
+
}
|
57
|
+
|
58
|
+
hash[:params] = params unless params.nil?
|
59
|
+
hash
|
60
|
+
end
|
61
|
+
|
62
|
+
def to_json(*)
|
63
|
+
to_h.to_json(*)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# Validates that the method name meets JSON-RPC 2.0 requirements
|
69
|
+
#
|
70
|
+
# @param method [String] the method name
|
71
|
+
# @raise [ArgumentError] if method is not a String or is reserved
|
72
|
+
#
|
73
|
+
def validate_method(method)
|
74
|
+
raise ArgumentError, 'Method must be a String' unless method.is_a?(String)
|
75
|
+
|
76
|
+
return unless method.start_with?('rpc.')
|
77
|
+
|
78
|
+
raise ArgumentError, "Method names starting with 'rpc.' are reserved"
|
79
|
+
end
|
80
|
+
|
81
|
+
# Validates that the params is a valid structure according to JSON-RPC 2.0
|
82
|
+
#
|
83
|
+
# @param params [Hash, Array, nil] the parameters
|
84
|
+
# @raise [ArgumentError] if params is not a Hash, Array, or nil
|
85
|
+
#
|
86
|
+
def validate_params(params)
|
87
|
+
return if params.nil?
|
88
|
+
|
89
|
+
return if params.is_a?(Hash) || params.is_a?(Array)
|
90
|
+
|
91
|
+
raise ArgumentError, 'Params must be an Object, Array, or omitted'
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module JSONRPC
|
6
|
+
# JSON-RPC 2.0 Parser for converting raw JSON into JSONRPC objects
|
7
|
+
#
|
8
|
+
# The Parser handles converting raw JSON strings into appropriate JSONRPC objects
|
9
|
+
# based on the JSON-RPC 2.0 protocol specification.
|
10
|
+
#
|
11
|
+
# @example Parse a request
|
12
|
+
# parser = JSONRPC::Parser.new
|
13
|
+
# request = parser.parse('{"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":1}')
|
14
|
+
#
|
15
|
+
# @example JSONRPC::Parse a batch request
|
16
|
+
# parser = JSONRPC::Parser.new
|
17
|
+
# batch = parser.parse('[{"jsonrpc":"2.0","method":"sum","params":[1,2],"id":"1"},
|
18
|
+
# {"jsonrpc":"2.0","method":"notify_hello","params":[7]}]')
|
19
|
+
#
|
20
|
+
class Parser
|
21
|
+
# Parse a JSON-RPC 2.0 message
|
22
|
+
#
|
23
|
+
# @param json [String] the JSON-RPC 2.0 message
|
24
|
+
#
|
25
|
+
# @return [Request, Notification, BatchRequest] the parsed object
|
26
|
+
#
|
27
|
+
# @raise [ParseError] if the JSON is invalid
|
28
|
+
# @raise [InvalidRequestError] if the request structure is invalid
|
29
|
+
#
|
30
|
+
def parse(json)
|
31
|
+
begin
|
32
|
+
data = JSON.parse(json)
|
33
|
+
rescue JSON::ParserError => e
|
34
|
+
raise ParseError.new(data: { details: e.message })
|
35
|
+
end
|
36
|
+
|
37
|
+
if data.is_a?(Array)
|
38
|
+
parse_batch(data)
|
39
|
+
else
|
40
|
+
parse_single(data)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# Parse a single JSON-RPC 2.0 message
|
47
|
+
#
|
48
|
+
# @param data [Hash] the parsed JSON data
|
49
|
+
# @return [Request, Notification, Error] the parsed request, notification, or error
|
50
|
+
#
|
51
|
+
def parse_single(data)
|
52
|
+
validate_jsonrpc_version(data)
|
53
|
+
validate_request_structure(data)
|
54
|
+
|
55
|
+
method = data['method']
|
56
|
+
params = data['params']
|
57
|
+
|
58
|
+
if data.key?('id')
|
59
|
+
Request.new(method: method, params: params, id: data['id'])
|
60
|
+
else
|
61
|
+
Notification.new(method: method, params: params)
|
62
|
+
end
|
63
|
+
rescue ArgumentError => e
|
64
|
+
request_id = data.is_a?(Hash) ? data['id'] : nil
|
65
|
+
raise InvalidRequestError.new(data: { details: e.message }, request_id: request_id)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Parse a batch JSON-RPC 2.0 message
|
69
|
+
#
|
70
|
+
# @param data [Array] the array of request data
|
71
|
+
# @return [BatchRequest] the batch request
|
72
|
+
# @raise [InvalidRequestError] if the batch is empty
|
73
|
+
#
|
74
|
+
def parse_batch(data)
|
75
|
+
raise InvalidRequestError.new(data: { details: 'Batch request cannot be empty' }) if data.empty?
|
76
|
+
|
77
|
+
items = []
|
78
|
+
|
79
|
+
data.each_with_index do |item, index|
|
80
|
+
parsed_item = parse_single_for_batch(item)
|
81
|
+
items << parsed_item
|
82
|
+
rescue InvalidRequestError => e
|
83
|
+
# For batch processing, we want to include the error in the results
|
84
|
+
# rather than stopping the entire batch processing
|
85
|
+
error_with_index = InvalidRequestError.new(
|
86
|
+
data: { index: index, details: e.data&.fetch(:details, nil) },
|
87
|
+
request_id: e.request_id
|
88
|
+
)
|
89
|
+
items << error_with_index
|
90
|
+
end
|
91
|
+
|
92
|
+
BatchRequest.new(items)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Parse a single item within a batch, allowing errors to be captured
|
96
|
+
#
|
97
|
+
# @param data [Hash] the parsed JSON data for a single item
|
98
|
+
# @return [Request, Notification] the parsed request or notification
|
99
|
+
# @raise [InvalidRequestError] if the request structure is invalid
|
100
|
+
#
|
101
|
+
def parse_single_for_batch(data)
|
102
|
+
validate_jsonrpc_version(data)
|
103
|
+
validate_request_structure(data)
|
104
|
+
|
105
|
+
method = data['method']
|
106
|
+
params = data['params']
|
107
|
+
|
108
|
+
if data.key?('id')
|
109
|
+
Request.new(method: method, params: params, id: data['id'])
|
110
|
+
else
|
111
|
+
Notification.new(method: method, params: params)
|
112
|
+
end
|
113
|
+
rescue ArgumentError => e
|
114
|
+
request_id = data.is_a?(Hash) ? data['id'] : nil
|
115
|
+
raise InvalidRequestError.new(data: { details: e.message }, request_id: request_id)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Validate the JSON-RPC 2.0 version
|
119
|
+
#
|
120
|
+
# @param data [Hash] the request data
|
121
|
+
# @raise [InvalidRequestError] if the version is missing or invalid
|
122
|
+
#
|
123
|
+
def validate_jsonrpc_version(data)
|
124
|
+
raise InvalidRequestError.new(data: { details: 'Request must be an object' }) unless data.is_a?(Hash)
|
125
|
+
|
126
|
+
jsonrpc = data['jsonrpc']
|
127
|
+
|
128
|
+
# Get request ID from the data
|
129
|
+
request_id = data['id']
|
130
|
+
|
131
|
+
if jsonrpc.nil?
|
132
|
+
raise InvalidRequestError.new(data: { details: "Missing 'jsonrpc' property" },
|
133
|
+
request_id: request_id)
|
134
|
+
end
|
135
|
+
|
136
|
+
return if jsonrpc == '2.0'
|
137
|
+
|
138
|
+
raise InvalidRequestError.new(data: { details: "Invalid JSON-RPC version, must be '2.0'" },
|
139
|
+
request_id: request_id)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Validate the request structure according to JSON-RPC 2.0 specification
|
143
|
+
#
|
144
|
+
# @param data [Hash] the request data
|
145
|
+
# @raise [InvalidRequestError] if the request structure is invalid
|
146
|
+
#
|
147
|
+
def validate_request_structure(data)
|
148
|
+
method = data['method']
|
149
|
+
|
150
|
+
# Get ID for possible errors
|
151
|
+
id = data['id']
|
152
|
+
|
153
|
+
raise InvalidRequestError.new(data: { details: "Missing 'method' property" }, request_id: id) if method.nil?
|
154
|
+
|
155
|
+
unless method.is_a?(String)
|
156
|
+
raise InvalidRequestError.new(data: { details: 'Method must be a string' }, request_id: id)
|
157
|
+
end
|
158
|
+
|
159
|
+
params = data['params']
|
160
|
+
|
161
|
+
unless params.nil? || params.is_a?(Array) || params.is_a?(Hash)
|
162
|
+
raise InvalidRequestError.new(data: { details: 'Params must be an object, array, or omitted' }, request_id: id)
|
163
|
+
end
|
164
|
+
|
165
|
+
id = data['id']
|
166
|
+
unless id.nil? || id.is_a?(String) || id.is_a?(Integer) || id.nil?
|
167
|
+
raise InvalidRequestError.new(
|
168
|
+
data: { details: 'ID must be a string, number, null, or omitted' },
|
169
|
+
request_id: id
|
170
|
+
)
|
171
|
+
end
|
172
|
+
|
173
|
+
nil unless id.is_a?(Integer)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JSONRPC
|
4
|
+
# A JSON-RPC 2.0 Request object
|
5
|
+
#
|
6
|
+
# @example Create a request with positional parameters
|
7
|
+
# request = JSONRPC::Request.new(method: "subtract", params: [42, 23], id: 1)
|
8
|
+
#
|
9
|
+
# @example Create a request with named parameters
|
10
|
+
# request = JSONRPC::Request.new(method: "subtract", params: {minuend: 42, subtrahend: 23}, id: 3)
|
11
|
+
#
|
12
|
+
class Request
|
13
|
+
# JSON-RPC protocol version
|
14
|
+
# @return [String]
|
15
|
+
#
|
16
|
+
attr_reader :jsonrpc
|
17
|
+
|
18
|
+
# The method name to invoke
|
19
|
+
# @return [String]
|
20
|
+
#
|
21
|
+
attr_reader :method
|
22
|
+
|
23
|
+
# Parameters to pass to the method
|
24
|
+
# @return [Hash, Array, nil]
|
25
|
+
#
|
26
|
+
attr_reader :params
|
27
|
+
|
28
|
+
# The request identifier
|
29
|
+
# @return [String, Integer, nil]
|
30
|
+
#
|
31
|
+
attr_reader :id
|
32
|
+
|
33
|
+
# Creates a new JSON-RPC 2.0 Request object
|
34
|
+
#
|
35
|
+
# @param method [String] the name of the method to be invoked
|
36
|
+
# @param params [Hash, Array, nil] the parameters to be used during method invocation
|
37
|
+
# @param id [String, Integer, nil] the request identifier
|
38
|
+
# @raise [ArgumentError] if method is not a String or is reserved
|
39
|
+
# @raise [ArgumentError] if params is not a Hash, Array, or nil
|
40
|
+
# @raise [ArgumentError] if id is not a String, Integer, or nil
|
41
|
+
#
|
42
|
+
def initialize(method:, id:, params: nil)
|
43
|
+
@jsonrpc = '2.0'
|
44
|
+
|
45
|
+
validate_method(method)
|
46
|
+
validate_params(params)
|
47
|
+
validate_id(id)
|
48
|
+
|
49
|
+
@method = method
|
50
|
+
@params = params
|
51
|
+
@id = id
|
52
|
+
end
|
53
|
+
|
54
|
+
# Converts the request to a JSON-compatible Hash
|
55
|
+
#
|
56
|
+
# @return [Hash] the request as a JSON-compatible Hash
|
57
|
+
#
|
58
|
+
def to_h
|
59
|
+
hash = {
|
60
|
+
jsonrpc: jsonrpc,
|
61
|
+
method: method,
|
62
|
+
id: id
|
63
|
+
}
|
64
|
+
|
65
|
+
hash[:params] = params unless params.nil?
|
66
|
+
hash
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_json(*)
|
70
|
+
to_h.to_json(*)
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# Validates that the method name meets JSON-RPC 2.0 requirements
|
76
|
+
#
|
77
|
+
# @param method [String] the method name
|
78
|
+
# @raise [ArgumentError] if method is not a String or is reserved
|
79
|
+
#
|
80
|
+
def validate_method(method)
|
81
|
+
raise ArgumentError, 'Method must be a String' unless method.is_a?(String)
|
82
|
+
|
83
|
+
return unless method.start_with?('rpc.')
|
84
|
+
|
85
|
+
raise ArgumentError, "Method names starting with 'rpc.' are reserved"
|
86
|
+
end
|
87
|
+
|
88
|
+
# Validates that the params is a valid structure according to JSON-RPC 2.0
|
89
|
+
#
|
90
|
+
# @param params [Hash, Array, nil] the parameters
|
91
|
+
# @raise [ArgumentError] if params is not a Hash, Array, or nil
|
92
|
+
#
|
93
|
+
def validate_params(params)
|
94
|
+
return if params.nil?
|
95
|
+
|
96
|
+
return if params.is_a?(Hash) || params.is_a?(Array)
|
97
|
+
|
98
|
+
raise ArgumentError, 'Params must be an Object, Array, or omitted'
|
99
|
+
end
|
100
|
+
|
101
|
+
# Validates that the id meets JSON-RPC 2.0 requirements
|
102
|
+
#
|
103
|
+
# @param id [String, Integer, nil] the request identifier
|
104
|
+
# @raise [ArgumentError] if id is not a String, Integer, or nil
|
105
|
+
#
|
106
|
+
def validate_id(id)
|
107
|
+
return if id.nil?
|
108
|
+
|
109
|
+
raise ArgumentError, 'ID must be a String, Integer, or nil' unless id.is_a?(String) || id.is_a?(Integer)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JSONRPC
|
4
|
+
# A JSON-RPC 2.0 Response object
|
5
|
+
#
|
6
|
+
# When a rpc call is made, the Server must reply with a Response, except for notifications.
|
7
|
+
# A Response object can contain either a result (for success) or an error (for failure),
|
8
|
+
# but never both.
|
9
|
+
#
|
10
|
+
# @example Create a successful response
|
11
|
+
# response = JSONRPC::Response.new(result: 19, id: 1)
|
12
|
+
#
|
13
|
+
# @example Create an error response
|
14
|
+
# error = JSONRPC::Error.new(code: -32601, message: "Method not found")
|
15
|
+
# response = JSONRPC::Response.new(error: error, id: 1)
|
16
|
+
#
|
17
|
+
class Response
|
18
|
+
# JSON-RPC protocol version
|
19
|
+
# @return [String]
|
20
|
+
#
|
21
|
+
attr_reader :jsonrpc
|
22
|
+
|
23
|
+
# The result of the method invocation (for success)
|
24
|
+
# @return [Object, nil]
|
25
|
+
#
|
26
|
+
attr_reader :result
|
27
|
+
|
28
|
+
# The error object (for failure)
|
29
|
+
# @return [JSONRPC::Error, nil]
|
30
|
+
#
|
31
|
+
attr_reader :error
|
32
|
+
|
33
|
+
# The request identifier
|
34
|
+
# @return [String, Integer, nil]
|
35
|
+
#
|
36
|
+
attr_reader :id
|
37
|
+
|
38
|
+
# Creates a new JSON-RPC 2.0 Response object
|
39
|
+
#
|
40
|
+
# @param result [Object, nil] the result of the method invocation (for success)
|
41
|
+
# @param error [JSONRPC::Error, nil] the error object (for failure)
|
42
|
+
# @param id [String, Integer, nil] the request identifier
|
43
|
+
# @raise [ArgumentError] if both result and error are present or both are nil
|
44
|
+
# @raise [ArgumentError] if error is present but not a JSONRPC::Error
|
45
|
+
# @raise [ArgumentError] if id is not a String, Integer, or nil
|
46
|
+
#
|
47
|
+
def initialize(id:, result: nil, error: nil)
|
48
|
+
@jsonrpc = '2.0'
|
49
|
+
|
50
|
+
validate_result_and_error(result, error)
|
51
|
+
validate_id(id)
|
52
|
+
|
53
|
+
@result = result
|
54
|
+
@error = error
|
55
|
+
@id = id
|
56
|
+
end
|
57
|
+
|
58
|
+
# Checks if the response is successful
|
59
|
+
#
|
60
|
+
# @return [Boolean] true if the response contains a result, false if it contains an error
|
61
|
+
#
|
62
|
+
def success?
|
63
|
+
!@result.nil?
|
64
|
+
end
|
65
|
+
|
66
|
+
# Checks if the response is an error
|
67
|
+
#
|
68
|
+
# @return [Boolean] true if the response contains an error, false if it contains a result
|
69
|
+
#
|
70
|
+
def error?
|
71
|
+
!@error.nil?
|
72
|
+
end
|
73
|
+
|
74
|
+
# Converts the response to a JSON-compatible Hash
|
75
|
+
#
|
76
|
+
# @return [Hash] the response as a JSON-compatible Hash
|
77
|
+
#
|
78
|
+
def to_h
|
79
|
+
hash = {
|
80
|
+
jsonrpc: jsonrpc,
|
81
|
+
id: id
|
82
|
+
}
|
83
|
+
|
84
|
+
if success?
|
85
|
+
hash[:result] = result
|
86
|
+
else
|
87
|
+
hash[:error] = error.to_h
|
88
|
+
end
|
89
|
+
|
90
|
+
hash
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_json(*)
|
94
|
+
to_h.to_json(*)
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# Validates that exactly one of result or error is present
|
100
|
+
#
|
101
|
+
# @param result [Object, nil] the result
|
102
|
+
# @param error [JSONRPC::Error, nil] the error
|
103
|
+
# @raise [ArgumentError] if both result and error are present or both are nil
|
104
|
+
# @raise [ArgumentError] if error is present but not a JSONRPC::Error
|
105
|
+
#
|
106
|
+
def validate_result_and_error(result, error)
|
107
|
+
raise ArgumentError, 'Either result or error must be present' if result.nil? && error.nil?
|
108
|
+
|
109
|
+
raise ArgumentError, 'Response cannot contain both result and error' if !result.nil? && !error.nil?
|
110
|
+
|
111
|
+
return unless !error.nil? && !error.is_a?(Error)
|
112
|
+
|
113
|
+
raise ArgumentError, 'Error must be a JSONRPC::Error'
|
114
|
+
end
|
115
|
+
|
116
|
+
# Validates that the id meets JSON-RPC 2.0 requirements
|
117
|
+
#
|
118
|
+
# @param id [String, Integer, nil] the request identifier
|
119
|
+
# @raise [ArgumentError] if id is not a String, Integer, or nil
|
120
|
+
#
|
121
|
+
def validate_id(id)
|
122
|
+
return if id.nil?
|
123
|
+
|
124
|
+
raise ArgumentError, 'ID must be a String, Integer, or nil' unless id.is_a?(String) || id.is_a?(Integer)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|