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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0605256ce9da467440c5c83f3d871eef77a465a33553f9f3fbb7d5f4f4edbcd2
4
- data.tar.gz: baa116715ac8ad29ad29f5cf188dcba524e6bcfbce4f88ecfd69288736bac692
3
+ metadata.gz: '0488651e9e3c97f90a8bfe83c904ade78a8e0532e524fc6b1e97fd7ec2334f0f'
4
+ data.tar.gz: 2d9c402bcd8786051868b545afdb858ca862fcd4e699e6910a720467b4d0ab3d
5
5
  SHA512:
6
- metadata.gz: 8843f873055775f88cd4d5780d21ae605c3ebd688c94f700388be5edddbf3e795ceb15ba69557ddafe7a08224b7f95d52a3c1a68357fd1ea427af54a2cf64868
7
- data.tar.gz: 7b8cd982a41aa44003629fdea9dc16d6ce447eb1cdaf3c25e26600e61dee5674490eed22f5b3183b71fcd48375270447ae4f8f29b21b8986889633840628bb3e
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
- - 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`
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::RequestMessage.new(method: "subtract", params: [42, 23])
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::RequestMessage id:"...", method: "subtract", params: [42, 23]>
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
- ### Realistic Server Implementation
90
+ ### Server Implementation
73
91
 
74
- For a more realistic server implementation:
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
- # Track pending requests awaiting responses
94
- pending_requests = {}
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
- 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))
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::RequestMessage.new(
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::RequestMessage.new(
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::NotificationMessage.new(
168
+ notification = Protocol::Jsonrpc::Notification.new(
162
169
  method: "update",
163
- params: [1, 2, 3, 4, 5]
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::ResponseMessage.new(
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
- # Standard error types include:
192
- # - ParseError
193
- # - InvalidRequestError
194
- # - MethodNotFoundError
195
- # - InvalidParamsError
196
- # - InternalError
197
- # - ServerError
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::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])
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
- 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(:-))
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
- msg.reply(Protocol::Jsonrpc::MethodNotFoundError.new)
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::NotificationMessage
229
- handle_notification(msg)
230
- nil
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 if not empty
235
- server.write(batch_response) unless batch_response.empty?
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
- def flush; end
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
- # Accepts a
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
- message = Protocol::Jsonrpc::NotificationMessage.new(method: "hello", params: ["world"])
262
- client.write(message) # calls write_frame with any message responding to `as_json`
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 a message from the framer
26
- # @yield [Protocol::Jsonrpc::Message] The read message
27
- # @return [Protocol::Jsonrpc::Message] The read message
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
- message = Message.load(frame.unpack)
32
- yield message if block_given?
33
- message
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>] The message(s) to write
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
- frame = Frame.pack(message)
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
- MESSAGES = Hash.new("Error").merge(
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: error)
59
+ ParseError.new("Parse error: #{error.message}", data:, id:)
56
60
  in StandardError
57
- InternalError.new(error.message, data: error)
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
- super([MESSAGES[code], message].compact.uniq.join(": "))
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
- ErrorMessage.new(id:, error: self)
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
- ErrorMessage = Data.define(:id, :error) do
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(id:, error: error.to_h)
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
- # 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:)
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 json into a JSON object
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(json, symbolize_names: true)
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 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
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("#{json}\n")
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
- # JsonrpcMessage provides stateless operations for creating and validating JSON-RPC messages.
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
- from_array(data)
29
+ Batch.load(data)
33
30
  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)
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
- 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
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
- ErrorMessage.new(id:, error: Error.from_message(**error))
43
+ ErrorResponse.new(id:, error: Error.from_message(**error), jsonrpc:)
69
44
  in { id:, result: }
70
- ResponseMessage.new(id:, result:)
45
+ Response.new(id:, result:, jsonrpc:)
71
46
  in { id:, method: }
72
- RequestMessage.new(id:, method:, params: parsed[:params])
47
+ Request.new(id:, method:, params: parsed[:params], jsonrpc:)
73
48
  in { method: }
74
- NotificationMessage.new(method:, params: parsed[:params])
49
+ Notification.new(method:, params: parsed[:params], jsonrpc:)
75
50
  else
76
- raise ParseError.new("Unknown message: #{parsed.inspect}", data: parsed)
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
- def match?(message) = false
73
+ # Is this an error response? (ErrorResponse)
74
+ def error? = false
94
75
 
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
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
- NotificationMessage = Data.define(:method, :params) do
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.merge(method:)
26
- h[:params] = params if params
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
- def reply = nil
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 response? = false
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
- RequestMessage = Data.define(:method, :params, :id) do
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.merge(id:, method:)
30
- h[:params] = params if params
28
+ h = super
29
+ h.delete(:params) if params.nil?
31
30
  h
32
31
  end
33
32
 
34
- def response? = false
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
- ResponseMessage = Data.define(:id, :result) do
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
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Protocol
7
7
  module Jsonrpc
8
- VERSION = "0.1.0"
8
+ VERSION = "0.2.0"
9
9
  end
10
10
  end
@@ -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.1.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/error_message.rb
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/notification_message.rb
44
- - lib/protocol/jsonrpc/request_message.rb
45
- - lib/protocol/jsonrpc/response_message.rb
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