protocol-jsonrpc 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/lib/protocol/jsonrpc/connection.rb +62 -0
- data/lib/protocol/jsonrpc/error.rb +136 -0
- data/lib/protocol/jsonrpc/error_message.rb +28 -0
- data/lib/protocol/jsonrpc/frame.rb +48 -0
- data/lib/protocol/jsonrpc/framer.rb +41 -0
- data/lib/protocol/jsonrpc/message.rb +104 -0
- data/lib/protocol/jsonrpc/notification_message.rb +37 -0
- data/lib/protocol/jsonrpc/request_message.rb +37 -0
- data/lib/protocol/jsonrpc/response_message.rb +26 -0
- data/lib/protocol/jsonrpc/version.rb +10 -0
- data/lib/protocol/jsonrpc.rb +18 -0
- data/sig/protocol/jsonrpc.rbs +6 -0
- metadata +73 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0605256ce9da467440c5c83f3d871eef77a465a33553f9f3fbb7d5f4f4edbcd2
|
4
|
+
data.tar.gz: baa116715ac8ad29ad29f5cf188dcba524e6bcfbce4f88ecfd69288736bac692
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8843f873055775f88cd4d5780d21ae605c3ebd688c94f700388be5edddbf3e795ceb15ba69557ddafe7a08224b7f95d52a3c1a68357fd1ea427af54a2cf64868
|
7
|
+
data.tar.gz: 7b8cd982a41aa44003629fdea9dc16d6ce447eb1cdaf3c25e26600e61dee5674490eed22f5b3183b71fcd48375270447ae4f8f29b21b8986889633840628bb3e
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Martin Emde
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,276 @@
|
|
1
|
+
# Protocol::Jsonrpc
|
2
|
+
|
3
|
+
A Ruby library for implementing JSON-RPC 2.0 protocol clients and servers.
|
4
|
+
|
5
|
+
Design influenced by [protocol-websocket](https://github.com/socketry/protocol-websocket) by Samuel Williams ([@ioquatix](https://github.com/ioquatix)).
|
6
|
+
Thanks Samuel!
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Add the gem to your project:
|
11
|
+
|
12
|
+
```bash
|
13
|
+
bundle add protocol-jsonrpc
|
14
|
+
```
|
15
|
+
|
16
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
17
|
+
|
18
|
+
```bash
|
19
|
+
gem install protocol-jsonrpc
|
20
|
+
```
|
21
|
+
|
22
|
+
## Core Concepts
|
23
|
+
|
24
|
+
Protocol::Jsonrpc has several core concepts:
|
25
|
+
|
26
|
+
- A `Protocol::Jsonrpc::Message` is the base class which represents JSON-RPC message structures
|
27
|
+
- A `Protocol::Jsonrpc::Framer` wraps an underlying stream (like a socket) for reading and writing JSON-RPC messages
|
28
|
+
- A `Protocol::Jsonrpc::Connection` wraps a framer and provides higher-level methods for communication
|
29
|
+
- Various message types including `RequestMessage`, `ResponseMessage`, `NotificationMessage`, and `ErrorMessage`
|
30
|
+
|
31
|
+
## Basic Usage
|
32
|
+
|
33
|
+
### Simple Example
|
34
|
+
|
35
|
+
Here's a basic example showing how to create a JSON-RPC connection over a socket pair:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
require 'protocol/jsonrpc'
|
39
|
+
require 'protocol/jsonrpc/connection'
|
40
|
+
require 'socket'
|
41
|
+
|
42
|
+
# Create a socket pair for testing
|
43
|
+
client_socket, server_socket = UNIXSocket.pair
|
44
|
+
|
45
|
+
# Create connections for both client and server
|
46
|
+
client = Protocol::Jsonrpc::Connection.new(Protocol::Jsonrpc::Framer.new(client_socket))
|
47
|
+
server = Protocol::Jsonrpc::Connection.new(Protocol::Jsonrpc::Framer.new(server_socket))
|
48
|
+
|
49
|
+
# Client sends a request
|
50
|
+
subtract = Protocol::Jsonrpc::RequestMessage.new(method: "subtract", params: [42, 23])
|
51
|
+
client.write(subtract)
|
52
|
+
|
53
|
+
# Server reads the request
|
54
|
+
message = server.read
|
55
|
+
# => <#Protocol::Jsonrpc::RequestMessage id:"...", method: "subtract", params: [42, 23]>
|
56
|
+
|
57
|
+
# Server processes the request (calculating the result)
|
58
|
+
result = message.params.inject(:-) if message.method == "subtract"
|
59
|
+
|
60
|
+
# Server sends a response
|
61
|
+
server.write(message.reply(result))
|
62
|
+
|
63
|
+
# Client reads the response
|
64
|
+
response = client.read
|
65
|
+
response.result # => 19
|
66
|
+
|
67
|
+
# Close connections
|
68
|
+
client.close
|
69
|
+
server.close
|
70
|
+
```
|
71
|
+
|
72
|
+
### Realistic Server Implementation
|
73
|
+
|
74
|
+
For a more realistic server implementation:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
require 'protocol/jsonrpc'
|
78
|
+
require 'protocol/jsonrpc/connection'
|
79
|
+
require 'socket'
|
80
|
+
|
81
|
+
server = TCPServer.new('localhost', 4567)
|
82
|
+
socket = server.accept
|
83
|
+
connection = Protocol::Jsonrpc::Connection.new(Protocol::Jsonrpc::Framer.new(socket))
|
84
|
+
|
85
|
+
# Simple dispatcher for handling different methods
|
86
|
+
handlers = {
|
87
|
+
"add" => ->(params) { params.sum },
|
88
|
+
"subtract" => ->(params) { params.reduce(:-) },
|
89
|
+
"multiply" => ->(params) { params.reduce(:*) },
|
90
|
+
"divide" => ->(params) { params.reduce(:/) }
|
91
|
+
}
|
92
|
+
|
93
|
+
# Track pending requests awaiting responses
|
94
|
+
pending_requests = {}
|
95
|
+
|
96
|
+
# Main server loop
|
97
|
+
begin
|
98
|
+
while (message = connection.read)
|
99
|
+
case message
|
100
|
+
when Protocol::Jsonrpc::RequestMessage
|
101
|
+
puts "Received request: #{message.method}"
|
102
|
+
|
103
|
+
if handlers.key?(message.method)
|
104
|
+
result = handlers[message.method].call(message.params)
|
105
|
+
connection.write(message.reply(result))
|
106
|
+
else
|
107
|
+
error = Protocol::Jsonrpc::MethodNotFoundError.new
|
108
|
+
connection.write(message.reply(error))
|
109
|
+
end
|
110
|
+
|
111
|
+
when Protocol::Jsonrpc::NotificationMessage
|
112
|
+
puts "Notification: #{message.method}"
|
113
|
+
# Handle notification (no response needed)
|
114
|
+
|
115
|
+
when Protocol::Jsonrpc::ResponseMessage
|
116
|
+
puts "Response: #{message.result}"
|
117
|
+
# Process response for an earlier request
|
118
|
+
request = pending_requests.delete(message.id)
|
119
|
+
request.call(message) if request
|
120
|
+
|
121
|
+
when Protocol::Jsonrpc::ErrorMessage
|
122
|
+
puts "Error: #{message.error.message}"
|
123
|
+
request = pending_requests.delete(message.id)
|
124
|
+
request.call(message) if request
|
125
|
+
end
|
126
|
+
end
|
127
|
+
rescue Errno::EPIPE, IOError => e
|
128
|
+
puts "Connection closed: #{e.message}"
|
129
|
+
ensure
|
130
|
+
connection.close
|
131
|
+
socket.close
|
132
|
+
server.close
|
133
|
+
end
|
134
|
+
```
|
135
|
+
|
136
|
+
## Message Types
|
137
|
+
|
138
|
+
### Request Message
|
139
|
+
|
140
|
+
```ruby
|
141
|
+
# Create a request with positional parameters
|
142
|
+
request = Protocol::Jsonrpc::RequestMessage.new(
|
143
|
+
method: "subtract",
|
144
|
+
params: [42, 23],
|
145
|
+
id: 1 # Optional, auto-generated if not provided
|
146
|
+
)
|
147
|
+
|
148
|
+
# Create a request with named parameters
|
149
|
+
request = Protocol::Jsonrpc::RequestMessage.new(
|
150
|
+
method: "subtract",
|
151
|
+
params: { minuend: 42, subtrahend: 23 },
|
152
|
+
id: 2
|
153
|
+
)
|
154
|
+
```
|
155
|
+
|
156
|
+
### Notification Message
|
157
|
+
|
158
|
+
Notifications are similar to requests but don't expect a response:
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
notification = Protocol::Jsonrpc::NotificationMessage.new(
|
162
|
+
method: "update",
|
163
|
+
params: [1, 2, 3, 4, 5]
|
164
|
+
)
|
165
|
+
```
|
166
|
+
|
167
|
+
### Response Message
|
168
|
+
|
169
|
+
Typically created by replying to a request:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
# From a request object
|
173
|
+
response = request.reply(19)
|
174
|
+
|
175
|
+
# Or directly
|
176
|
+
response = Protocol::Jsonrpc::ResponseMessage.new(
|
177
|
+
result: 19,
|
178
|
+
id: 1
|
179
|
+
)
|
180
|
+
```
|
181
|
+
|
182
|
+
### Error Message
|
183
|
+
|
184
|
+
For error responses:
|
185
|
+
|
186
|
+
```ruby
|
187
|
+
# Create from an error object
|
188
|
+
error = Protocol::Jsonrpc::InvalidParamsError.new("Invalid parameters")
|
189
|
+
error_response = request.reply(error)
|
190
|
+
|
191
|
+
# Standard error types include:
|
192
|
+
# - ParseError
|
193
|
+
# - InvalidRequestError
|
194
|
+
# - MethodNotFoundError
|
195
|
+
# - InvalidParamsError
|
196
|
+
# - InternalError
|
197
|
+
# - ServerError
|
198
|
+
```
|
199
|
+
|
200
|
+
## Batch Processing
|
201
|
+
|
202
|
+
JSON-RPC supports batch requests and responses:
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
batch = [
|
206
|
+
Protocol::Jsonrpc::RequestMessage.new(method: "sum", params: [1, 2, 4]),
|
207
|
+
Protocol::Jsonrpc::NotificationMessage.new(method: "notify_hello", params: [7]),
|
208
|
+
Protocol::Jsonrpc::RequestMessage.new(method: "subtract", params: [42, 23])
|
209
|
+
]
|
210
|
+
|
211
|
+
# Send batch request
|
212
|
+
client.write(batch)
|
213
|
+
|
214
|
+
# Process batch on server
|
215
|
+
messages = server.read
|
216
|
+
|
217
|
+
batch_response = messages.filter_map do |msg|
|
218
|
+
case msg
|
219
|
+
when Protocol::Jsonrpc::RequestMessage
|
220
|
+
# Only add responses for requests, not notifications
|
221
|
+
if msg.method == "sum"
|
222
|
+
msg.reply(msg.params.sum)
|
223
|
+
elsif msg.method == "subtract"
|
224
|
+
msg.reply(msg.params.reduce(:-))
|
225
|
+
else
|
226
|
+
msg.reply(Protocol::Jsonrpc::MethodNotFoundError.new)
|
227
|
+
end
|
228
|
+
when Protocol::Jsonrpc::NotificationMessage
|
229
|
+
handle_notification(msg)
|
230
|
+
nil
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Send batch response if not empty
|
235
|
+
server.write(batch_response) unless batch_response.empty?
|
236
|
+
```
|
237
|
+
|
238
|
+
## Custom Framers
|
239
|
+
|
240
|
+
The supplied Framer is designed for a bidirectional socket.
|
241
|
+
You can also supply your own framer:
|
242
|
+
|
243
|
+
```ruby
|
244
|
+
class MyFramer
|
245
|
+
def flush; end
|
246
|
+
def close; end
|
247
|
+
|
248
|
+
# Return an object that response to unpack
|
249
|
+
def read_frame
|
250
|
+
end
|
251
|
+
|
252
|
+
# Accepts a
|
253
|
+
def write_frame(frame)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
|
258
|
+
client = Protocol::Jsonrpc::Connection.new(MyFramer.new)
|
259
|
+
client.read # calls read_frame, calling unpack on the returned object
|
260
|
+
|
261
|
+
message = Protocol::Jsonrpc::NotificationMessage.new(method: "hello", params: ["world"])
|
262
|
+
client.write(message) # calls write_frame with any message responding to `as_json`
|
263
|
+
|
264
|
+
```
|
265
|
+
|
266
|
+
## Development
|
267
|
+
|
268
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
269
|
+
|
270
|
+
## Contributing
|
271
|
+
|
272
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/martinemde/protocol-jsonrpc.
|
273
|
+
|
274
|
+
## License
|
275
|
+
|
276
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require_relative "message"
|
7
|
+
|
8
|
+
module Protocol
|
9
|
+
module Jsonrpc
|
10
|
+
class Connection
|
11
|
+
# Initialize a new Connection instance
|
12
|
+
# @param framer [Protocol::Jsonrpc::Framer] Any class implementing the Framer interface
|
13
|
+
def initialize(framer)
|
14
|
+
@framer = framer
|
15
|
+
end
|
16
|
+
|
17
|
+
def flush
|
18
|
+
@framer.flush
|
19
|
+
end
|
20
|
+
|
21
|
+
def close
|
22
|
+
@framer.close
|
23
|
+
end
|
24
|
+
|
25
|
+
# Read a message from the framer
|
26
|
+
# @yield [Protocol::Jsonrpc::Message] The read message
|
27
|
+
# @return [Protocol::Jsonrpc::Message] The read message
|
28
|
+
def read(&block)
|
29
|
+
flush
|
30
|
+
frame = read_frame
|
31
|
+
message = Message.load(frame.unpack)
|
32
|
+
yield message if block_given?
|
33
|
+
message
|
34
|
+
end
|
35
|
+
|
36
|
+
# Write a message to the framer
|
37
|
+
# @param message [Protocol::Jsonrpc::Message, Array<Protocol::Jsonrpc::Message>] The message(s) to write
|
38
|
+
# @return [Boolean] True if successful
|
39
|
+
def write(message)
|
40
|
+
frame = Frame.pack(message)
|
41
|
+
write_frame(frame)
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
# Low level read a frame from the framer
|
46
|
+
# @yield [Protocol::Jsonrpc::Frame] The read frame
|
47
|
+
# @return [Protocol::Jsonrpc::Frame] The read frame
|
48
|
+
def read_frame(&)
|
49
|
+
frame = @framer.read_frame
|
50
|
+
yield frame if block_given?
|
51
|
+
frame
|
52
|
+
end
|
53
|
+
|
54
|
+
# Low level write a frame to the framer
|
55
|
+
# @param frame [Protocol::Jsonrpc::Frame] The frame to write
|
56
|
+
# @return [Boolean] True if successful
|
57
|
+
def write_frame(frame)
|
58
|
+
@framer.write_frame(frame)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require "securerandom"
|
7
|
+
require "json"
|
8
|
+
|
9
|
+
module Protocol
|
10
|
+
module Jsonrpc
|
11
|
+
class Error < StandardError
|
12
|
+
PARSE_ERROR = -32_700
|
13
|
+
INVALID_REQUEST = -32_600
|
14
|
+
METHOD_NOT_FOUND = -32_601
|
15
|
+
INVALID_PARAMS = -32_602
|
16
|
+
INTERNAL_ERROR = -32_603
|
17
|
+
|
18
|
+
MESSAGES = Hash.new("Error").merge(
|
19
|
+
PARSE_ERROR => "Parse error",
|
20
|
+
INVALID_REQUEST => "Invalid Request",
|
21
|
+
METHOD_NOT_FOUND => "Method not found",
|
22
|
+
INVALID_PARAMS => "Invalid params",
|
23
|
+
INTERNAL_ERROR => "Internal error"
|
24
|
+
).freeze
|
25
|
+
|
26
|
+
# Factory method to create the appropriate error type
|
27
|
+
# @param id [String, Integer] The request ID
|
28
|
+
# @param error [Hash] The error data from the JSON-RPC response
|
29
|
+
# @return [Error] The appropriate error instance
|
30
|
+
def self.from_message(code:, message:, data: nil, id: nil)
|
31
|
+
case code
|
32
|
+
when PARSE_ERROR
|
33
|
+
ParseError.new(message, data:, id:)
|
34
|
+
when INVALID_REQUEST
|
35
|
+
InvalidRequestError.new(message, data:, id:)
|
36
|
+
when METHOD_NOT_FOUND
|
37
|
+
MethodNotFoundError.new(message, data:, id:)
|
38
|
+
when INVALID_PARAMS
|
39
|
+
InvalidParamsError.new(message, data:, id:)
|
40
|
+
when INTERNAL_ERROR
|
41
|
+
InternalError.new(message, data:, id:)
|
42
|
+
else
|
43
|
+
new(message, data:, id:)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.wrap(error)
|
48
|
+
case error
|
49
|
+
in Hash
|
50
|
+
error = error.transform_keys(&:to_sym)
|
51
|
+
from_message(**error)
|
52
|
+
in Jsonrpc::Error
|
53
|
+
error
|
54
|
+
in JSON::ParserError
|
55
|
+
ParseError.new(error.message, data: error)
|
56
|
+
in StandardError
|
57
|
+
InternalError.new(error.message, data: error)
|
58
|
+
else
|
59
|
+
raise error
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
attr_reader :data, :code, :id
|
64
|
+
|
65
|
+
def initialize(message = nil, data: nil, id: nil)
|
66
|
+
message = nil if message&.empty?
|
67
|
+
super([MESSAGES[code], message].compact.uniq.join(": "))
|
68
|
+
@data = data
|
69
|
+
@id = id
|
70
|
+
end
|
71
|
+
|
72
|
+
def reply(id: @id)
|
73
|
+
ErrorMessage.new(id:, error: self)
|
74
|
+
end
|
75
|
+
|
76
|
+
def to_h
|
77
|
+
h = {code:, message:}
|
78
|
+
h[:data] = data if data
|
79
|
+
h
|
80
|
+
end
|
81
|
+
|
82
|
+
def [](key)
|
83
|
+
case key
|
84
|
+
when :code
|
85
|
+
code
|
86
|
+
when :message
|
87
|
+
message
|
88
|
+
when :data
|
89
|
+
data
|
90
|
+
else
|
91
|
+
raise KeyError, "Invalid key: #{key}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Error raised when a JSON-RPC parse error is received from the server
|
97
|
+
# Raised when error code is -32700
|
98
|
+
class ParseError < Error
|
99
|
+
def code
|
100
|
+
PARSE_ERROR
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Error raised when a JSON-RPC invalid request error is received from the server
|
105
|
+
# Raised when error code is -32600
|
106
|
+
class InvalidRequestError < Error
|
107
|
+
def code
|
108
|
+
INVALID_REQUEST
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Error raised when a JSON-RPC method not found error is received from the server
|
113
|
+
# Raised when error code is -32601
|
114
|
+
class MethodNotFoundError < Error
|
115
|
+
def code
|
116
|
+
METHOD_NOT_FOUND
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Error raised when a JSON-RPC invalid params error is received from the server
|
121
|
+
# Raised when error code is -32602
|
122
|
+
class InvalidParamsError < Error
|
123
|
+
def code
|
124
|
+
INVALID_PARAMS
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Error raised when a JSON-RPC internal error is received from the server
|
129
|
+
# Raised when error code is -32603
|
130
|
+
class InternalError < Error
|
131
|
+
def code
|
132
|
+
INTERNAL_ERROR
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require_relative "message"
|
7
|
+
|
8
|
+
module Protocol
|
9
|
+
module Jsonrpc
|
10
|
+
ErrorMessage = Data.define(:id, :error) do
|
11
|
+
include Message
|
12
|
+
|
13
|
+
def initialize(id:, error:)
|
14
|
+
unless id.nil? || id.is_a?(String) || id.is_a?(Numeric)
|
15
|
+
raise InvalidRequestError.new("ID must be nil, string or number", id: id)
|
16
|
+
end
|
17
|
+
|
18
|
+
error = Error.wrap(error)
|
19
|
+
|
20
|
+
super
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_h = super.merge(id:, error: error.to_h)
|
24
|
+
|
25
|
+
def response? = true
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require "json"
|
7
|
+
require_relative "error"
|
8
|
+
|
9
|
+
module Protocol
|
10
|
+
module Jsonrpc
|
11
|
+
# Frame represents the raw JSON data structure of a JSON-RPC message
|
12
|
+
# before it's validated and converted into a proper Message object.
|
13
|
+
# This handles the parsing of JSON and initial structure validation.
|
14
|
+
Frame = Data.define(:json) do
|
15
|
+
# Read a frame from the stream
|
16
|
+
# @param stream [IO] The stream to read from
|
17
|
+
# @return [Frame, nil] The parsed frame or nil if the stream is empty
|
18
|
+
def self.read(stream)
|
19
|
+
json = stream.gets
|
20
|
+
return nil if json.nil?
|
21
|
+
new(json:)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Unpack the json into a JSON object
|
25
|
+
# @return [Hash] The parsed JSON object
|
26
|
+
def unpack
|
27
|
+
JSON.parse(json, symbolize_names: true)
|
28
|
+
rescue JSON::ParserError => e
|
29
|
+
raise ParseError.new("Failed to parse message: #{e.message}", data: json)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.pack(message)
|
33
|
+
case message
|
34
|
+
when Array
|
35
|
+
new(json: message.map { |msg| msg.is_a?(Message) ? msg.as_json : msg }.to_json)
|
36
|
+
when Hash, Message
|
37
|
+
new(json: message.to_json)
|
38
|
+
else
|
39
|
+
raise ArgumentError, "Invalid message type: #{message.class}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def write(stream)
|
44
|
+
stream.write("#{json}\n")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require "protocol/jsonrpc/frame"
|
7
|
+
|
8
|
+
module Protocol
|
9
|
+
module Jsonrpc
|
10
|
+
class Framer
|
11
|
+
def initialize(stream, frame_class: Frame)
|
12
|
+
@stream = stream
|
13
|
+
@frame_class = frame_class
|
14
|
+
end
|
15
|
+
|
16
|
+
# Read a frame from the stream
|
17
|
+
# @return [Frame] The parsed frame
|
18
|
+
def read_frame(&block)
|
19
|
+
frame = @frame_class.read(@stream)
|
20
|
+
yield frame if block_given?
|
21
|
+
frame
|
22
|
+
end
|
23
|
+
|
24
|
+
# Write a frame to the stream
|
25
|
+
# @param frame [Frame] The frame to write
|
26
|
+
def write_frame(frame)
|
27
|
+
frame.write(@stream)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Flush the stream
|
31
|
+
def flush
|
32
|
+
@stream.flush
|
33
|
+
end
|
34
|
+
|
35
|
+
# Close the stream
|
36
|
+
def close
|
37
|
+
@stream.close
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require "json"
|
7
|
+
require "securerandom"
|
8
|
+
require "timeout"
|
9
|
+
require_relative "error"
|
10
|
+
|
11
|
+
module Protocol
|
12
|
+
module Jsonrpc
|
13
|
+
# JsonrpcMessage provides stateless operations for creating and validating JSON-RPC messages.
|
14
|
+
# This class handles the pure functional aspects of JSON-RPC like:
|
15
|
+
# - Creating properly formatted request/notification messages
|
16
|
+
# - Validating incoming messages
|
17
|
+
# - Parsing responses and errors
|
18
|
+
module Message
|
19
|
+
JSONRPC_VERSION = "2.0"
|
20
|
+
|
21
|
+
class << self
|
22
|
+
# Validate, and return the JSON-RPC message or batch
|
23
|
+
# @param data [Hash, Array] The parsed message
|
24
|
+
# @return [Message, Array<Message>] The parsed message or batch
|
25
|
+
# @raise [ParseError] If the message cannot be parsed
|
26
|
+
# @raise [InvalidRequestError] If the message is invalid
|
27
|
+
def load(data)
|
28
|
+
case data
|
29
|
+
when Hash
|
30
|
+
from_hash(data)
|
31
|
+
when Array
|
32
|
+
from_array(data)
|
33
|
+
else
|
34
|
+
raise InvalidRequestError.new("Invalid request object", data: data.inspect)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# This is wrong. It seems like we need something more like
|
39
|
+
# an enumerator where we can run the full parse, handle, response
|
40
|
+
# cycle for each item in the array, aggregating the results and
|
41
|
+
# errors and then returning the array.
|
42
|
+
#
|
43
|
+
# The problem is that the errors should get raised and then
|
44
|
+
# handled which turns them into ErrorMessages and then the errors
|
45
|
+
# get returned to the client.
|
46
|
+
#
|
47
|
+
# Ideally this array handling would be invisible to the connection
|
48
|
+
# which would just handle one at a time with the array wrapper
|
49
|
+
# being applied by a single place that handles the batching.
|
50
|
+
def from_array(array)
|
51
|
+
raise InvalidRequestError.new("Empty batch", data: array.inspect) if array.empty?
|
52
|
+
|
53
|
+
array.map do |msg|
|
54
|
+
from_hash(msg)
|
55
|
+
rescue => e
|
56
|
+
Error.wrap(e)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# @param parsed [Hash] The parsed message
|
61
|
+
# @return [Message] The parsed message
|
62
|
+
def from_hash(parsed)
|
63
|
+
raise InvalidRequestError.new("Request is not an object", data: parsed) unless parsed.is_a?(Hash)
|
64
|
+
raise InvalidRequestError.new("Unexpected JSON-RPC version", data: parsed) unless parsed[:jsonrpc] == JSONRPC_VERSION
|
65
|
+
|
66
|
+
case parsed
|
67
|
+
in { id:, error: }
|
68
|
+
ErrorMessage.new(id:, error: Error.from_message(**error))
|
69
|
+
in { id:, result: }
|
70
|
+
ResponseMessage.new(id:, result:)
|
71
|
+
in { id:, method: }
|
72
|
+
RequestMessage.new(id:, method:, params: parsed[:params])
|
73
|
+
in { method: }
|
74
|
+
NotificationMessage.new(method:, params: parsed[:params])
|
75
|
+
else
|
76
|
+
raise ParseError.new("Unknown message: #{parsed.inspect}", data: parsed)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def to_h = {jsonrpc: JSONRPC_VERSION}
|
82
|
+
|
83
|
+
def to_hash = to_h
|
84
|
+
|
85
|
+
def as_json = to_h
|
86
|
+
|
87
|
+
def to_json(...) = JSON.generate(as_json, ...)
|
88
|
+
|
89
|
+
def to_s = to_json
|
90
|
+
|
91
|
+
def response? = false
|
92
|
+
|
93
|
+
def match?(message) = false
|
94
|
+
|
95
|
+
def reply(result_or_error)
|
96
|
+
if result_or_error.is_a?(StandardError)
|
97
|
+
ErrorMessage.new(id:, error: result_or_error)
|
98
|
+
else
|
99
|
+
ResponseMessage.new(id:, result: result_or_error)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require_relative "message"
|
7
|
+
|
8
|
+
module Protocol
|
9
|
+
module Jsonrpc
|
10
|
+
NotificationMessage = Data.define(:method, :params) do
|
11
|
+
include Message
|
12
|
+
|
13
|
+
def initialize(method:, params: nil)
|
14
|
+
super
|
15
|
+
|
16
|
+
unless method.is_a?(String)
|
17
|
+
raise InvalidRequestError.new("Method must be a string", data: method.inspect)
|
18
|
+
end
|
19
|
+
unless params.nil? || params.is_a?(Array) || params.is_a?(Hash)
|
20
|
+
raise InvalidRequestError.new("Params must be an array or object", data: params.inspect)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_h
|
25
|
+
h = super.merge(method:)
|
26
|
+
h[:params] = params if params
|
27
|
+
h
|
28
|
+
end
|
29
|
+
|
30
|
+
def id = nil
|
31
|
+
|
32
|
+
def reply = nil
|
33
|
+
|
34
|
+
def response? = false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require "securerandom"
|
7
|
+
require_relative "message"
|
8
|
+
|
9
|
+
module Protocol
|
10
|
+
module Jsonrpc
|
11
|
+
RequestMessage = Data.define(:method, :params, :id) do
|
12
|
+
include Message
|
13
|
+
|
14
|
+
def initialize(method:, params: nil, id: SecureRandom.uuid)
|
15
|
+
unless method.is_a?(String)
|
16
|
+
raise InvalidRequestError.new("Method must be a string", data: method.inspect)
|
17
|
+
end
|
18
|
+
unless params.nil? || params.is_a?(Array) || params.is_a?(Hash)
|
19
|
+
raise InvalidRequestError.new("Params must be an array or object", data: params.inspect)
|
20
|
+
end
|
21
|
+
unless id.is_a?(String) || id.is_a?(Numeric)
|
22
|
+
raise InvalidRequestError.new("ID must be a string or number", id:)
|
23
|
+
end
|
24
|
+
|
25
|
+
super
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_h
|
29
|
+
h = super.merge(id:, method:)
|
30
|
+
h[:params] = params if params
|
31
|
+
h
|
32
|
+
end
|
33
|
+
|
34
|
+
def response? = false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require_relative "message"
|
7
|
+
|
8
|
+
module Protocol
|
9
|
+
module Jsonrpc
|
10
|
+
ResponseMessage = Data.define(:id, :result) do
|
11
|
+
include Message
|
12
|
+
|
13
|
+
def initialize(id:, result:)
|
14
|
+
unless id.nil? || id.is_a?(String) || id.is_a?(Numeric)
|
15
|
+
raise InvalidRequestError.new("ID must be nil, string or number", id:)
|
16
|
+
end
|
17
|
+
|
18
|
+
super
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_h = super.merge(id:, result:)
|
22
|
+
|
23
|
+
def response? = true
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
require_relative "jsonrpc/version"
|
7
|
+
require_relative "jsonrpc/error"
|
8
|
+
require_relative "jsonrpc/message"
|
9
|
+
require_relative "jsonrpc/connection"
|
10
|
+
require_relative "jsonrpc/error_message"
|
11
|
+
require_relative "jsonrpc/notification_message"
|
12
|
+
require_relative "jsonrpc/request_message"
|
13
|
+
require_relative "jsonrpc/response_message"
|
14
|
+
|
15
|
+
module Protocol
|
16
|
+
module Jsonrpc
|
17
|
+
end
|
18
|
+
end
|
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: protocol-jsonrpc
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Martin Emde
|
8
|
+
bindir: exe
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: json
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '2.10'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '2.10'
|
26
|
+
description: JSON-RPC 2.0 protocol implementation
|
27
|
+
email:
|
28
|
+
- me@martinemde.com
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- CHANGELOG.md
|
34
|
+
- LICENSE.txt
|
35
|
+
- README.md
|
36
|
+
- lib/protocol/jsonrpc.rb
|
37
|
+
- lib/protocol/jsonrpc/connection.rb
|
38
|
+
- lib/protocol/jsonrpc/error.rb
|
39
|
+
- lib/protocol/jsonrpc/error_message.rb
|
40
|
+
- lib/protocol/jsonrpc/frame.rb
|
41
|
+
- lib/protocol/jsonrpc/framer.rb
|
42
|
+
- lib/protocol/jsonrpc/message.rb
|
43
|
+
- lib/protocol/jsonrpc/notification_message.rb
|
44
|
+
- lib/protocol/jsonrpc/request_message.rb
|
45
|
+
- lib/protocol/jsonrpc/response_message.rb
|
46
|
+
- lib/protocol/jsonrpc/version.rb
|
47
|
+
- sig/protocol/jsonrpc.rbs
|
48
|
+
homepage: https://github.com/martinemde/protocol-jsonrpc
|
49
|
+
licenses:
|
50
|
+
- MIT
|
51
|
+
metadata:
|
52
|
+
allowed_push_host: https://rubygems.org
|
53
|
+
homepage_uri: https://github.com/martinemde/protocol-jsonrpc
|
54
|
+
source_code_uri: https://github.com/martinemde/protocol-jsonrpc
|
55
|
+
changelog_uri: https://github.com/martinemde/protocol-jsonrpc/blob/main/CHANGELOG.md
|
56
|
+
rdoc_options: []
|
57
|
+
require_paths:
|
58
|
+
- lib
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ">="
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: 3.1.0
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
requirements: []
|
70
|
+
rubygems_version: 3.6.7
|
71
|
+
specification_version: 4
|
72
|
+
summary: JSON-RPC 2.0 protocol implementation
|
73
|
+
test_files: []
|