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.
@@ -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