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,725 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers'
4
+ require_relative 'transport_options'
5
+
6
+ module JsonRpcKit
7
+ # This module provides a framework for receiving JSON-RPC requests, resolving them with Ruby methods,
8
+ # and sending responses.
9
+ #
10
+ # It is agnostic to the underlying transport (HTTP, MQTT, WebSocket) and provides a minimal concurrency abstraction
11
+ # for optional asynchronous processing.
12
+ #
13
+ # ## Key Service logic components
14
+ # - {Registry} Class level utilities to map JSON-RPC methods to Ruby methods.
15
+ # - {#json_rpc_call} - Service handler interface mapping JSON-RPC requests to business logic in Ruby.
16
+ # - {#json_rpc_async?} - Policy interface for determining which methods would benefit from asynchronous processing.
17
+ #
18
+ # ## Key Transport logic components
19
+ # - {Service.transport} - Create a transport handler
20
+ # - {Transport#json_rpc_transport} - The transport handler interface to dispatch incoming requests and send back
21
+ # responses.
22
+ #
23
+ # ## Quick Start
24
+ #
25
+ # ### 1. Define a Service
26
+ # Include this module in your class and define JSON-RPC mappings to methods
27
+ # ```ruby
28
+ # class UserService
29
+ # include JsonRpcKit::Service
30
+ #
31
+ # json_rpc_namespace 'users', async: true
32
+ #
33
+ # json_rpc :get_user
34
+ # def get_user(id)
35
+ # { id: id, name: "User #{id}" }
36
+ # end
37
+ #
38
+ # json_rpc :list_users
39
+ # def list_users(limit: 10)
40
+ # (1..limit).map { |i| { id: i, name: "User #{i}" } }
41
+ # end
42
+ # end
43
+ # ```
44
+ #
45
+ # ### 2. Create Transport Handler
46
+ # Use {.transport} to define a handler to process JSON-RPC requests
47
+ #
48
+ # ```ruby
49
+ # service = UserService.new
50
+ # handler = JsonRpcKit::Service.transport(service: service, merge: nil)
51
+ # ```
52
+ #
53
+ # ### 3. Handle Requests
54
+ # Call the handler with a JSON-RPC request and obtain a JSON-RPC response
55
+ # ```ruby
56
+ # # Synchronous (HTTP)
57
+ # response_json, opts = handler.call(request_json, request_opts)
58
+ #
59
+ # # Asynchronous (MQTT, WebSocket)
60
+ # handler.call(request_json, request_opts) do |response_json, opts|
61
+ # send_response(response_json, opts)
62
+ # end
63
+ # ```
64
+ #
65
+ module Service
66
+ # Registry methods added to the class that includes {Service}
67
+ # @example
68
+ # json_rpc :create_user, async: true # => 'createUser' (async: true)
69
+ # json_rpc_namespace 'users', async: true
70
+ # json_rpc :get_user # => "users.getUser" (async: true)
71
+ # json_rpc :list_users # => "users.listUsers" (async: true)
72
+ #
73
+ # json_rpc_namespace 'system', async: false
74
+ # json_rpc :ping # => "system.ping" (async: false)
75
+ #
76
+ # json_rpc_namespace nil, async: false
77
+ # json_rpc :ping # => "ping", async: false
78
+ # json_rpc_namespace 'users', async: nil
79
+ # json_rpc :get_user # !> Error 'no default async for namespace: 'users'
80
+ module Registry
81
+ include Helpers
82
+
83
+ # Simple method registry
84
+ # @return [Hash<String,Symbol>] map of json-rpc method names to ruby method names, as registered via {#json_rpc}
85
+ attr_reader :json_rpc_methods
86
+
87
+ # @return [Set] list of json-rpc method names that would benefit from parallel execution during batch operations
88
+ attr_reader :json_rpc_async_methods
89
+
90
+ # @!visibility private
91
+ def json_rpc_async
92
+ @json_rpc_async ||= {}
93
+ end
94
+
95
+ # Register a method for JSON-RPC dispatch
96
+ #
97
+ # @overload json_rpc(method, namespace: json_rpc_namespace:, async: json_rpc_async[namespace])
98
+ # Converts ruby method name to JSON_RPC (camelCase) with optional namespace
99
+ #
100
+ # In this form the default async will come from that stored in either the supplied or inherited namespace,
101
+ # or in the nil namespace.
102
+ #
103
+ # @param namespace [String] a namespace prefix for the method. Defaults from the most recent call to
104
+ # {json_rpc_namespace}
105
+ # @overload json_rpc(method, as:, async: async: json_rpc_async[nil])
106
+ # Use an explicit JSON-RPC method name.
107
+ #
108
+ # In this form the default async comes from that stored in the nil namespace
109
+ # @param as [String] a fully qualified JSON-RPC method name
110
+ # @param method [Symbol] the ruby method name (snake_case)
111
+ # @param async [Boolean] whether this method would benefit from asynchronous execution
112
+ # * `true` - method can be executed asynchronously (e.g., slow, IO-blocking operations)
113
+ # * `false` - method should be executed synchronously (e.g., fast, non-blocking operations)
114
+ # * Must be provided explicitly or inherited from a default stored via {json_rpc_namespace}
115
+ # @return [String] the registered JSON-RPC method name
116
+ def json_rpc(method, as: nil, namespace: as ? nil : json_rpc_namespace, async: :default)
117
+ as ||= ruby_to_json_rpc(method, namespace: namespace)
118
+
119
+ raise ArgumentError, 'async: must be explicitly true or false' unless [true, false, :default].include?(async)
120
+
121
+ if async == :default
122
+ async = json_rpc_async.fetch(namespace, json_rpc_async[nil])
123
+ raise ArgumentError, "no async for namespace:#{namespace || 'nil'}" unless [true, false].include?(async)
124
+ end
125
+
126
+ @json_rpc_methods ||= {}
127
+ @json_rpc_methods[as] = method
128
+
129
+ @json_rpc_async_methods ||= Set.new
130
+ @json_rpc_async_methods << as if async
131
+ as
132
+ end
133
+
134
+ # Set or get the default namespace for subsequent {json_rpc} declarations
135
+ #
136
+ # @overload json_rpc_namespace()
137
+ # Get the current namespace
138
+ # @return [String, nil] current namespace
139
+ #
140
+ # @overload json_rpc_namespace(namespace, async:)
141
+ # Set the namespace and its async default for subsequent {json_rpc} declarations
142
+ # @param namespace [String] namespace prefix for methods
143
+ # @param async [Boolean|nil] whether methods in this namespace would benefit from async execution,
144
+ # or nil to remove the default
145
+ # @return [String] the namespace
146
+ #
147
+ def json_rpc_namespace(*namespace, async: nil)
148
+ if namespace.any?
149
+ @json_rpc_namespace = namespace.first
150
+
151
+ raise ArgumentError, 'async: must be true, false or nil' unless async.nil? || [true, false].include?(async)
152
+
153
+ if async.nil?
154
+ json_rpc_async.delete(@json_rpc_namespace)
155
+ else
156
+ json_rpc_async[@json_rpc_namespace] = async
157
+ end
158
+ end
159
+ @json_rpc_namespace
160
+ end
161
+ end
162
+
163
+ # @!parse
164
+ # # @abstract Documents the task spawning interface
165
+ # class Task
166
+ # class << self
167
+ # # Called based on the type of transport (see {#json_rpc_transport})
168
+ # #
169
+ # # - For **synchronous transports**: Only called for inner {Transport#async_policy_proc async-hinted}
170
+ # # request tasks within batches
171
+ # # - For **asynchronous transports**: Called for single async-hinted requests or for batches with containing
172
+ # # async-hinted requests (async_count: > 0) and for those async-hinted item requests
173
+ # #
174
+ # # @yield &task to execute asynchronously
175
+ # # @yieldreturn [Object] Result of the task
176
+ # # @return [#value] Object that blocks until complete and returns result
177
+ # def async(&task)
178
+ # end
179
+ #
180
+ # # Called regardless of transport type or async hints.
181
+ # # Spawner can choose to spawn a task asynchronously or return nil for synchronous execution.
182
+ # #
183
+ # # @param task_type [Symbol] `:batch` or `:request`
184
+ # # @param request_opts [Hash] Mutable transport-specific options (e.g., for passing barriers between tasks)
185
+ # # @param context [Hash] Immutable request metadata
186
+ # # @option context [Integer] :count `:batch` Total items in the batch
187
+ # # @option context [Integer] :async_count `:batch` Number of items where async would be beneficial
188
+ # # @option context [Boolean] :async `:request` Whether async execution would be beneficial for this method
189
+ # # @option context [Boolean] :batch `:request` Whether this is a batch item (vs single request)
190
+ # # @option context [String, Integer, nil] :id `:request` JSON-RPC request id
191
+ # # @option context [String] :method `:request` JSON-RPC method name
192
+ # # @yield Task to execute
193
+ # # @return [#value] Object that blocks until complete and returns result
194
+ # # @return [nil] For synchronous execution (block executed immediately)
195
+ # # @example MQTT Spawner with Barrier (Full Control Interface)
196
+ # # spawner = proc do |task_type, request_opts, **context, &block|
197
+ # # watcher = request_opts[:timeout_watcher]
198
+ # # next nil unless watcher # Synchronous if no watcher
199
+ # #
200
+ # # case task_type
201
+ # # when :batch
202
+ # # next nil unless context[:async_count].positive?
203
+ # #
204
+ # # # Add a Barrier to request_opts so it is available to spawn the request tasks
205
+ # # request_opts[:barrier] = watcher.new_barrier
206
+ # #
207
+ # # # Wrap block with an ensure barrier.stop, so that all our async requests are timed out
208
+ # # # if this task is timed out.
209
+ # # watcher.with_timeout(timeout) { request_opts[:barrier].wait!(&block) }
210
+ # #
211
+ # # when :request
212
+ # # next nil unless context[:async]
213
+ # # request_opts[:barrier].async(&block)
214
+ # # end
215
+ # # end
216
+ # def call(task_type, request_opts, **context, &task)
217
+ # end
218
+ # end
219
+ #
220
+ # # Returns the result of the task block or raises its error
221
+ # #
222
+ # # @return [Object]
223
+ # # @raise [StandardError]
224
+ # def value()
225
+ # end
226
+ # end
227
+
228
+ SyncTask = Data.define(:result, :error)
229
+
230
+ # Implements the **Simple** TaskSpawner interface, but just runs tasks directly
231
+ class SyncTask < Data
232
+ # @!attribute [r] error
233
+ # @return [StandardError]
234
+
235
+ # yields the block
236
+ def self.async
237
+ new(result: yield, error: nil)
238
+ rescue StandardError => e
239
+ new(result: nil, error: e)
240
+ end
241
+
242
+ # returns the value, or raises the error
243
+ def value
244
+ raise error if error
245
+
246
+ result
247
+ end
248
+ end
249
+
250
+ # Encapsulates JSON-RPC transport configuration
251
+ #
252
+ # Created via {Service.transport}, this class holds the configuration for
253
+ # * manipulating transport request and response options
254
+ # * managing when and how asynchronous tasks are spawned
255
+ # * invoking a {Service#json_rpc_call} handler
256
+ #
257
+ # ## Task spawning interface
258
+ #
259
+ # Parallel execution can improve throughput when processing batch requests containing
260
+ # multiple independent operations, or when individual methods perform I/O or blocking operations.
261
+ #
262
+ # For naturally asynchronous transports (e.g., MQTT, message queues) that process requests
263
+ # on dedicated threads, spawning tasks prevents blocking the transport's message handling.
264
+ #
265
+ # The `async:` parameter to {Service.transport} accepts objects implementing either:
266
+ # - {Task.async #async} - Simple Interface that fully respects async hints
267
+ # - {Task.call #call} - Full Control Interface for custom spawning logic
268
+ #
269
+ # Async hints come from {Service#json_rpc_async?} metadata, indicating which methods
270
+ # would benefit from parallel execution. See {#async_policy_proc} for the hint provider.
271
+ #
272
+ # ### Task Nesting
273
+ #
274
+ # Batch Request - requests nested inside batch, can pass information from batch to request via opts
275
+ # ```
276
+ # :batch,opts={}, (context: { count: 3, async_count: 2 }) # opts[:x] = 'y')
277
+ # └─> :request,{x: 'y'} (context: { async: true, batch: true, id: "xx-1", method: "foo" })
278
+ # └─> :request,{x:,'y'} (context: { async: true, batch: true, id: "yy-32", method: "bar" })
279
+ # └─> :request,{x:,'y'} (context: { async: false, batch: true, id: "zz-43", method: "baz" })
280
+ # ```
281
+ # Single Request - no nesting:
282
+ # ```
283
+ # :request, opts={} (context: { async: true, batch: false, id: 1234, method: "foo" })
284
+ # ```
285
+ #
286
+ # ### Task Interface
287
+ #
288
+ # Objects returned by spawners must implement:
289
+ #
290
+ # ```ruby
291
+ # task.value # => block until complete and then return the task result or raise its error
292
+ # ```
293
+ class Transport
294
+ class << self
295
+ # @!visibility private
296
+ def service_proc(service:, &service_proc)
297
+ return service_proc if service_proc
298
+
299
+ if service.respond_to?(:json_rpc_call)
300
+ service.method(:json_rpc_call).to_proc
301
+ elsif service.respond_to?(:to_proc)
302
+ service.to_proc
303
+ elsif service.respond_to?(:call)
304
+ ->(*a, **kw) { service.call(*a, **kw) }
305
+ else
306
+ raise ArgumentError, 'No valid service: or block provided'
307
+ end.tap { it.call({}, {}, nil, 'rpc.validate') }
308
+ end
309
+
310
+ # @!visibility private
311
+ def async_policy_proc(service:, async_policy: :not_set)
312
+ if async_policy == :not_set
313
+ if service.respond_to?(:json_rpc_async?)
314
+ service.method(:json_rpc_async?).to_proc
315
+ else
316
+ ->(*, **) { false }
317
+ end
318
+ elsif async_policy.nil? || [true, false].include?(async_policy)
319
+ ->(*, **) { async_policy ? true : false }
320
+ elsif async_policy.respond_to?(:to_proc)
321
+ async_policy.to_proc
322
+ elsif async_policy.respond_to?(:call)
323
+ ->(*a, **kw) { async_policy.call(*a, **kw) }
324
+ else
325
+ raise ArgumentError, "Invalid async_policy: #{async_policy.class.name}"
326
+ end.tap { it.call({}, id: 'validation', method: 'rpc.validate') }
327
+ end
328
+
329
+ # @!visibility private
330
+ def async(async:)
331
+ return SyncTask unless async
332
+
333
+ async = async.to_proc if async.respond_to?(:to_proc) && !async.respond_to?(:call)
334
+ async.tap { validate_async!(async:) }
335
+ end
336
+
337
+ def validate_async!(async:)
338
+ if async.respond_to?(:call)
339
+ async.call(:request, {}, id: nil, method: 'rpc.validate', async: false, batch: false) { :validate }&.value
340
+ async.call(:batch, {}, async_count: 0, count: 0) do
341
+ async.call(:request, {}, id: nil, method: 'rpc.validate', async: false, batch: false) do
342
+ :validate
343
+ end&.value
344
+ end&.value
345
+ elsif async.respond_to?(:async)
346
+ async.async { :validate }.value
347
+ else
348
+ raise ArgumentError, "async:(#{async.class.name}): must implement #call or #async"
349
+ end
350
+ end
351
+ end
352
+
353
+ # @return [Proc<{#json_rpc_call}>] Proc used to invoke the {Service} with a request
354
+ attr_reader :service_proc
355
+
356
+ # Provides hints for which requests could benefit from asynchronous processing.
357
+ # Defaults to the service's {JsonRpcKit::Service#json_rpc_async? json_rpc_async?} method if `:async_policy` is
358
+ # not explicitly provided.
359
+ # @return [Proc] Proc<{JsonRpcKit::Service#json_rpc_async? json_rpc_async?}>
360
+ attr_reader :async_policy_proc
361
+
362
+ # @!visibility private
363
+ def initialize(
364
+ async: nil, async_policy: :not_set,
365
+ service: nil, **transport_opts, &service_proc
366
+ )
367
+ @service_proc = Transport.service_proc(service:, &service_proc)
368
+ @async = Transport.async(async:)
369
+ @async_policy_proc = Transport.async_policy_proc(service:, async_policy:)
370
+ @options_config = TransportOptions.create_from_opts(transport_opts)
371
+
372
+ raise ArgumentError, "Unknown options #{transport_opts.keys}" unless transport_opts.empty?
373
+ end
374
+
375
+ # Transport options configuration
376
+ #
377
+ # Handles prefix, filter, and merge for request/response options:
378
+ # - Request options received from transport are prefixed before passing to service
379
+ # - Response options from service are de-prefixed, filtered, and merged before returning to transport
380
+ #
381
+ # @return [TransportOptions]
382
+ attr_reader :options_config
383
+
384
+ # rubocop:disable Style/OptionalBooleanParameter
385
+
386
+ # The task spawning proc derived from the `:async` parameter to {.transport}
387
+ # @!attribute [r] async_proc
388
+ # @return [Proc<Task.async>] Simple Interface task spawner (if `async:` parameter implements `#async`)
389
+ # @return [Proc<Task.call>] Full Control Interface task spawner (if `async:` parameter implements `#call`)
390
+ def async_proc(async_transport = false)
391
+ # Strictly a transport could sometimes send a callback and sometimes not. Highly unlikely
392
+ @async_procs ||= {}
393
+ @async_procs[async_transport] ||=
394
+ if @async.respond_to?(:call)
395
+ ->(*request_info, **context, &block) { @async.call(*request_info, **context, &block) }
396
+ elsif @async.respond_to?(:async)
397
+ ->(*, **context, &block) { @async.async(&block) if simple_async?(async_transport, **context) }
398
+ end
399
+ end
400
+ # rubocop:enable Style/OptionalBooleanParameter
401
+
402
+ # @!visibility private
403
+ def simple_async?(async_transport, async: nil, async_count: 0, batch: false, **)
404
+ # If transport is asynchronous, respect the hint - outer request is single async, or batch has async requests
405
+ # If transport is synchronous, only do batch requests that are hinted as async
406
+ async_transport ? (async || async_count.positive?) : (async && batch)
407
+ end
408
+
409
+ # Transport handler interface for incoming JSON-RPC requests.
410
+ #
411
+ # This is the signature of the Proc returned to {Service.transport}
412
+ #
413
+ # **Transport type:**
414
+ #
415
+ # The presence of `&transport_callback` distinguishes transport types:
416
+ # - **Synchronous transports** (HTTP, stdio): No callback - handler blocks and returns result
417
+ # - **Asynchronous transports** (MQTT, message queues): Callback provided - handler returns immediately
418
+ #
419
+ # Asynchronous transports typically process requests on dedicated message threads and use
420
+ # the callback and asynchronous tasks to avoid blocking.
421
+ #
422
+ # This impacts the behaviour of the {Task.async Simple} TaskSpawner interface in terms of which tasks are
423
+ # processed asynchronously.
424
+ #
425
+ # Note that a {Task.call Full Control} TaskSpawner is called regardless of the transport type and can choose to
426
+ # block or not as necessary.
427
+ #
428
+ # @param request_json [String] JSON-RPC request (single or batch)
429
+ # @param request_opts [Hash] Transport metadata (will be prefixed if configured)
430
+ # @yield [response_json, response_opts] optional &callback for async transports
431
+ # @yieldparam response_json [String|nil] JSON-RPC response (nil if all notifications)
432
+ # @yieldparam response_opts [Hash] Filtered and merged response options
433
+ # @return [#value] **asynchronous** transport
434
+ # @return [[String|nil, Hash]] `[response_json, response_opts]` **synchronous** transport (no &callback given)
435
+ def json_rpc_transport(request_json, request_opts = {}, &)
436
+ Request.new(request_json, request_opts, transport: self).execute(&)
437
+ end
438
+
439
+ # Returns the transport handler as a proc.
440
+ #
441
+ # @return [Proc] Handler proc wrapping {#json_rpc_transport}
442
+ def to_proc
443
+ method(:json_rpc_transport).to_proc
444
+ end
445
+
446
+ def reduce_response_options(*response_options_list)
447
+ options_config.reduce_to_transport_space(*response_options_list)
448
+ end
449
+ end
450
+
451
+ # @!visibility private
452
+ class Request
453
+ # @!visibility private
454
+ class << self
455
+ include Helpers
456
+
457
+ # Parse the JSON and tag/augment/enrich
458
+ def parse_with_async_policy(request_json, request_opts, &async_policy)
459
+ request = parse_request(request_json, **request_opts.slice(:content_type))
460
+
461
+ batch = request.is_a?(Array)
462
+ async_count = (batch ? request : [request]).count do |r|
463
+ async_policy.call(request_opts, **r.slice(:id, :method)).tap do |async|
464
+ r.merge!(async: async ? true : false, batch:)
465
+ end
466
+ end
467
+
468
+ [batch, request, async_count]
469
+ end
470
+ end
471
+
472
+ # @!visibility private
473
+ attr_reader :request_opts, :frozen_request_opts, :async_count, :request, :transport, :parse_error
474
+
475
+ # @!visibility private
476
+ def initialize(request_json, request_opts, transport:)
477
+ @transport = transport
478
+ # The transport space request opts are available to the async proc
479
+ @request_opts = request_opts
480
+ # Frozen, user space request opts are available to the async_policy: and service: handler
481
+ @frozen_request_opts = transport.options_config.to_user_space(request_opts).freeze
482
+
483
+ @batch, @request, @async_count =
484
+ Request.parse_with_async_policy(request_json, @frozen_request_opts, &transport.async_policy_proc)
485
+ rescue StandardError => e
486
+ @parse_error = Error.rescue_error(nil, e)
487
+ end
488
+
489
+ # Process the request - block is the transport_callback
490
+ def execute(&)
491
+ task =
492
+ if parse_error
493
+ parse_error_task(&)
494
+ elsif batch?
495
+ batch_task(&)
496
+ else
497
+ single_request_task(&)
498
+ end
499
+
500
+ block_given? ? task : task.value
501
+ end
502
+
503
+ private
504
+
505
+ def batch?
506
+ @batch
507
+ end
508
+
509
+ def parse_error_task(&)
510
+ SyncTask.async { respond(parse_error.to_json, &) }
511
+ end
512
+
513
+ def batch_task(&)
514
+ async_call(:batch, block_given?, count: request.size, async_count:) do
515
+ respond(*handle_batch(*request, &transport.service_proc), &)
516
+ end
517
+ end
518
+
519
+ def single_request_task(&)
520
+ async_call(:request, block_given?, **request.slice(:async, :batch, :id, :method)) do
521
+ respond(*handle_request(**request, &transport.service_proc), &)
522
+ end
523
+ end
524
+
525
+ def async_call(request_type, async_transport = nil, **context, &)
526
+ transport.async_proc(async_transport)&.call(request_type, request_opts, **context, &) || SyncTask.async(&)
527
+ end
528
+
529
+ def handle_batch(*requests, &)
530
+ tasks = requests.map do |r|
531
+ # Inner async call
532
+ async_call(:request, **r.slice(:batch, :async, :id, :method)) do
533
+ handle_request(**r, &) # NOTE: batch: true is embedded in r by .parse
534
+ end
535
+ end.map(&:value).select(&:first) # Filter out notifications
536
+ return nil if tasks.empty?
537
+
538
+ result_list, response_opts_list = tasks.transpose
539
+ [result_list.to_json, *response_opts_list]
540
+ rescue StandardError => e
541
+ # expect this is json generation error (since handle_request rescues errors)
542
+ # some non JSONable object in the results
543
+ [Error.rescue_error(nil, e).to_json, *response_opts_list]
544
+ end
545
+
546
+ def handle_request(batch:, id: nil, method: nil, params: [], **_, &service)
547
+ args, kwargs = params.is_a?(Array) ? [params, {}] : [[], params]
548
+ result = service.call(frozen_request_opts, response_opts = {}, id, method, *args, **kwargs)
549
+ rpc_result = id ? { jsonrpc: '2.0', id: id, result: result } : nil
550
+ return [nil, response_opts] unless rpc_result
551
+
552
+ batch ? [rpc_result, response_opts] : [rpc_result.to_json, response_opts]
553
+ rescue StandardError => e
554
+ rpc_error = Error.rescue_error(id, e)
555
+ batch ? [rpc_error, response_opts] : [rpc_error.to_json, response_opts]
556
+ end
557
+
558
+ def respond(response_json, *response_opts_list, &callback)
559
+ return callback&.call(nil, {}) unless response_json
560
+
561
+ # TODO: Somehow here we need to log bad response options, but we don't have a logging facility
562
+ response = [response_json, transport.reduce_response_options(*response_opts_list)]
563
+ callback&.call(*response) || response
564
+ end
565
+ end
566
+
567
+ class << self
568
+ include Helpers
569
+
570
+ # Configures a {Transport} and wraps it in a handler proc for processing incoming JSON-RPC requests
571
+ #
572
+ # This is the entry point for creating JSON-RPC service handlers that work with
573
+ # various transports (HTTP, MQTT, WebSocket) and concurrency models (Threads, Fibers, Async gem).
574
+ #
575
+ # @overload transport(service:,async: nil, async_policy: nil, **transport_opts)
576
+ # @param service [#json_rpc_call] Service implementation
577
+ # @param async [#call, #async, nil] see {Transport#async_proc Transport#async_proc}
578
+ # @param async_policy [Boolean, #call, nil] see {Transport#async_policy_proc Transport#async_policy_proc}
579
+ # @param transport_opts [Hash] configure {TransportOptions}
580
+ # @option transport_opts :prefix,:merge,:filter,:ignore [Object] see {TransportOptions}
581
+ #
582
+ # @overload transport(service, async: nil, async_policy: nil,**transport_opts)
583
+ # Positional service argument (sugar for service: keyword)
584
+ #
585
+ # @overload transport(async: nil, async_policy: nil, **transports_opts, &service_proc)
586
+ # Service handler as a block argument
587
+ # @yield [request_opts, response_opts, id, method, *args, **kwargs] Service handler (see {#json_rpc_call})
588
+ #
589
+ # @return [Proc] request handler Proc<{Transport#json_rpc_transport}>
590
+ #
591
+ # @example HTTP/Rack Transport (synchronous)
592
+ # handler = JsonRpcKit::Service.transport(
593
+ # prefix: 'http',
594
+ # filter: %i[status headers],
595
+ # merge: proc { |k, old, new|
596
+ # case k
597
+ # when :status then [old, new].max
598
+ # when :headers then old.merge(new)
599
+ # end
600
+ # }
601
+ # ) do |request_opts, response_opts, id, method, *args, **kwargs|
602
+ # # Handle request
603
+ # end
604
+ #
605
+ # # In Rack app
606
+ # def call(env)
607
+ # request_opts = { headers: extract_headers(env) }
608
+ # response_json, opts = handler.call(env['rack.input'].read, request_opts)
609
+ # [opts[:status] || 200, {'Content-Type' => 'application/json'}.merge(opts[:headers]), [response_json]]
610
+ # end
611
+ def transport(service_arg = nil, service: service_arg, **transport_opts, &service_proc)
612
+ Transport.new(service:, **transport_opts, &service_proc).to_proc
613
+ end
614
+
615
+ def included(base)
616
+ base.extend(Registry)
617
+ base.json_rpc :list_methods, namespace: 'system', async: false
618
+ end
619
+ end
620
+
621
+ # Simple discovery of available method names, automatically bound as `system.listMethods`
622
+ def list_methods
623
+ self.class.json_rpc_methods&.keys
624
+ end
625
+
626
+ # Get a transport handler proc for this service.
627
+ #
628
+ # Convenience method that calls {Service.transport} with this service instance.
629
+ #
630
+ # @param transport [Hash] Transport options (see {Service.transport})
631
+ # @return [Proc] Handler proc (see {Transport#to_proc})
632
+ def json_rpc_transport(**transport)
633
+ Service.transport(**transport, service: self)
634
+ end
635
+
636
+ # Determine if async execution would be beneficial for a JSON-RPC method.
637
+ #
638
+ # This method provides the AsyncPolicy interface. It's called to determine whethertransport
639
+ # spawning an async task for a method would be beneficial (e.g., for I/O-bound operations,
640
+ # slow computations, or methods that yield control).
641
+ #
642
+ # Override this method to provide custom, per-request logic based on authentication,
643
+ # rate limits, or other request context.
644
+ #
645
+ # The default implementation uses the async metadata from {Registry.json_rpc} declarations.
646
+ #
647
+ # @overload json_rpc_async?(request_opts, id, method)
648
+ # @param request_opts [Hash] Frozen, prefixed transport metadata from the request
649
+ # @param id [String, Integer, nil] JSON-RPC request id
650
+ # @param method [String] JSON-RPC method name
651
+ # @return [Boolean] true if async execution would be beneficial for this method
652
+ #
653
+ # @example Custom async logic
654
+ # def json_rpc_async?(request_opts, id, method)
655
+ # # Only async for premium users
656
+ # return false unless request_opts[:user_tier] == :premium
657
+ # super
658
+ # end
659
+ def json_rpc_async?(_request_opts, _id, method)
660
+ self.class.json_rpc_async_methods&.include?(method)
661
+ end
662
+
663
+ # Handle a JSON-RPC request.
664
+ #
665
+ # This is called by the transport handler for each request. The default implementation:
666
+ # 1. Finds the ruby method associated with the JSON-RPC method (from {Registry.json_rpc_methods})
667
+ # 2. Passes request_opts to {#json_rpc_route} to determine which object should receive the call
668
+ # 3. Passes the args/kwargs to the ruby method on the receiver
669
+ #
670
+ # @note Custom implementations should validate method calls (e.g., to avoid exposing `instance_eval`)
671
+ #
672
+ # rubocop:disable Metrics/ParameterLists
673
+
674
+ # @param request_opts [Hash] Frozen, prefixed transport metadata from the request
675
+ # @param response_opts [Hash] Mutable hash for populating response options (e.g., HTTP status, headers)
676
+ # @param id [String, Integer, nil] JSON-RPC request id (nil for notifications)
677
+ # @param method [String] JSON-RPC method name
678
+ # @param args [Array] Positional parameters from the JSON-RPC request
679
+ # @param kwargs [Hash] Named parameters from the JSON-RPC request
680
+ # @return [Object] JSON-serializable result
681
+ # @raise [Error, StandardError] Error to be encapsulated in JSON-RPC error response
682
+ def json_rpc_call(request_opts, response_opts, id, method, *args, **kwargs)
683
+ return true if method == 'rpc.validate'
684
+
685
+ rb_method = self.class.json_rpc_methods[method]
686
+ service = json_rpc_route(request_opts, response_opts, method, args, kwargs, via: rb_method) if rb_method
687
+ raise NoMethodError, "No RPC service for #{method}" unless service&.respond_to?(method) # rubocop:disable Lint/RedundantSafeNavigation
688
+
689
+ service.public_send(method, *args, **kwargs)
690
+ rescue StandardError => e
691
+ json_rpc_error(request_opts, id, method, e) if respond_to?(:json_rpc_error)
692
+ raise
693
+ end
694
+ # rubocop:enable Metrics/ParameterLists
695
+
696
+ # @!method json_rpc_error(request_opts, id, json_method, error)
697
+ # @abstract define to log or transform errors
698
+ # @param [Hash] request_opts (frozen)
699
+ # @param [String|Integer|nil] id
700
+ # @param [String] json_method
701
+ # @param [StandardError] error
702
+ # @return [void]
703
+
704
+ # Routes the JSON-RPC method to a ruby object.
705
+ # @abstract Override this method to route to another object, based on namespace, or options
706
+ # provided by the transport (MQTT topic, HTTP headers...).
707
+ #
708
+ # Default implementation routes to self.
709
+ #
710
+ # Positional and named arguments can also be mutated here, eg to convert simple Hash to Data/Struct
711
+ #
712
+ # @overload json_rpc_route(request_opts, response_opts, method, args, kwargs, via:)
713
+ # @param request_opts [Hash<Symbol>] (frozen) options from the transport that received the request
714
+ # @param response_opts [Hash<Symbol>] (mutable) options for the transport response
715
+ # @param method [String] method name as received in the JSON-RPC request
716
+ # @param args [Array] positional arguments (mutable)
717
+ # @param kwargs [Hash] json object argument (mutable)
718
+ # @param via [Symbol] the ruby method name as registered with {.json_rpc}
719
+ # @return [Object] receiver for the method call
720
+ # @return [nil] to ignore the request (will raise a JSON-RPC NoMethodError to the caller)
721
+ def json_rpc_route(*, via: nil)
722
+ defined?(super) ? super : self
723
+ end
724
+ end
725
+ end