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,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
|