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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.local.json +9 -0
  3. data/.editorconfig +11 -0
  4. data/.overcommit.yml +31 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +74 -0
  7. data/.tool-versions +1 -0
  8. data/.yardstick.yml +22 -0
  9. data/CHANGELOG.md +37 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/Guardfile +22 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +248 -0
  14. data/Rakefile +41 -0
  15. data/Steepfile +7 -0
  16. data/docs/JSON-RPC-2.0-Specification.md +278 -0
  17. data/examples/procedures.rb +55 -0
  18. data/examples/rack/Gemfile +8 -0
  19. data/examples/rack/Gemfile.lock +68 -0
  20. data/examples/rack/README.md +7 -0
  21. data/examples/rack/app.rb +48 -0
  22. data/examples/rack/config.ru +19 -0
  23. data/examples/rack-echo/Gemfile +8 -0
  24. data/examples/rack-echo/Gemfile.lock +68 -0
  25. data/examples/rack-echo/README.md +7 -0
  26. data/examples/rack-echo/app.rb +43 -0
  27. data/examples/rack-echo/config.ru +18 -0
  28. data/lib/jsonrpc/batch_request.rb +102 -0
  29. data/lib/jsonrpc/batch_response.rb +85 -0
  30. data/lib/jsonrpc/configuration.rb +85 -0
  31. data/lib/jsonrpc/error.rb +96 -0
  32. data/lib/jsonrpc/errors/internal_error.rb +27 -0
  33. data/lib/jsonrpc/errors/invalid_params_error.rb +27 -0
  34. data/lib/jsonrpc/errors/invalid_request_error.rb +31 -0
  35. data/lib/jsonrpc/errors/method_not_found_error.rb +31 -0
  36. data/lib/jsonrpc/errors/parse_error.rb +29 -0
  37. data/lib/jsonrpc/helpers.rb +83 -0
  38. data/lib/jsonrpc/middleware.rb +190 -0
  39. data/lib/jsonrpc/notification.rb +94 -0
  40. data/lib/jsonrpc/parser.rb +176 -0
  41. data/lib/jsonrpc/request.rb +112 -0
  42. data/lib/jsonrpc/response.rb +127 -0
  43. data/lib/jsonrpc/validator.rb +140 -0
  44. data/lib/jsonrpc/version.rb +5 -0
  45. data/lib/jsonrpc.rb +25 -0
  46. data/sig/jsonrpc/middleware.rbs +6 -0
  47. data/sig/jsonrpc/parser.rbs +7 -0
  48. data/sig/jsonrpc.rbs +164 -0
  49. 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