protocol-jsonrpc 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +110 -78
- data/lib/protocol/jsonrpc/batch.rb +45 -0
- data/lib/protocol/jsonrpc/connection.rb +8 -12
- data/lib/protocol/jsonrpc/error.rb +12 -7
- data/lib/protocol/jsonrpc/{error_message.rb → error_response.rb} +5 -5
- data/lib/protocol/jsonrpc/frame.rb +41 -25
- data/lib/protocol/jsonrpc/invalid_message.rb +33 -0
- data/lib/protocol/jsonrpc/message.rb +23 -47
- data/lib/protocol/jsonrpc/{notification_message.rb → notification.rb} +12 -8
- data/lib/protocol/jsonrpc/{request_message.rb → request.rb} +25 -6
- data/lib/protocol/jsonrpc/{response_message.rb → response.rb} +3 -7
- data/lib/protocol/jsonrpc/version.rb +1 -1
- data/lib/protocol/jsonrpc.rb +14 -9
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '0488651e9e3c97f90a8bfe83c904ade78a8e0532e524fc6b1e97fd7ec2334f0f'
|
4
|
+
data.tar.gz: 2d9c402bcd8786051868b545afdb858ca862fcd4e699e6910a720467b4d0ab3d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 32a5eaf0c2424ce7a93250a7d164fb98a856eb918422481de1d7348906edbd9817cd87d32f55c1eeef3338c9ff7c531329cdd98bad5512254e4c121ae5442b0f
|
7
|
+
data.tar.gz: be8901fb47e177223a99175999b3e25798c0709505f558442020cca36d24da59e970114cefdbb907874005fac1c241360a78134e7bba2ca49869fbde3182cab2
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,13 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.2.0] - 2025-06-01
|
4
|
+
|
5
|
+
**Breaking changes**: As I work towarsd a 1.0.0 release, I've changed the interface to ensure a uniform interface for [JSON-RPC 2.0 batch processing](https://www.jsonrpc.org/specification#batch). I believe we have a much more robust implementation now, so I will try to stay more consistent, but please be cautious upgrading until 1.0.0 is released and we finalize the interface.
|
6
|
+
|
7
|
+
- Adds full support for batch processing with uniform reply block interface.
|
8
|
+
- Better error handling, though this is my biggest area of improvement.
|
9
|
+
- InvalidMessage is now returned when a message is invalid, allowing the receiver to inspect and handle the message, but still benefiting from automatic replies.
|
10
|
+
|
3
11
|
## [0.1.0] - 2025-04-20
|
4
12
|
|
5
13
|
- Initial release
|
data/README.md
CHANGED
@@ -23,10 +23,27 @@ gem install protocol-jsonrpc
|
|
23
23
|
|
24
24
|
Protocol::Jsonrpc has several core concepts:
|
25
25
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
26
|
+
### `Protocol::Jsonrpc::Message`
|
27
|
+
|
28
|
+
This is the type which represents JSON-RPC message structures.
|
29
|
+
|
30
|
+
Each of the 4 JSONRPC message types has their own class which include the `Message` module.
|
31
|
+
|
32
|
+
- `Protocol::Jsonrpc::ErrorResponse`
|
33
|
+
- `Protocol::Jsonrpc::Notification`
|
34
|
+
- `Protocol::Jsonrpc::Request`
|
35
|
+
- `Protocol::Jsonrpc::Response`
|
36
|
+
|
37
|
+
### `Protocol::Jsonrpc::Framer`
|
38
|
+
|
39
|
+
This provides one implementation for an object that splits JSONRPC messages off of some sort of socket or communication layer.
|
40
|
+
|
41
|
+
The provided implementation wraps an underlying bi-directional stream (like a unixsocket) for reading and writing JSON-RPC messages.
|
42
|
+
|
43
|
+
### `Protocol::Jsonrpc::Connection`
|
44
|
+
|
45
|
+
This wraps a framer and provides higher-level methods for communication.
|
46
|
+
|
30
47
|
|
31
48
|
## Basic Usage
|
32
49
|
|
@@ -37,6 +54,7 @@ Here's a basic example showing how to create a JSON-RPC connection over a socket
|
|
37
54
|
```ruby
|
38
55
|
require 'protocol/jsonrpc'
|
39
56
|
require 'protocol/jsonrpc/connection'
|
57
|
+
require 'protocol/jsonrpc/framer'
|
40
58
|
require 'socket'
|
41
59
|
|
42
60
|
# Create a socket pair for testing
|
@@ -47,12 +65,12 @@ client = Protocol::Jsonrpc::Connection.new(Protocol::Jsonrpc::Framer.new(client_
|
|
47
65
|
server = Protocol::Jsonrpc::Connection.new(Protocol::Jsonrpc::Framer.new(server_socket))
|
48
66
|
|
49
67
|
# Client sends a request
|
50
|
-
subtract = Protocol::Jsonrpc::
|
68
|
+
subtract = Protocol::Jsonrpc::Request.new(method: "subtract", params: [42, 23])
|
51
69
|
client.write(subtract)
|
52
70
|
|
53
71
|
# Server reads the request
|
54
72
|
message = server.read
|
55
|
-
# => <#Protocol::Jsonrpc::
|
73
|
+
# => <#Protocol::Jsonrpc::Request id:"...", method: "subtract", params: [42, 23]>
|
56
74
|
|
57
75
|
# Server processes the request (calculating the result)
|
58
76
|
result = message.params.inject(:-) if message.method == "subtract"
|
@@ -69,13 +87,14 @@ client.close
|
|
69
87
|
server.close
|
70
88
|
```
|
71
89
|
|
72
|
-
###
|
90
|
+
### Server Implementation
|
73
91
|
|
74
|
-
|
92
|
+
Here's a server implementation showing how to handle different message types:
|
75
93
|
|
76
94
|
```ruby
|
77
95
|
require 'protocol/jsonrpc'
|
78
96
|
require 'protocol/jsonrpc/connection'
|
97
|
+
require 'protocol/jsonrpc/framer'
|
79
98
|
require 'socket'
|
80
99
|
|
81
100
|
server = TCPServer.new('localhost', 4567)
|
@@ -90,39 +109,27 @@ handlers = {
|
|
90
109
|
"divide" => ->(params) { params.reduce(:/) }
|
91
110
|
}
|
92
111
|
|
93
|
-
|
94
|
-
|
112
|
+
def handle_request(method, params)
|
113
|
+
puts "Received request: #{method}"
|
114
|
+
if handlers.key?(method)
|
115
|
+
handlers[method].call(params)
|
116
|
+
else
|
117
|
+
raise Protocol::Jsonrpc::MethodNotFoundError.new
|
118
|
+
end
|
119
|
+
end
|
95
120
|
|
96
121
|
# Main server loop
|
97
122
|
begin
|
98
123
|
while (message = connection.read)
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
connection.write(message.reply(result))
|
106
|
-
else
|
107
|
-
error = Protocol::Jsonrpc::MethodNotFoundError.new
|
108
|
-
connection.write(message.reply(error))
|
124
|
+
response = message.reply do |message|
|
125
|
+
if message.request?
|
126
|
+
handle_request(message.method, message.params)
|
127
|
+
elsif message.notification?
|
128
|
+
puts "Notification: #{message.method}"
|
129
|
+
# Handle notification (no response needed)
|
109
130
|
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
131
|
end
|
132
|
+
connection.write(response)
|
126
133
|
end
|
127
134
|
rescue Errno::EPIPE, IOError => e
|
128
135
|
puts "Connection closed: #{e.message}"
|
@@ -139,14 +146,14 @@ end
|
|
139
146
|
|
140
147
|
```ruby
|
141
148
|
# Create a request with positional parameters
|
142
|
-
request = Protocol::Jsonrpc::
|
149
|
+
request = Protocol::Jsonrpc::Request.new(
|
143
150
|
method: "subtract",
|
144
151
|
params: [42, 23],
|
145
152
|
id: 1 # Optional, auto-generated if not provided
|
146
153
|
)
|
147
154
|
|
148
155
|
# Create a request with named parameters
|
149
|
-
request = Protocol::Jsonrpc::
|
156
|
+
request = Protocol::Jsonrpc::Request.new(
|
150
157
|
method: "subtract",
|
151
158
|
params: { minuend: 42, subtrahend: 23 },
|
152
159
|
id: 2
|
@@ -158,9 +165,9 @@ request = Protocol::Jsonrpc::RequestMessage.new(
|
|
158
165
|
Notifications are similar to requests but don't expect a response:
|
159
166
|
|
160
167
|
```ruby
|
161
|
-
notification = Protocol::Jsonrpc::
|
168
|
+
notification = Protocol::Jsonrpc::Notification.new(
|
162
169
|
method: "update",
|
163
|
-
params:
|
170
|
+
params: { a: 1 }
|
164
171
|
)
|
165
172
|
```
|
166
173
|
|
@@ -173,7 +180,7 @@ Typically created by replying to a request:
|
|
173
180
|
response = request.reply(19)
|
174
181
|
|
175
182
|
# Or directly
|
176
|
-
response = Protocol::Jsonrpc::
|
183
|
+
response = Protocol::Jsonrpc::Response.new(
|
177
184
|
result: 19,
|
178
185
|
id: 1
|
179
186
|
)
|
@@ -187,80 +194,105 @@ For error responses:
|
|
187
194
|
# Create from an error object
|
188
195
|
error = Protocol::Jsonrpc::InvalidParamsError.new("Invalid parameters")
|
189
196
|
error_response = request.reply(error)
|
197
|
+
```
|
190
198
|
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
199
|
+
Error types represent the standard JSON-RPC error codes:
|
200
|
+
|
201
|
+
```ruby
|
202
|
+
Protocol::Jsonrpc::ParseError
|
203
|
+
Protocol::Jsonrpc::InvalidRequestError
|
204
|
+
Protocol::Jsonrpc::MethodNotFoundError
|
205
|
+
Protocol::Jsonrpc::InvalidParamsError
|
206
|
+
Protocol::Jsonrpc::InternalError
|
198
207
|
```
|
199
208
|
|
200
209
|
## Batch Processing
|
201
210
|
|
202
|
-
JSON-RPC supports batch requests and responses:
|
211
|
+
JSON-RPC supports batch requests and responses. The library returns a `Protocol::Jsonrpc::Batch` that acts like an array and provides a `reply` method for processing:
|
203
212
|
|
204
213
|
```ruby
|
214
|
+
# Send a batch request (client side)
|
205
215
|
batch = [
|
206
|
-
Protocol::Jsonrpc::
|
207
|
-
Protocol::Jsonrpc::
|
208
|
-
Protocol::Jsonrpc::
|
216
|
+
Protocol::Jsonrpc::Request.new(method: "sum", params: [1, 2, 4]),
|
217
|
+
Protocol::Jsonrpc::Notification.new(method: "notify_hello", params: [7]),
|
218
|
+
Protocol::Jsonrpc::Request.new(method: "subtract", params: [42, 23])
|
209
219
|
]
|
210
220
|
|
211
|
-
# Send batch request
|
212
221
|
client.write(batch)
|
213
222
|
|
214
223
|
# Process batch on server
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
224
|
+
batch = server.read
|
225
|
+
|
226
|
+
# Process each message in the batch using reply
|
227
|
+
batch_response = batch.reply do |message|
|
228
|
+
case message
|
229
|
+
when Protocol::Jsonrpc::Request
|
230
|
+
# Handle request and return result
|
231
|
+
if message.method == "sum"
|
232
|
+
message.params.sum
|
233
|
+
elsif message.method == "subtract"
|
234
|
+
message.params.reduce(:-)
|
225
235
|
else
|
226
|
-
|
236
|
+
# raising during a reply block will automatically respond with an error
|
237
|
+
raise Protocol::Jsonrpc::MethodNotFoundError.new
|
227
238
|
end
|
228
|
-
when Protocol::Jsonrpc::
|
229
|
-
|
230
|
-
|
239
|
+
when Protocol::Jsonrpc::Notification
|
240
|
+
# Handle notification (return value is ignored)
|
241
|
+
handle_notification(message)
|
231
242
|
end
|
232
243
|
end
|
233
244
|
|
234
|
-
# Send batch response
|
235
|
-
server.write(batch_response)
|
245
|
+
# Send batch response (automatically includes responses for requests, not notifications)
|
246
|
+
server.write(batch_response)
|
236
247
|
```
|
237
248
|
|
249
|
+
Batch processing supports;
|
250
|
+
|
251
|
+
1. Consistent interface (`reply`) for both single and batch requests
|
252
|
+
2. Automatic error handling and response collection
|
253
|
+
3. Filters out nil responses (from notifications) automatically
|
254
|
+
4. Maintains protocol compliance by only responding to requests and handling malformed batches
|
255
|
+
|
238
256
|
## Custom Framers
|
239
257
|
|
240
258
|
The supplied Framer is designed for a bidirectional socket.
|
241
|
-
You can also supply your own framer:
|
259
|
+
You can also supply your own framer by implementing the following interface:
|
242
260
|
|
243
261
|
```ruby
|
244
262
|
class MyFramer
|
245
|
-
|
246
|
-
def close; end
|
247
|
-
|
248
|
-
# Return an object that response to unpack
|
263
|
+
# Return a Frame object that contains the raw JSON
|
249
264
|
def read_frame
|
265
|
+
# Read JSON data from your source (e.g., HTTP body, WebSocket, etc.)
|
266
|
+
raw_json = get_json_line_from_somewhere
|
267
|
+
|
268
|
+
# Return a Frame object
|
269
|
+
Protocol::Jsonrpc::Frame.new(raw_json: raw_json)
|
250
270
|
end
|
251
271
|
|
252
|
-
#
|
272
|
+
# Write a Frame object
|
253
273
|
def write_frame(frame)
|
274
|
+
# frame.raw_json contains the JSON string to send
|
275
|
+
send_json_somewhere(frame.raw_json)
|
276
|
+
end
|
277
|
+
|
278
|
+
# Flush any buffered data
|
279
|
+
def flush
|
280
|
+
# Implementation depends on your transport
|
254
281
|
end
|
255
|
-
end
|
256
282
|
|
283
|
+
# Close the connection
|
284
|
+
def close
|
285
|
+
# Clean up resources
|
286
|
+
end
|
287
|
+
end
|
257
288
|
|
258
289
|
client = Protocol::Jsonrpc::Connection.new(MyFramer.new)
|
259
|
-
client.read # calls read_frame, calling unpack on the returned object
|
260
290
|
|
261
|
-
|
262
|
-
|
291
|
+
# Read messages (calls framer.read_frame and unpacks the JSON)
|
292
|
+
message = client.read
|
263
293
|
|
294
|
+
# Write messages (packs to JSON and calls framer.write_frame)
|
295
|
+
client.write(Protocol::Jsonrpc::Notification.new(method: "hello", params: ["world"]))
|
264
296
|
```
|
265
297
|
|
266
298
|
## Development
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
module Protocol
|
7
|
+
module Jsonrpc
|
8
|
+
Batch = Data.define(:messages) do
|
9
|
+
def self.load(data)
|
10
|
+
return InvalidMessage.new(data: data.inspect) if data.empty?
|
11
|
+
|
12
|
+
messages = data.map { |message| Message.load(message) }
|
13
|
+
new(messages)
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_a = messages
|
17
|
+
alias_method :to_ary, :to_a
|
18
|
+
|
19
|
+
def as_json = to_a.map(&:as_json)
|
20
|
+
|
21
|
+
def to_json(...) = JSON.generate(to_a.map(&:as_json), ...)
|
22
|
+
alias_method :to_s, :to_json
|
23
|
+
|
24
|
+
def reply(&block)
|
25
|
+
to_a.filter_map do |message|
|
26
|
+
message.reply(&block)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def method_missing(method, *args, **kwargs, &block)
|
33
|
+
if messages.respond_to?(method)
|
34
|
+
messages.send(method, *args, **kwargs, &block)
|
35
|
+
else
|
36
|
+
super
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def respond_to_missing?(method, include_private = false)
|
41
|
+
messages.respond_to?(method, include_private) || super
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -3,8 +3,6 @@
|
|
3
3
|
# Released under the MIT License.
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
5
|
|
6
|
-
require_relative "message"
|
7
|
-
|
8
6
|
module Protocol
|
9
7
|
module Jsonrpc
|
10
8
|
class Connection
|
@@ -22,24 +20,22 @@ module Protocol
|
|
22
20
|
@framer.close
|
23
21
|
end
|
24
22
|
|
25
|
-
# Read
|
26
|
-
# @yield [Protocol::Jsonrpc::Message]
|
27
|
-
# @return [Protocol::Jsonrpc::Message] The
|
23
|
+
# Read the next message or batch of messages from the framer
|
24
|
+
# @yield [Protocol::Jsonrpc::Message] Each message is yielded to the block
|
25
|
+
# @return [Protocol::Jsonrpc::Message, Protocol::Jsonrpc::Batch] The message or batch of messages
|
28
26
|
def read(&block)
|
29
27
|
flush
|
30
28
|
frame = read_frame
|
31
|
-
|
32
|
-
|
33
|
-
|
29
|
+
Message.load(frame.unpack)
|
30
|
+
rescue => e
|
31
|
+
InvalidMessage.new(error: e)
|
34
32
|
end
|
35
33
|
|
36
34
|
# Write a message to the framer
|
37
|
-
# @param message [Protocol::Jsonrpc::Message, Array<Protocol::Jsonrpc::Message
|
35
|
+
# @param message [Protocol::Jsonrpc::Message, Array<Protocol::Jsonrpc::Message>, Batch] The message(s) to write
|
38
36
|
# @return [Boolean] True if successful
|
39
37
|
def write(message)
|
40
|
-
|
41
|
-
write_frame(frame)
|
42
|
-
true
|
38
|
+
write_frame Frame.pack(message)
|
43
39
|
end
|
44
40
|
|
45
41
|
# Low level read a frame from the framer
|
@@ -15,7 +15,7 @@ module Protocol
|
|
15
15
|
INVALID_PARAMS = -32_602
|
16
16
|
INTERNAL_ERROR = -32_603
|
17
17
|
|
18
|
-
|
18
|
+
ERROR_MESSAGES = Hash.new("Error").merge(
|
19
19
|
PARSE_ERROR => "Parse error",
|
20
20
|
INVALID_REQUEST => "Invalid Request",
|
21
21
|
METHOD_NOT_FOUND => "Method not found",
|
@@ -44,17 +44,21 @@ module Protocol
|
|
44
44
|
end
|
45
45
|
end
|
46
46
|
|
47
|
-
def self.wrap(error)
|
47
|
+
def self.wrap(error, data: nil, id: nil)
|
48
48
|
case error
|
49
|
+
in nil
|
50
|
+
InvalidRequestError.new(data:, id:)
|
51
|
+
in String
|
52
|
+
InternalError.new(error, data:, id:)
|
49
53
|
in Hash
|
50
54
|
error = error.transform_keys(&:to_sym)
|
51
|
-
from_message(**error)
|
55
|
+
from_message(id: id, data: data, **error)
|
52
56
|
in Jsonrpc::Error
|
53
57
|
error
|
54
58
|
in JSON::ParserError
|
55
|
-
ParseError.new(error.message, data:
|
59
|
+
ParseError.new("Parse error: #{error.message}", data:, id:)
|
56
60
|
in StandardError
|
57
|
-
InternalError.new(error.message, data:
|
61
|
+
InternalError.new(error.message, data:, id:)
|
58
62
|
else
|
59
63
|
raise error
|
60
64
|
end
|
@@ -64,13 +68,14 @@ module Protocol
|
|
64
68
|
|
65
69
|
def initialize(message = nil, data: nil, id: nil)
|
66
70
|
message = nil if message&.empty?
|
67
|
-
|
71
|
+
message ||= ERROR_MESSAGES[code]
|
72
|
+
super(message)
|
68
73
|
@data = data
|
69
74
|
@id = id
|
70
75
|
end
|
71
76
|
|
72
77
|
def reply(id: @id)
|
73
|
-
|
78
|
+
ErrorResponse.new(id:, error: self)
|
74
79
|
end
|
75
80
|
|
76
81
|
def to_h
|
@@ -3,14 +3,12 @@
|
|
3
3
|
# Released under the MIT License.
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
5
|
|
6
|
-
require_relative "message"
|
7
|
-
|
8
6
|
module Protocol
|
9
7
|
module Jsonrpc
|
10
|
-
|
8
|
+
ErrorResponse = Data.define(:id, :error, :jsonrpc) do
|
11
9
|
include Message
|
12
10
|
|
13
|
-
def initialize(id:, error:)
|
11
|
+
def initialize(id:, error:, jsonrpc: JSONRPC_VERSION)
|
14
12
|
unless id.nil? || id.is_a?(String) || id.is_a?(Numeric)
|
15
13
|
raise InvalidRequestError.new("ID must be nil, string or number", id: id)
|
16
14
|
end
|
@@ -20,7 +18,9 @@ module Protocol
|
|
20
18
|
super
|
21
19
|
end
|
22
20
|
|
23
|
-
def to_h = super.merge(
|
21
|
+
def to_h = super.merge(error: error.to_h)
|
22
|
+
|
23
|
+
def error? = true
|
24
24
|
|
25
25
|
def response? = true
|
26
26
|
end
|
@@ -4,44 +4,60 @@
|
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
5
|
|
6
6
|
require "json"
|
7
|
-
require_relative "error"
|
8
7
|
|
9
8
|
module Protocol
|
10
9
|
module Jsonrpc
|
11
10
|
# Frame represents the raw JSON data structure of a JSON-RPC message
|
12
11
|
# before it's validated and converted into a proper Message object.
|
13
|
-
#
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
12
|
+
# Handles translation between JSON strings and Ruby Hashes and
|
13
|
+
# reading and writing to a stream.
|
14
|
+
Frame = Data.define(:raw_json) do
|
15
|
+
class << self
|
16
|
+
# Read a frame from the stream
|
17
|
+
# @param stream [IO] An objects that responds to `gets` and returns a String
|
18
|
+
# @return [Frame, nil] The parsed frame or nil if the stream is empty
|
19
|
+
def read(stream)
|
20
|
+
raw_json = stream.gets
|
21
|
+
return nil if raw_json.nil?
|
22
|
+
new(raw_json: raw_json.strip)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Pack a message into a frame
|
26
|
+
# @param message [Message, Array<Message>] The message to pack
|
27
|
+
# @return [Frame] an instance that can be written to a stream
|
28
|
+
# @raise [ArgumentError] if the message is not a Message or Array of Messages
|
29
|
+
def pack(message)
|
30
|
+
if message.is_a?(Array)
|
31
|
+
new(raw_json: message.map { |msg| as_json(msg) }.to_json)
|
32
|
+
else
|
33
|
+
new(raw_json: as_json(message).to_json)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private def as_json(message)
|
38
|
+
return message if message.is_a?(Hash)
|
39
|
+
return message.as_json if message.respond_to?(:as_json)
|
40
|
+
raise ArgumentError, "Invalid message type: #{message.class}. Must be a Hash or respond to :as_json."
|
41
|
+
end
|
22
42
|
end
|
23
43
|
|
24
|
-
# Unpack the
|
44
|
+
# Unpack the raw_json into a Hash representing the JSON object
|
45
|
+
# Symbolizes the keys of the Hash.
|
25
46
|
# @return [Hash] The parsed JSON object
|
47
|
+
# @raise [ParseError] if the JSON is invalid
|
26
48
|
def unpack
|
27
|
-
JSON.parse(
|
28
|
-
rescue JSON::ParserError => e
|
29
|
-
raise ParseError.new("Failed to parse message: #{e.message}", data: json)
|
49
|
+
JSON.parse(raw_json, symbolize_names: true)
|
30
50
|
end
|
31
51
|
|
32
|
-
def
|
33
|
-
|
34
|
-
|
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
|
52
|
+
def to_json(...) = raw_json
|
53
|
+
|
54
|
+
def to_s = raw_json
|
42
55
|
|
56
|
+
# Write the frame to a stream
|
57
|
+
# @param stream [IO] The stream to write to
|
58
|
+
# @return [void]
|
43
59
|
def write(stream)
|
44
|
-
stream.write("#{
|
60
|
+
stream.write("#{raw_json}\n")
|
45
61
|
end
|
46
62
|
end
|
47
63
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Released under the MIT License.
|
4
|
+
# Copyright 2025 by Martin Emde
|
5
|
+
|
6
|
+
module Protocol
|
7
|
+
module Jsonrpc
|
8
|
+
# When the message received is not valid JSON or not a valid JSON-RPC message,
|
9
|
+
# this class is returned in place of a normal Message.
|
10
|
+
# The error that would have been raised is returned as the error.
|
11
|
+
# This simplifies batch processing because invalid messages in the batch
|
12
|
+
# can be processed as part of the batch rather than raising and interrupting
|
13
|
+
# the batch processing.
|
14
|
+
InvalidMessage = Data.define(:error, :id) do
|
15
|
+
include Message
|
16
|
+
|
17
|
+
def initialize(error: nil, data: nil, id: nil)
|
18
|
+
error = Error.wrap(error, data:, id:)
|
19
|
+
super(error:, id: error.id)
|
20
|
+
end
|
21
|
+
|
22
|
+
def invalid? = true
|
23
|
+
|
24
|
+
def as_json = raise "InvalidMessage cannot be serialized"
|
25
|
+
|
26
|
+
def reply(...) = ErrorResponse.new(id:, error:)
|
27
|
+
|
28
|
+
def to_json(...) = raise "InvalidMessage cannot be serialized"
|
29
|
+
|
30
|
+
def to_s = raise "InvalidMessage cannot be serialized"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -6,18 +6,15 @@
|
|
6
6
|
require "json"
|
7
7
|
require "securerandom"
|
8
8
|
require "timeout"
|
9
|
-
require_relative "error"
|
10
9
|
|
11
10
|
module Protocol
|
12
11
|
module Jsonrpc
|
13
|
-
#
|
12
|
+
# Protocol::Jsonrpc::Message provides operations for creating and validating JSON-RPC messages.
|
14
13
|
# This class handles the pure functional aspects of JSON-RPC like:
|
15
14
|
# - Creating properly formatted request/notification messages
|
16
15
|
# - Validating incoming messages
|
17
16
|
# - Parsing responses and errors
|
18
17
|
module Message
|
19
|
-
JSONRPC_VERSION = "2.0"
|
20
|
-
|
21
18
|
class << self
|
22
19
|
# Validate, and return the JSON-RPC message or batch
|
23
20
|
# @param data [Hash, Array] The parsed message
|
@@ -29,76 +26,55 @@ module Protocol
|
|
29
26
|
when Hash
|
30
27
|
from_hash(data)
|
31
28
|
when Array
|
32
|
-
|
29
|
+
Batch.load(data)
|
33
30
|
else
|
34
|
-
|
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)
|
31
|
+
InvalidMessage.new(data: data.inspect)
|
57
32
|
end
|
58
33
|
end
|
59
34
|
|
60
35
|
# @param parsed [Hash] The parsed message
|
61
36
|
# @return [Message] The parsed message
|
62
37
|
def from_hash(parsed)
|
63
|
-
|
64
|
-
|
38
|
+
return InvalidMessage.new(data: parsed.inspect) unless parsed.is_a?(Hash)
|
39
|
+
jsonrpc = parsed[:jsonrpc]
|
65
40
|
|
66
41
|
case parsed
|
67
42
|
in { id:, error: }
|
68
|
-
|
43
|
+
ErrorResponse.new(id:, error: Error.from_message(**error), jsonrpc:)
|
69
44
|
in { id:, result: }
|
70
|
-
|
45
|
+
Response.new(id:, result:, jsonrpc:)
|
71
46
|
in { id:, method: }
|
72
|
-
|
47
|
+
Request.new(id:, method:, params: parsed[:params], jsonrpc:)
|
73
48
|
in { method: }
|
74
|
-
|
49
|
+
Notification.new(method:, params: parsed[:params], jsonrpc:)
|
75
50
|
else
|
76
|
-
|
51
|
+
InvalidMessage.new(data: parsed.inspect)
|
77
52
|
end
|
53
|
+
rescue => error
|
54
|
+
InvalidMessage.new(error:, data: parsed.inspect)
|
78
55
|
end
|
79
56
|
end
|
80
57
|
|
81
|
-
def to_h = {jsonrpc: JSONRPC_VERSION}
|
82
|
-
|
83
|
-
def to_hash = to_h
|
84
|
-
|
85
58
|
def as_json = to_h
|
86
59
|
|
87
60
|
def to_json(...) = JSON.generate(as_json, ...)
|
88
61
|
|
89
62
|
def to_s = to_json
|
90
63
|
|
64
|
+
# Is this a request? (Request)
|
65
|
+
def request? = false
|
66
|
+
|
67
|
+
# Is this a notification? (Notification)
|
68
|
+
def notification? = false
|
69
|
+
|
70
|
+
# Is this a response to a request? (Error or Response)
|
91
71
|
def response? = false
|
92
72
|
|
93
|
-
|
73
|
+
# Is this an error response? (ErrorResponse)
|
74
|
+
def error? = false
|
94
75
|
|
95
|
-
|
96
|
-
|
97
|
-
ErrorMessage.new(id:, error: result_or_error)
|
98
|
-
else
|
99
|
-
ResponseMessage.new(id:, result: result_or_error)
|
100
|
-
end
|
101
|
-
end
|
76
|
+
# Is this an invalid message? (InvalidMessage)
|
77
|
+
def invalid? = false
|
102
78
|
end
|
103
79
|
end
|
104
80
|
end
|
@@ -3,14 +3,12 @@
|
|
3
3
|
# Released under the MIT License.
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
5
|
|
6
|
-
require_relative "message"
|
7
|
-
|
8
6
|
module Protocol
|
9
7
|
module Jsonrpc
|
10
|
-
|
8
|
+
Notification = Data.define(:method, :params, :jsonrpc) do
|
11
9
|
include Message
|
12
10
|
|
13
|
-
def initialize(method:, params: nil)
|
11
|
+
def initialize(method:, params: nil, jsonrpc: JSONRPC_VERSION)
|
14
12
|
super
|
15
13
|
|
16
14
|
unless method.is_a?(String)
|
@@ -22,16 +20,22 @@ module Protocol
|
|
22
20
|
end
|
23
21
|
|
24
22
|
def to_h
|
25
|
-
h = super
|
26
|
-
h
|
23
|
+
h = super
|
24
|
+
h.delete(:params) if params.nil?
|
27
25
|
h
|
28
26
|
end
|
29
27
|
|
28
|
+
# Compatibility with the Message interface, Notifications have no ID
|
30
29
|
def id = nil
|
31
30
|
|
32
|
-
|
31
|
+
# Compatibility with the Request
|
32
|
+
# Yields the notificatino for processing but ignores the result
|
33
|
+
def reply(*, &)
|
34
|
+
yield self if block_given?
|
35
|
+
nil # notification always returns nil
|
36
|
+
end
|
33
37
|
|
34
|
-
def
|
38
|
+
def notification? = true
|
35
39
|
end
|
36
40
|
end
|
37
41
|
end
|
@@ -4,14 +4,13 @@
|
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
5
|
|
6
6
|
require "securerandom"
|
7
|
-
require_relative "message"
|
8
7
|
|
9
8
|
module Protocol
|
10
9
|
module Jsonrpc
|
11
|
-
|
10
|
+
Request = Data.define(:method, :params, :id, :jsonrpc) do
|
12
11
|
include Message
|
13
12
|
|
14
|
-
def initialize(method:, params: nil, id: SecureRandom.uuid)
|
13
|
+
def initialize(method:, params: nil, id: SecureRandom.uuid, jsonrpc: JSONRPC_VERSION)
|
15
14
|
unless method.is_a?(String)
|
16
15
|
raise InvalidRequestError.new("Method must be a string", data: method.inspect)
|
17
16
|
end
|
@@ -26,12 +25,32 @@ module Protocol
|
|
26
25
|
end
|
27
26
|
|
28
27
|
def to_h
|
29
|
-
h = super
|
30
|
-
h
|
28
|
+
h = super
|
29
|
+
h.delete(:params) if params.nil?
|
31
30
|
h
|
32
31
|
end
|
33
32
|
|
34
|
-
def
|
33
|
+
def request? = true
|
34
|
+
|
35
|
+
def reply(*args, &)
|
36
|
+
if args.empty? && block_given?
|
37
|
+
begin
|
38
|
+
result_or_error = yield self
|
39
|
+
rescue => error
|
40
|
+
return ErrorResponse.new(id:, error:)
|
41
|
+
end
|
42
|
+
elsif args.length == 1
|
43
|
+
result_or_error = args.first
|
44
|
+
else
|
45
|
+
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 0 or 1)"
|
46
|
+
end
|
47
|
+
|
48
|
+
if result_or_error.is_a?(StandardError)
|
49
|
+
ErrorResponse.new(id:, error: result_or_error)
|
50
|
+
else
|
51
|
+
Response.new(id:, result: result_or_error)
|
52
|
+
end
|
53
|
+
end
|
35
54
|
end
|
36
55
|
end
|
37
56
|
end
|
@@ -3,23 +3,19 @@
|
|
3
3
|
# Released under the MIT License.
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
5
|
|
6
|
-
require_relative "message"
|
7
|
-
|
8
6
|
module Protocol
|
9
7
|
module Jsonrpc
|
10
|
-
|
8
|
+
Response = Data.define(:id, :result, :jsonrpc) do
|
11
9
|
include Message
|
12
10
|
|
13
|
-
def initialize(id:, result:)
|
11
|
+
def initialize(id:, result:, jsonrpc: JSONRPC_VERSION)
|
14
12
|
unless id.nil? || id.is_a?(String) || id.is_a?(Numeric)
|
15
|
-
raise InvalidRequestError.new("ID must be nil, string or number", id:)
|
13
|
+
raise InvalidRequestError.new("ID must be nil, string, or number", id:)
|
16
14
|
end
|
17
15
|
|
18
16
|
super
|
19
17
|
end
|
20
18
|
|
21
|
-
def to_h = super.merge(id:, result:)
|
22
|
-
|
23
19
|
def response? = true
|
24
20
|
end
|
25
21
|
end
|
data/lib/protocol/jsonrpc.rb
CHANGED
@@ -3,16 +3,21 @@
|
|
3
3
|
# Released under the MIT License.
|
4
4
|
# Copyright 2025 by Martin Emde
|
5
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
6
|
module Protocol
|
16
7
|
module Jsonrpc
|
8
|
+
JSONRPC_VERSION = "2.0"
|
9
|
+
|
10
|
+
autoload :Batch, "protocol/jsonrpc/batch"
|
11
|
+
autoload :Connection, "protocol/jsonrpc/connection"
|
12
|
+
autoload :Error, "protocol/jsonrpc/error"
|
13
|
+
autoload :ErrorResponse, "protocol/jsonrpc/error_response"
|
14
|
+
autoload :Frame, "protocol/jsonrpc/frame"
|
15
|
+
autoload :Framer, "protocol/jsonrpc/framer"
|
16
|
+
autoload :InvalidMessage, "protocol/jsonrpc/invalid_message"
|
17
|
+
autoload :Message, "protocol/jsonrpc/message"
|
18
|
+
autoload :Notification, "protocol/jsonrpc/notification"
|
19
|
+
autoload :Request, "protocol/jsonrpc/request"
|
20
|
+
autoload :Response, "protocol/jsonrpc/response"
|
21
|
+
autoload :VERSION, "protocol/jsonrpc/version"
|
17
22
|
end
|
18
23
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: protocol-jsonrpc
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Martin Emde
|
@@ -34,15 +34,17 @@ files:
|
|
34
34
|
- LICENSE.txt
|
35
35
|
- README.md
|
36
36
|
- lib/protocol/jsonrpc.rb
|
37
|
+
- lib/protocol/jsonrpc/batch.rb
|
37
38
|
- lib/protocol/jsonrpc/connection.rb
|
38
39
|
- lib/protocol/jsonrpc/error.rb
|
39
|
-
- lib/protocol/jsonrpc/
|
40
|
+
- lib/protocol/jsonrpc/error_response.rb
|
40
41
|
- lib/protocol/jsonrpc/frame.rb
|
41
42
|
- lib/protocol/jsonrpc/framer.rb
|
43
|
+
- lib/protocol/jsonrpc/invalid_message.rb
|
42
44
|
- lib/protocol/jsonrpc/message.rb
|
43
|
-
- lib/protocol/jsonrpc/
|
44
|
-
- lib/protocol/jsonrpc/
|
45
|
-
- lib/protocol/jsonrpc/
|
45
|
+
- lib/protocol/jsonrpc/notification.rb
|
46
|
+
- lib/protocol/jsonrpc/request.rb
|
47
|
+
- lib/protocol/jsonrpc/response.rb
|
46
48
|
- lib/protocol/jsonrpc/version.rb
|
47
49
|
- sig/protocol/jsonrpc.rbs
|
48
50
|
homepage: https://github.com/martinemde/protocol-jsonrpc
|