json_rpc_kit 0.9.0.rc1
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/lib/json_rpc_kit/endpoint.rb +758 -0
- data/lib/json_rpc_kit/errors.rb +82 -0
- data/lib/json_rpc_kit/helpers.rb +81 -0
- data/lib/json_rpc_kit/service.rb +725 -0
- data/lib/json_rpc_kit/transport_options.rb +220 -0
- data/lib/json_rpc_kit/version.rb +5 -0
- data/lib/json_rpc_kit.rb +28 -0
- metadata +62 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'transport_options'
|
|
4
|
+
|
|
5
|
+
module JsonRpcKit
|
|
6
|
+
# @!group Implementation and Documentation Notes
|
|
7
|
+
# **Batch vs Endpoint Pattern**:
|
|
8
|
+
# - Batch separates "add request" (json_rpc_request) from "invoke" (json_rpc_invoke)
|
|
9
|
+
# - Endpoint combines request+invoke in single methods (json_rpc_invoke)
|
|
10
|
+
#
|
|
11
|
+
# **Static vs Instance Methods**:
|
|
12
|
+
# - Where an instance method (Endpoint or Batch) calls a corresponding class method
|
|
13
|
+
# - Keep parameter descriptions identical between static and instance versions
|
|
14
|
+
#
|
|
15
|
+
# ** Documentation**:
|
|
16
|
+
# - do not use YARD macros it is too complex, and we can dedup with other tools or AI.
|
|
17
|
+
# - parameters with common intent across similar methods described above, should have identical descriptions
|
|
18
|
+
# across all usages unless there is a specific difference that is noted.
|
|
19
|
+
# @!endgroup
|
|
20
|
+
|
|
21
|
+
# An endpoint for dispatching JSON-RPC requests via ruby calls.
|
|
22
|
+
#
|
|
23
|
+
# This class covers the JSON-RPC generation of ids and encoding/decoding of method and parameters while delegating
|
|
24
|
+
# the send/receive of JSON encoded data to a {.transport} proc.
|
|
25
|
+
#
|
|
26
|
+
# Requests can be sent individually via
|
|
27
|
+
# * the class method {.invoke}
|
|
28
|
+
# * the instance method {#json_rpc_invoke}
|
|
29
|
+
# * dynamic method call (via {#method_missing} )
|
|
30
|
+
#
|
|
31
|
+
# or as a {Batch}
|
|
32
|
+
#
|
|
33
|
+
# ### Composability
|
|
34
|
+
#
|
|
35
|
+
# Endpoints can be configured and composed using {InstanceHelpers#initialize .new} and {#with}
|
|
36
|
+
# Conventionally supported options include `:async` and `:timeout`, plus any transport-specific options:
|
|
37
|
+
#
|
|
38
|
+
# ### Asynchronous Operations
|
|
39
|
+
#
|
|
40
|
+
# Async operations return transport-specific futures/promises instead of blocking:
|
|
41
|
+
#
|
|
42
|
+
# @example Basic Usage
|
|
43
|
+
# # Create an endpoint with optional namespace
|
|
44
|
+
# endpoint = JsonRpcKit::Endpoint.new(namespace: 'api.v1') do |id, request_json, **opts, &response|
|
|
45
|
+
# # Your transport implementation here
|
|
46
|
+
# http_response = HTTP.post('http://api.example.com/rpc', json: request_json)
|
|
47
|
+
# response.call { http_response.body.to_s }
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # Call methods directly - returns the result from the JSON-RPC response
|
|
51
|
+
# result = endpoint.get_user(id: 123)
|
|
52
|
+
#
|
|
53
|
+
# @example Dynamic method calls (recommended)
|
|
54
|
+
# result = endpoint.get_user(id: 123)
|
|
55
|
+
# # => {"name"=>"Alice", "id"=>123} via {"jsonrpc":"2.0","method":"api.v1.getUser","params":{"id":123},"id":"abc-1"}
|
|
56
|
+
#
|
|
57
|
+
# # Named parameters
|
|
58
|
+
# endpoint.update_user(id: 123, name: 'Alice')
|
|
59
|
+
# # via {"jsonrpc":"2.0","method":"api.v1.updateUser","params":{"id":123,"name":"Alice"},"id":"abc-2"}
|
|
60
|
+
#
|
|
61
|
+
# # Positional parameters
|
|
62
|
+
# endpoint.calculate(10, 20, 'add')
|
|
63
|
+
# # => 30 via {"jsonrpc":"2.0","method":"api.v1.calculate","params":[10,20,"add"],"id":"abc-3"}
|
|
64
|
+
#
|
|
65
|
+
# @example Explicit JSON-RPC calls (for advanced control)
|
|
66
|
+
# result = endpoint.json_rpc_invoke({}, :next_id, 'users.v1.getUser', id: 123)
|
|
67
|
+
#
|
|
68
|
+
# @example Notifications (fire-and-forget, no response expected)
|
|
69
|
+
# # Bang method syntax
|
|
70
|
+
# endpoint.log_event!('User updated')
|
|
71
|
+
# # => nil via {"jsonrpc":"2.0","method":"api.v1.logEvent","params":["User updated"]}
|
|
72
|
+
#
|
|
73
|
+
# # Explicit notify
|
|
74
|
+
# endpoint.json_rpc_notify(:log_event, message: 'User updated')
|
|
75
|
+
# # => nil via {"jsonrpc":"2.0","method":"api.v1.logEvent","params":{"message":"User updated"}}
|
|
76
|
+
#
|
|
77
|
+
# # Via json_rpc_invoke with nil id
|
|
78
|
+
# endpoint.json_rpc_invoke({}, nil, 'system.logEvent', message: 'User updated')
|
|
79
|
+
# # => nil via {"jsonrpc":"2.0","method":"system.logEvent","params":{"message":"User updated"}}
|
|
80
|
+
#
|
|
81
|
+
# @example Result conversion and error handling
|
|
82
|
+
# user = endpoint.get_user(id: 123) do |**, &result|
|
|
83
|
+
# User.from_json(result.call)
|
|
84
|
+
# rescue JsonRpcKit::Error => e
|
|
85
|
+
# raise NotFoundError, "User not found" if e.code == -32001
|
|
86
|
+
# raise
|
|
87
|
+
# end
|
|
88
|
+
# # => User object or raises NotFoundError for specific error codes
|
|
89
|
+
#
|
|
90
|
+
# @example Asynchronous calls
|
|
91
|
+
# # one-shot context
|
|
92
|
+
# future = endpoint.with(async: true).get_user(id: 123) # returns future immediately
|
|
93
|
+
# # .. do some other things...
|
|
94
|
+
# result = future.value # blocks until complete
|
|
95
|
+
#
|
|
96
|
+
# # with more control over method naming
|
|
97
|
+
# future = endpoint.json_rpc_async(:next_id, 'users.v2.getUser', id: 123)
|
|
98
|
+
#
|
|
99
|
+
# # async context for multiple calls
|
|
100
|
+
# async_endpoint = endpoint.with(async: true)
|
|
101
|
+
# future1 = async_endpoint.get_user(id: 123)
|
|
102
|
+
# future2 = async_endpoint.get_user_perms(id: 123)
|
|
103
|
+
#
|
|
104
|
+
# result = User.new(data: future1.value, perms: future2.value)
|
|
105
|
+
#
|
|
106
|
+
# @example Creating specialized contexts with smart merging
|
|
107
|
+
# # Create endpoint with custom ID generator
|
|
108
|
+
# uuid_generator = ->() { SecureRandom.uuid }
|
|
109
|
+
# api_endpoint = JsonRpcKit::Endpoint.new(next_id: uuid_generator) do |id, json, **opts, &response|
|
|
110
|
+
# # transport implementation using opts for timeout, headers, etc.
|
|
111
|
+
# end
|
|
112
|
+
#
|
|
113
|
+
# # Create context with base headers
|
|
114
|
+
# authed_endpoint = api_endpoint.with(headers: {'X-Auth': 'token'})
|
|
115
|
+
#
|
|
116
|
+
# # Smart merge: headers are merged (Hash), timeout is replaced
|
|
117
|
+
# authed_endpoint.with(timeout: 30, headers: {'X-Request-ID': '123'}).get_user(id: 1)
|
|
118
|
+
# # opts passed to transport: {headers: {'X-Auth': 'token', 'X-Request-ID': '123'}, timeout: 30}
|
|
119
|
+
#
|
|
120
|
+
# # Arrays are concatenated
|
|
121
|
+
# tagged = api_endpoint.with(tags: ['api', 'v1'])
|
|
122
|
+
# tagged.with(tags: ['user']).get_user(id: 1)
|
|
123
|
+
# # opts passed to transport: {tags: ['api', 'v1', 'user']}
|
|
124
|
+
#
|
|
125
|
+
# @example Result conversion
|
|
126
|
+
# # Convert errors
|
|
127
|
+
# not_found_endpoint = api_endpoint.with_conversion do |**, &result|
|
|
128
|
+
# result.call
|
|
129
|
+
# rescue JsonRpcKit::Error => e
|
|
130
|
+
# raise NotFoundError if e.code == -32_001
|
|
131
|
+
# raise
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
# # Convert results based on response metadata
|
|
135
|
+
# typed_endpoint = api_endpoint.with_conversion do |resp_headers: {}, **, &result|
|
|
136
|
+
# case resp_headers['X-Result-Type']
|
|
137
|
+
# when 'User'
|
|
138
|
+
# User.from_json(result.call)
|
|
139
|
+
# else
|
|
140
|
+
# result.call
|
|
141
|
+
# end
|
|
142
|
+
# end
|
|
143
|
+
class Endpoint
|
|
144
|
+
# @!visibility private
|
|
145
|
+
# Common static class methods for Endpoint and Endpoint::Batch
|
|
146
|
+
module ClassHelpers
|
|
147
|
+
include Helpers
|
|
148
|
+
|
|
149
|
+
# Build JSON-RPC request object
|
|
150
|
+
# @return [Hash] compact request object
|
|
151
|
+
def to_request(id, method, args, kwargs)
|
|
152
|
+
raise ArgumentError, 'Use either positional or named parameters, not both' if args.any? && kwargs.any?
|
|
153
|
+
|
|
154
|
+
if kwargs.keys.any? { |k| k.start_with?('rpc_') }
|
|
155
|
+
raise ArgumentError, "Invalid arguments for batch request: #{kwargs.keys}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
params = nil
|
|
159
|
+
params = args if args.any?
|
|
160
|
+
params = kwargs if kwargs.any?
|
|
161
|
+
|
|
162
|
+
{ jsonrpc: '2.0', method: method, params: params, id: id }.compact
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Handle JSON-RPC response object (Hash)
|
|
166
|
+
# @return [Object] the result or raises error
|
|
167
|
+
def from_response(response)
|
|
168
|
+
return response[:result] unless response[:error]
|
|
169
|
+
|
|
170
|
+
Error.raise_error(**response[:error])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Ensure response errors are JsonRpcKit errors.
|
|
174
|
+
def call_response(&response_json)
|
|
175
|
+
response_json.call
|
|
176
|
+
rescue Error
|
|
177
|
+
raise
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
raise InvalidResponse, e.message, class_name: e.class.name
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Common instance methods for {Endpoint} and {Endpoint::Batch}
|
|
184
|
+
module InstanceHelpers
|
|
185
|
+
# Create a JSON-RPC endpoint.
|
|
186
|
+
#
|
|
187
|
+
# @example Creating an endpoint with context
|
|
188
|
+
# endpoint = JsonRpcKit::Endpoint.new(namespace: 'api.v1') do |id, json, **opts, &response|
|
|
189
|
+
# # transport implementation
|
|
190
|
+
# end
|
|
191
|
+
#
|
|
192
|
+
# # Create contexts for different scenarios
|
|
193
|
+
# fast_endpoint = endpoint.with(timeout: 5)
|
|
194
|
+
# slow_endpoint = endpoint.with(timeout: 30)
|
|
195
|
+
# async_endpoint = endpoint.with(async: true)
|
|
196
|
+
#
|
|
197
|
+
# @param namespace [String] optional namespace prefix for method names
|
|
198
|
+
# @param next_id [#call] ID generator (default: DefaultIdGenerator.new)
|
|
199
|
+
# @param opts [Hash] default options for requests (async, timeout) and arbitrary options specific
|
|
200
|
+
# to the underlying transport (http_headers etc)
|
|
201
|
+
# @option opts [Boolean] :async (false) send requests asynchronously
|
|
202
|
+
# @option opts [Numeric] :timeout (nil) timeout to wait for responses
|
|
203
|
+
# @option opts :prefix,:merge,:filter,:ignore [Object] see {TransportOptions}
|
|
204
|
+
# @option opts [Object] :.* arbitrary options for transport
|
|
205
|
+
# @param transport [Proc] transport block for sending requests
|
|
206
|
+
def initialize(namespace: nil, next_id: DefaultIdGenerator.new, **opts, &transport)
|
|
207
|
+
@namespace = namespace
|
|
208
|
+
@next_id = next_id
|
|
209
|
+
@options_config = TransportOptions.create_from_opts(opts)
|
|
210
|
+
@opts = @options_config.filter_opts(opts)
|
|
211
|
+
@transport = transport
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def json_rpc_wrap_converter(&converter)
|
|
217
|
+
return converter || DEFAULT_CONVERTER unless @opts[:converter]
|
|
218
|
+
return @opts[:converter] unless converter
|
|
219
|
+
|
|
220
|
+
# We are immutable so it is safe to use @opts[:converter]
|
|
221
|
+
existing = @opts[:converter]
|
|
222
|
+
proc { |**res_opts, &result| converter.call(**res_opts) { existing.call(**res_opts, &result) } }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def json_rpc_id_method(id, method)
|
|
226
|
+
# Handle bang methods for notifications
|
|
227
|
+
if method.is_a?(Symbol) && method.to_s.end_with?('!')
|
|
228
|
+
id = nil
|
|
229
|
+
method = method.to_s.chomp('!').to_sym
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
[
|
|
233
|
+
id == :next_id ? @next_id.call : id,
|
|
234
|
+
method.is_a?(Symbol) ? self.class.ruby_to_json_rpc(method, namespace: @namespace) : method
|
|
235
|
+
]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def respond_to_missing?(method, _include_private = false)
|
|
239
|
+
return true unless method.end_with?('?', '=')
|
|
240
|
+
|
|
241
|
+
super
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Default ID generator using object_id and counter
|
|
246
|
+
#
|
|
247
|
+
# IDs are automatically generated when using dynamic method calls or when
|
|
248
|
+
# passing `:next_id` to explicit JSON-RPC methods. The format is "objectid-counter"
|
|
249
|
+
# ensuring uniqueness within the endpoint instance.
|
|
250
|
+
class DefaultIdGenerator
|
|
251
|
+
# @return [String] generated ID in format "objectid-counter"
|
|
252
|
+
# @example
|
|
253
|
+
# generator = DefaultIdGenerator.new
|
|
254
|
+
# generator.call #=> "abc123-1"
|
|
255
|
+
# generator.call #=> "abc123-2"
|
|
256
|
+
# @example Using a custom proc
|
|
257
|
+
# custom_generator = -> { SecureRandom.uuid }
|
|
258
|
+
# endpoint = JsonRpcKit::Endpoint.new(next_id: custom_generator) { |id, json, &response| ... }
|
|
259
|
+
def call
|
|
260
|
+
@id ||= 0
|
|
261
|
+
"#{object_id.to_s(36)}-#{(@id += 1).to_s(36)}"
|
|
262
|
+
end
|
|
263
|
+
alias next call
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Looks like a class, is actually a proc.
|
|
267
|
+
UUIDGenerator = -> { SecureRandom.uuid }
|
|
268
|
+
|
|
269
|
+
# The default converter block (no conversion) for type conversion of JSON-RPC calls and errors
|
|
270
|
+
# @see #with_conversion
|
|
271
|
+
DEFAULT_CONVERTER = ->(**, &result) { result.call }
|
|
272
|
+
|
|
273
|
+
# Batch request builder for collecting multiple JSON-RPC calls
|
|
274
|
+
#
|
|
275
|
+
# @example Basic Batch Usage
|
|
276
|
+
# # Create a batch from an endpoint
|
|
277
|
+
# batch = endpoint.json_rpc_batch
|
|
278
|
+
#
|
|
279
|
+
# # Add requests to the batch
|
|
280
|
+
# batch.get_user(id: 1)
|
|
281
|
+
# batch.get_user(id: 2)
|
|
282
|
+
# batch.update_user(id: 3, name: 'Alice')
|
|
283
|
+
#
|
|
284
|
+
# # Execute all requests
|
|
285
|
+
# results = batch.json_rpc_invoke
|
|
286
|
+
#
|
|
287
|
+
# # Access individual results
|
|
288
|
+
# user1 = results[batch_id_1].call # Returns user data or raises error
|
|
289
|
+
#
|
|
290
|
+
# Requests are created and added to a collection via
|
|
291
|
+
# * the static class method {.request}
|
|
292
|
+
# * the instance method {#json_rpc_request}
|
|
293
|
+
# * dynamic method call (via {#method_missing} )
|
|
294
|
+
#
|
|
295
|
+
# The batch is then dispatched to the underlying transport via
|
|
296
|
+
# * the static class method {.invoke}
|
|
297
|
+
# * the instance method {#json_rpc_invoke}
|
|
298
|
+
#
|
|
299
|
+
# The result of a batch is a map of request id to a Proc that when called returns the result of the corresponding
|
|
300
|
+
# request or raises its error.
|
|
301
|
+
#
|
|
302
|
+
# @example Working with batch results
|
|
303
|
+
# batch = endpoint.json_rpc_batch
|
|
304
|
+
# id1 = batch.get_user(id: 1)
|
|
305
|
+
# id2 = batch.get_user(id: 2)
|
|
306
|
+
#
|
|
307
|
+
# results = batch.json_rpc_invoke
|
|
308
|
+
#
|
|
309
|
+
# # Get successful results
|
|
310
|
+
# user1 = results[id1].call
|
|
311
|
+
#
|
|
312
|
+
# # Handle errors for individual requests
|
|
313
|
+
# begin
|
|
314
|
+
# user2 = results[id2].call
|
|
315
|
+
# rescue JsonRpcKit::Error => e
|
|
316
|
+
# puts "Failed to get user 2: #{e.message}"
|
|
317
|
+
# end
|
|
318
|
+
class Batch
|
|
319
|
+
# Default converter for handling response options and errors for a batch
|
|
320
|
+
DEFAULT_BATCH_CONVERTER = ->(**response_opts, &batch) { batch.call(**response_opts) }
|
|
321
|
+
|
|
322
|
+
class << self
|
|
323
|
+
include ClassHelpers
|
|
324
|
+
|
|
325
|
+
# The default proc for the {invoke} result hash
|
|
326
|
+
NO_RESPONSE_DEFAULT_PROC = ->(_h, id) { raise InvalidResponse, "Invalid id='#{id}' in batch response" }
|
|
327
|
+
|
|
328
|
+
# Add a request to a batch collection
|
|
329
|
+
# @overload request(batch, id, method, *args, **kwargs, &converter)
|
|
330
|
+
# @param batch [Array|#<<] a collection to add the batch request to
|
|
331
|
+
# @param id [String|Integer|Symbol|nil] request ID (:next_id for auto-generated, nil for notification)
|
|
332
|
+
# @param method [String] the JSON-RPC method name
|
|
333
|
+
# @param args [Array] positional arguments
|
|
334
|
+
# @param kwargs [Hash] named arguments
|
|
335
|
+
# @yield [**response_opts, &result] optional result converter
|
|
336
|
+
# @yieldparam result [Proc] callback to retrieve the response result or raise its error
|
|
337
|
+
# @yieldreturn [Object] the converted result, or raise a converted error
|
|
338
|
+
# @return [String|Integer|nil] the id of the request, or nil for a notification
|
|
339
|
+
# @note As per JSON-RPC spec it is an error to provide both positional and named arguments
|
|
340
|
+
def request(batch, id, method, *args, **kwargs, &converter)
|
|
341
|
+
converter ||= DEFAULT_CONVERTER
|
|
342
|
+
id.tap { batch << { id:, request: to_request(id, method, args, kwargs), converter: } }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Send the batch of requests with transport options
|
|
346
|
+
# @overload invoke(batch, transport_opts = {}, &transport)
|
|
347
|
+
# @param batch [Array] collection filled by {request}
|
|
348
|
+
# @param transport_opts [Hash] transport options
|
|
349
|
+
# @option transport_opts :batch_converter [#call] (optional)
|
|
350
|
+
# converter to transform response options and handle the batch result or error
|
|
351
|
+
# @option transport_opts :async [Boolean] handle responses asynchronously
|
|
352
|
+
# @option transport_opts :timeout [Numeric] request timeout
|
|
353
|
+
# @option transport_opts :batch_converter [Proc] a converter to manage the batch result Hash or
|
|
354
|
+
# the single error indicating something went wrong in invoking the batch.
|
|
355
|
+
# @yield [id, request_json, **transport_opts, &transport_response]
|
|
356
|
+
# transport to send requests and handle responses
|
|
357
|
+
# @return [nil] if the batch was empty or all the requests were notifications
|
|
358
|
+
# @return [#value] a transport-specific future/promise (async operations) whose value is the response Hash
|
|
359
|
+
# @return [Hash<String|Integer, Proc>] map of ids to result proc
|
|
360
|
+
def invoke(batch, transport_opts = {}, **kw_opts, &transport)
|
|
361
|
+
return nil if batch.empty?
|
|
362
|
+
|
|
363
|
+
transport_opts.merge!(**kw_opts, content_type: CONTENT_TYPE)
|
|
364
|
+
|
|
365
|
+
# The batch converter converts the whole batch the result it receives is either the Hash result
|
|
366
|
+
# or the top level error raised if the batch failed entirely. eg It can convert the response opts
|
|
367
|
+
batch_converter = transport_opts.delete(:batch_converter) || DEFAULT_BATCH_CONVERTER
|
|
368
|
+
batch_id, requests, converters = batch_prepare(batch)
|
|
369
|
+
|
|
370
|
+
transport.call(batch_id, requests.to_json, **transport_opts) do
|
|
371
|
+
|content_type: CONTENT_TYPE, **response_opts, &json_response|
|
|
372
|
+
batch_converter.call(**response_opts) do |**converted_opts|
|
|
373
|
+
batch_response(converters, **converted_opts, content_type:, &json_response)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def yield_calls(calls)
|
|
381
|
+
calls.each { |call| yield(**call) }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def batch_prepare(batch, batch_id = nil, requests: [], converters: {})
|
|
385
|
+
yield_calls(batch) do |id:, request:, converter:|
|
|
386
|
+
requests << request
|
|
387
|
+
next unless id
|
|
388
|
+
|
|
389
|
+
# Thread safety issue?
|
|
390
|
+
raise InternalError, "Duplicate request id #{id} in batch" if converters.key?(id)
|
|
391
|
+
|
|
392
|
+
converters[id] = converter
|
|
393
|
+
batch_id ||= id # use the id of the first request we find
|
|
394
|
+
end
|
|
395
|
+
converters.default_proc = NO_RESPONSE_DEFAULT_PROC
|
|
396
|
+
[batch_id, requests, converters]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def batch_response(converters, content_type:, **, &json_response)
|
|
400
|
+
response = call_response(&json_response)
|
|
401
|
+
|
|
402
|
+
parse_response(response, content_type:, batch: true)
|
|
403
|
+
.to_h { |r| [r[:id], proc { converters[r[:id]].call(**) { from_response(r) } }] }
|
|
404
|
+
.tap { |h| h.default_proc = NO_RESPONSE_DEFAULT_PROC }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
include InstanceHelpers
|
|
409
|
+
|
|
410
|
+
# Create a new batch.
|
|
411
|
+
# @param batch [Array] empty container for batch requests (default: [])
|
|
412
|
+
# @param transport_options [Hash]
|
|
413
|
+
# @param transport [Proc] transport block for sending requests
|
|
414
|
+
def initialize(batch: [], **transport_options, &transport)
|
|
415
|
+
raise ArgumentError 'batch must be initially empty' unless batch.empty?
|
|
416
|
+
|
|
417
|
+
@batch = batch
|
|
418
|
+
super(**transport_options, &transport)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# rubocop:disable Style/MissingRespondToMissing
|
|
422
|
+
|
|
423
|
+
# @overload method_missing(method, *args, **kwargs, &converter)
|
|
424
|
+
# Redirects all method calls to {#json_rpc_request}
|
|
425
|
+
# @return [String|Integer|nil] the id of the request, or nil for a notification (bang! method)
|
|
426
|
+
def method_missing(method, ...)
|
|
427
|
+
json_rpc_request(:next_id, method, ...)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# rubocop:enable Style/MissingRespondToMissing
|
|
431
|
+
|
|
432
|
+
# Fire and forget notification (ie does not get a response)
|
|
433
|
+
# @overload json_rpc_notify(method, *args, **kwargs)
|
|
434
|
+
# @param method [Symbol|String] the RPC method to invoke (Symbol converted with ruby_to_json_rpc)
|
|
435
|
+
# @param args [Array] positional arguments
|
|
436
|
+
# @param kwargs [Hash] named arguments
|
|
437
|
+
# @return [nil]
|
|
438
|
+
def json_rpc_notify(method, *, **)
|
|
439
|
+
json_rpc_request(nil, method, *, **)
|
|
440
|
+
nil
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Add a request to this batch
|
|
444
|
+
# @overload json_rpc_request(id, method, *args, **kwargs, &converter)
|
|
445
|
+
# @param id [String|Integer|Symbol|nil] request ID (:next_id for auto-generated, nil for notification)
|
|
446
|
+
# @param method [Symbol|String] the RPC method to invoke (Symbol converted with ruby_to_json_rpc)
|
|
447
|
+
# @param args [Array] positional arguments
|
|
448
|
+
# @param kwargs [Hash] named arguments
|
|
449
|
+
# @yield [**response_opts, &result] optional result converter
|
|
450
|
+
# @yieldparam result [Proc] callback to retrieve the response result or raise its error
|
|
451
|
+
# @yieldreturn [Object] the converted result, or raise a converted error
|
|
452
|
+
# @return [String|Integer|nil] the id of the request, or nil for a notification
|
|
453
|
+
# @note As per JSON-RPC spec it is an error to provide both positional and named arguments
|
|
454
|
+
def json_rpc_request(id, method, *, **, &)
|
|
455
|
+
self.class.request(@batch, *json_rpc_id_method(id, method), *, **, &json_rpc_wrap_converter(&))
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Process the currently batched requests.
|
|
459
|
+
# @return [nil] if the batch was empty or all the requests were notifications
|
|
460
|
+
# @return [#value] a transport-specific future/promise (async operations)
|
|
461
|
+
# @return [Hash<String|Integer, Proc>] map of ids to result proc
|
|
462
|
+
# @raise [Error] if the batch is not sent OR if the response has a transport issue
|
|
463
|
+
# @note the batch is automatically reset if it is successfully delivered to the transport.
|
|
464
|
+
def json_rpc_invoke(&batch_converter)
|
|
465
|
+
batch_converter ||= DEFAULT_BATCH_CONVERTER
|
|
466
|
+
|
|
467
|
+
wrapped_converter = proc do |**response_opts, &batch_result|
|
|
468
|
+
# Prefix the incoming response_opts before our userland converter sees them
|
|
469
|
+
prefixed_opts = @options_config.to_user_space(response_opts)
|
|
470
|
+
batch_converter.call(**prefixed_opts, &batch_result)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# De-prefix the outgoing request options so the transport sees natural options. and exclude converter
|
|
474
|
+
transport_opts = @options_config.to_transport_space(@opts.except(:converter))
|
|
475
|
+
self.class.invoke(@batch, transport_opts, batch_converter: wrapped_converter, &@transport).tap { @batch.clear }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Create a single request Endpoint with the same configuration as this Batch
|
|
479
|
+
# @overload json_rpc_endpoint(**with_opts)
|
|
480
|
+
# @param with_opts [Hash] optional context options to override
|
|
481
|
+
# @return [Endpoint]
|
|
482
|
+
def json_rpc_endpoint(**)
|
|
483
|
+
Endpoint.new(**@opts, namespace: @namespace, next_id: @next_id, options_config: @options_config, &@transport)
|
|
484
|
+
.with(**)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
class << self
|
|
489
|
+
include ClassHelpers
|
|
490
|
+
|
|
491
|
+
# @!method transport(id, request_json, **transport_opts, &transport_response)
|
|
492
|
+
# @abstract Signature for the block passed to {.invoke}
|
|
493
|
+
#
|
|
494
|
+
# When **id** is nil it must return immediately after dispatching the request, without calling
|
|
495
|
+
# **&{transport_response}**.
|
|
496
|
+
# Transport errors when dispatching the request should also be allowed to propagate immediately.
|
|
497
|
+
#
|
|
498
|
+
# Otherwise, in synchronous operations a transport should then...
|
|
499
|
+
# * block waiting for the JSON-RPC response, raising {TimeoutError} if a response is not received
|
|
500
|
+
# before the optional **:timeout** expires.
|
|
501
|
+
# * call the **&{transport_response}** proc with a block that returns the response string or raises
|
|
502
|
+
# {InvalidResponse}
|
|
503
|
+
# * return the result of the above call or allow its error to propagate
|
|
504
|
+
#
|
|
505
|
+
# To support asynchronous operations, when **:async** is set, the transport should immediately return a
|
|
506
|
+
# future/promise that is eventually resolved from the **&{transport_response}** callback.
|
|
507
|
+
#
|
|
508
|
+
# @param id [String|Integer|nil] trace id for the request or nil for a notification.
|
|
509
|
+
# @param transport_opts [Hash] transport options.
|
|
510
|
+
#
|
|
511
|
+
# Custom transport options should use a descriptive prefix (e.g., `http_`, `mqtt_`).
|
|
512
|
+
#
|
|
513
|
+
# @option transport_opts :async [Boolean] handle responses asynchronously
|
|
514
|
+
# @option transport_opts :timeout [Numeric] request timeout
|
|
515
|
+
# @option transport_opts :converter Reserved for internal use
|
|
516
|
+
# @yield [**response_opts, &json_response] the &{transport_response}
|
|
517
|
+
# @return [void] for notifications (when id is nil)
|
|
518
|
+
# @return [#value] a future/promise if **:async** was requested
|
|
519
|
+
# @return [Object] the result of the call to &{transport_response}
|
|
520
|
+
# @raise [InternalError] if something goes wrong with the transport
|
|
521
|
+
# @raise [TimeoutError] if a synchronous request times out
|
|
522
|
+
# @raise [Error] error raised from &{transport_response} callback
|
|
523
|
+
# @example Simple HTTP Transport
|
|
524
|
+
# def simple_http_transport(id, request_json, timeout: 30, **opts, &transport_response)
|
|
525
|
+
# return post_async(request_json) unless id # notifications
|
|
526
|
+
#
|
|
527
|
+
# response = HTTP.timeout(timeout).post('http://api.example.com/rpc',
|
|
528
|
+
# json: request_json)
|
|
529
|
+
#
|
|
530
|
+
# transport_response.call do
|
|
531
|
+
# raise JsonRpcKit::InvalidResponse, "HTTP #{response.status}" unless response.status.success?
|
|
532
|
+
# response.body.to_s
|
|
533
|
+
# end
|
|
534
|
+
# end
|
|
535
|
+
#
|
|
536
|
+
# @example
|
|
537
|
+
# def json_rpc_transport(id, request_json, async: false, timeout: nil, **opts, &transport_response)
|
|
538
|
+
# unless id
|
|
539
|
+
# return send_async(request_json, **opts) # if there is no id, just fire and forget
|
|
540
|
+
# end
|
|
541
|
+
#
|
|
542
|
+
# future = new_future # some transport-specific concurrency primitive
|
|
543
|
+
#
|
|
544
|
+
# send_async(request_json) do |resp, error| # a transport-specific async with response/error callback
|
|
545
|
+
#
|
|
546
|
+
# result = transport_response.call do # NOTE: &transport_response itself takes a block! that...
|
|
547
|
+
# raise InvalidResponse, error.message if error # raises InvalidResponse if something went wrong
|
|
548
|
+
# resp # or returns the response string
|
|
549
|
+
# end
|
|
550
|
+
# future.fulfill(result) # fulfill/resolve the future
|
|
551
|
+
# rescue StandardError => e
|
|
552
|
+
# future.reject(e) # or reject it
|
|
553
|
+
# end
|
|
554
|
+
# return future if async # async -> immediately return the future
|
|
555
|
+
#
|
|
556
|
+
# future.wait(timeout) # not async so wait on the future
|
|
557
|
+
# unless future.fulfilled?
|
|
558
|
+
# raise JsonRpcKit::TimeoutError # if timeout without response, raise TimeoutError
|
|
559
|
+
# end
|
|
560
|
+
# future.value # return the resolved value or raise resolved error
|
|
561
|
+
# end
|
|
562
|
+
#
|
|
563
|
+
# def json_rpc_endpoint
|
|
564
|
+
# JsonRpcKit.endpoint { |*a, **kw, &tp_resp| json_rpc_transport(*a, **kw, &tp_resp) }
|
|
565
|
+
# end
|
|
566
|
+
|
|
567
|
+
# @!method transport_response(**response_opts, &json_response)
|
|
568
|
+
# Block signature for the &transport_response callback provided to {transport}. Handles parsing and extracting
|
|
569
|
+
# results from the transport's String response.
|
|
570
|
+
# @param response_opts [Hash] transport options from the response
|
|
571
|
+
# @option response_opts :content_type [String] if provided must be 'application/json'
|
|
572
|
+
# @option response_opts :.* [Object] arbitrary transport specific options for tracing, type conversion etc...
|
|
573
|
+
#
|
|
574
|
+
# Recommend using a consistent prefix for the transport type.
|
|
575
|
+
# @param json_response [Proc] (required) a callback providing the JSON-RPC response or error
|
|
576
|
+
#
|
|
577
|
+
# This proc should raise an error (e.g., InvalidResponse) if something has gone wrong with the
|
|
578
|
+
# response, or otherwise return the String response received from the transport.
|
|
579
|
+
# Transport errors are automatically wrapped as InvalidResponse by the endpoint.
|
|
580
|
+
# @return [Object] the result extracted from the response String
|
|
581
|
+
# @raise [StandardError] any error raised in response processing, or extracted from the response String
|
|
582
|
+
|
|
583
|
+
# @!method converter(**response_opts, &result)
|
|
584
|
+
# @abstract Signature for block provided to {Batch.request}, {Batch#json_rpc_request} or {Endpoint#invoke}
|
|
585
|
+
# @param response_opts [Hash] transport options from the response (with 'rpc' prefix)
|
|
586
|
+
# @yield [] &{result} callback
|
|
587
|
+
# @return [Object] the converted result
|
|
588
|
+
# @raise [StandardError] the converted error
|
|
589
|
+
|
|
590
|
+
# @!method result
|
|
591
|
+
# Signature for the &result callback provided to a {converter} and for values held in result of {Batch.invoke}
|
|
592
|
+
# @return [Object] the result object parsed from the response result
|
|
593
|
+
# @raise [JsonRpcKit::Error, NoMethodError, ArgumentError, JSON::ParserError] errors raised during response
|
|
594
|
+
# processing
|
|
595
|
+
|
|
596
|
+
# Dispatches a JSON-RPC request to the given transport block
|
|
597
|
+
# @param transport_opts [Hash] transport options (including :converter, :async, :timeout, etc.)
|
|
598
|
+
# @param id [String|Integer|nil] request ID (nil for notification)
|
|
599
|
+
# @param method [String] the JSON-RPC method name
|
|
600
|
+
# @param args [Array] positional arguments
|
|
601
|
+
# @param kwargs [Hash] named arguments
|
|
602
|
+
# @yield [id, request_json, **transport_opts, &transport_response] transport block
|
|
603
|
+
# @return [Object] the result from the response (nil for notifications)
|
|
604
|
+
def invoke(transport_opts, id, method, *args, **kwargs, &transport)
|
|
605
|
+
converter = transport_opts.delete(:converter) || DEFAULT_CONVERTER
|
|
606
|
+
raise ArgumentError, 'Async notifications are not supported' if transport_opts[:async] && !id
|
|
607
|
+
|
|
608
|
+
request = to_request(id, method, args, kwargs)
|
|
609
|
+
|
|
610
|
+
transport_opts.merge!(content_type: CONTENT_TYPE)
|
|
611
|
+
result = transport.call(id, request.to_json, **transport_opts) do
|
|
612
|
+
|content_type: CONTENT_TYPE, **response_opts, &json_response|
|
|
613
|
+
converter.call(**response_opts) { single_response(content_type:, &json_response) }
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
id ? result : nil
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
private
|
|
620
|
+
|
|
621
|
+
def single_response(content_type:, &json_response)
|
|
622
|
+
response_str = call_response(&json_response)
|
|
623
|
+
response_item = parse_response(response_str, content_type:, batch: false)
|
|
624
|
+
from_response(response_item)
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
include InstanceHelpers
|
|
629
|
+
|
|
630
|
+
# Create a new endpoint context with merged options
|
|
631
|
+
# @param opts [Hash] additional context options to merge
|
|
632
|
+
# @return [Endpoint] new endpoint with merged context
|
|
633
|
+
# @example Basic context creation
|
|
634
|
+
# async_endpoint = endpoint.with(async: true, timeout: 30)
|
|
635
|
+
# @example Custom merge strategy
|
|
636
|
+
# # Create contexts with different transport options
|
|
637
|
+
# fast_endpoint = endpoint.with(timeout: 5)
|
|
638
|
+
# slow_endpoint = endpoint.with(timeout: 30)
|
|
639
|
+
# async_endpoint = endpoint.with(async: true)
|
|
640
|
+
def with(klass: self.class, namespace: @namespace, next_id: @next_id, **opts)
|
|
641
|
+
# raise errors on invalid options, and filter out ignored options
|
|
642
|
+
opts = @options_config.filter_opts(opts)
|
|
643
|
+
return self if klass == self.class && opts.empty?
|
|
644
|
+
|
|
645
|
+
merged_opts = @options_config.merge_opts(@opts, opts, filtered: true)
|
|
646
|
+
|
|
647
|
+
klass.new(**merged_opts, namespace:, next_id:, options_config: @options_config, &@transport)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Create a new endpoint context with a default result converter
|
|
651
|
+
# @param replace [Boolean] if true, replaces existing converter; if false, wraps it
|
|
652
|
+
# @yield [**response_opts, &result] result converter
|
|
653
|
+
# @return [Endpoint] new endpoint with converter
|
|
654
|
+
# @example Replace converter
|
|
655
|
+
# user_endpoint = endpoint.with_conversion { |**, &result| User.from_json(result.call) }
|
|
656
|
+
# @example Wrap existing converter
|
|
657
|
+
# validated_endpoint = user_endpoint.with_conversion(replace: false) do |**, &result|
|
|
658
|
+
# result.call.tap(&:validate!)
|
|
659
|
+
# end
|
|
660
|
+
# @example Remove converter
|
|
661
|
+
# raw_endpoint = user_endpoint.with_conversion(replace: true)
|
|
662
|
+
def with_conversion(replace: false, &converter)
|
|
663
|
+
raise ArgumentError, 'Block required with replace: false' unless replace || converter
|
|
664
|
+
|
|
665
|
+
with(converter: replace ? converter || DEFAULT_CONVERTER : json_rpc_wrap_converter(&converter))
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# @overload method_missing(method, *args, **kwargs, &converter)
|
|
669
|
+
# Redirects all method calls to {#json_rpc_invoke}
|
|
670
|
+
# @param method [Symbol] the RPC method to invoke (Symbol converted with ruby_to_json_rpc)
|
|
671
|
+
# @param args [Array] positional arguments
|
|
672
|
+
# @param kwargs [Hash] named arguments
|
|
673
|
+
# @yield [**response_opts, &result] optional result converter
|
|
674
|
+
# @return [Object] the result from the response (or the result of a converter block)
|
|
675
|
+
# @return [#value] a transport-specific future/promise (async operations)
|
|
676
|
+
# @return [nil] for notifications
|
|
677
|
+
# @raise [StandardError] the error from the response (or as rescued and re-raised by a converter block)
|
|
678
|
+
def method_missing(method, ...)
|
|
679
|
+
json_rpc_invoke(:next_id, method, ...)
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Fire and forget notification (ie does not get a response)
|
|
683
|
+
# @overload json_rpc_notify(method, *args, **kwargs)
|
|
684
|
+
# @param method [Symbol|String] the RPC method to invoke (Symbol converted with ruby_to_json_rpc)
|
|
685
|
+
# @param args [Array] positional arguments
|
|
686
|
+
# @param kwargs [Hash] named arguments
|
|
687
|
+
# @return [nil]
|
|
688
|
+
def json_rpc_notify(method, *, **)
|
|
689
|
+
(@opts[:async] ? with(async: false) : self).json_rpc_invoke(nil, method, *, **)
|
|
690
|
+
nil
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Execute JSON-RPC method asynchronously
|
|
694
|
+
# @overload json_rpc_async(id, method, *args, **kwargs, &converter)
|
|
695
|
+
# @param id [String|Integer|Symbol] request ID (:next_id for auto-generated, nil is not valid for explicit async)
|
|
696
|
+
# @param method [Symbol|String] the RPC method to invoke
|
|
697
|
+
# @param args [Array] positional arguments
|
|
698
|
+
# @param kwargs [Hash] named arguments
|
|
699
|
+
# @yield [**response_opts, &result] optional result converter
|
|
700
|
+
# @return [#value] a transport-specific future/promise
|
|
701
|
+
def json_rpc_async(id, method, ...)
|
|
702
|
+
(@opts[:async] ? self : with(async: true)).json_rpc_invoke(id, method, ...)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Invokes JSON-RPC
|
|
706
|
+
# Invokes a JSON-RPC method with explicit ID control
|
|
707
|
+
# @overload json_rpc_invoke(id, method, *args, **kwargs, &converter)
|
|
708
|
+
# @param id [String|Integer|Symbol|nil] request ID (:next_id for auto-generated, nil for notification)
|
|
709
|
+
# @param method [Symbol|String] the RPC method to invoke (Symbol converted with ruby_to_json_rpc)
|
|
710
|
+
# @param args [Array] positional arguments
|
|
711
|
+
# @param kwargs [Hash] named arguments
|
|
712
|
+
# @yield [**response_opts, &result] optional result converter (wraps the {#with_conversion current converter})
|
|
713
|
+
# @yieldparam response_opts [Hash] transport response options
|
|
714
|
+
# @yieldparam result [Proc] callback to get the JSON-RPC result or raise its error
|
|
715
|
+
# @yieldreturn [Object] the converted result
|
|
716
|
+
# @note As per JSON-RPC spec it is an error to provide both positional and named arguments
|
|
717
|
+
# @return [Object] the result from the response (or the result of a converter block)
|
|
718
|
+
# @return [#value] a transport-specific future/promise (async operations)
|
|
719
|
+
# @return [nil] for notifications
|
|
720
|
+
# @raise [StandardError] the error from the response (or as rescued and re-raised by a converter block)
|
|
721
|
+
# @example Explicit ID
|
|
722
|
+
# result = endpoint.json_rpc_invoke({}, 'custom-123', 'get_user', id: 456)
|
|
723
|
+
# @example Auto-generated ID
|
|
724
|
+
# result = endpoint.json_rpc_invoke({}, :next_id, 'get_user', id: 456)
|
|
725
|
+
# @example Notification (no response)
|
|
726
|
+
# endpoint.json_rpc_invoke({}, nil, 'log_event', message: 'test')
|
|
727
|
+
# @example With transport options
|
|
728
|
+
# result = endpoint.json_rpc_invoke({timeout: 30}, :next_id, 'get_user', id: 456)
|
|
729
|
+
def json_rpc_invoke(id, method, *, **, &)
|
|
730
|
+
# We need to do a final wrap of the converter so that it receives properly prefixed response options
|
|
731
|
+
wrapped_converter = proc do |**response_options, &result|
|
|
732
|
+
# prefix the incoming response options before our user land converters see them
|
|
733
|
+
response_options = @options_config.to_user_space(response_options)
|
|
734
|
+
json_rpc_wrap_converter(&).call(**response_options, &result)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# de prefix the outgoing request options, add wrapped converter
|
|
738
|
+
transport_opts = @options_config.to_transport_space(@opts.merge(converter: wrapped_converter))
|
|
739
|
+
|
|
740
|
+
self.class.invoke(transport_opts, *json_rpc_id_method(id, method), *, **, &@transport)
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Create a batch endpoint from this context
|
|
744
|
+
# @overload json_rpc_batch(**context_opts)
|
|
745
|
+
# @param context_opts [Hash] additional context options to merge with current context
|
|
746
|
+
# @return [Batch] batch endpoint with merged context
|
|
747
|
+
# @example Basic batch creation
|
|
748
|
+
# batch = endpoint.json_rpc_batch
|
|
749
|
+
# batch.get_user(id: 1).get_user(id: 2)
|
|
750
|
+
# results = batch.json_rpc_invoke
|
|
751
|
+
# @example Batch with context options
|
|
752
|
+
# batch = endpoint.with(timeout: 60).json_rpc_batch
|
|
753
|
+
# results = batch.get_user(id: 1).json_rpc_invoke
|
|
754
|
+
def json_rpc_batch(**)
|
|
755
|
+
with(klass: Batch, **)
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
end
|