ultimate_json_rpc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module UltimateJsonRpc
6
+ GENERIC_ERROR_DATA = "Internal server error"
7
+ GENERIC_PARAMS_DATA = "Invalid method parameters"
8
+ private_constant :GENERIC_ERROR_DATA, :GENERIC_PARAMS_DATA
9
+
10
+ module BatchProcessor
11
+ private
12
+
13
+ def handle_batch(requests)
14
+ return @json.generate(Core::Response.error(Core::INVALID_REQUEST, nil)) if requests.empty?
15
+ if batch_too_large?(requests)
16
+ return @json.generate(Core::Response.error(Core::INVALID_REQUEST, nil, data: "Batch too large"))
17
+ end
18
+
19
+ json_parts = process_batch_items(requests)
20
+ json_parts.empty? ? nil : "[#{json_parts.join(",")}]"
21
+ end
22
+
23
+ def process_batch_items(requests)
24
+ if @concurrent_batches
25
+ pool_dispatch(requests).compact
26
+ else
27
+ requests.filter_map { |req| serialize_single(req) }
28
+ end
29
+ end
30
+
31
+ def pool_dispatch(requests)
32
+ queue = Queue.new
33
+ requests.each_with_index { |req, i| queue << [req, i] }
34
+ results = Array.new(requests.size)
35
+ spawn_workers(queue:, results:, pool_size: [requests.size, @max_concurrency].min)
36
+ results
37
+ end
38
+
39
+ def spawn_workers(queue:, results:, pool_size:)
40
+ mutex = Mutex.new
41
+ pool_size.times.map do
42
+ Thread.new { drain_queue(queue, results, mutex) }
43
+ end.each(&:join)
44
+ end
45
+
46
+ def drain_queue(queue, results, mutex)
47
+ loop do
48
+ req, i = begin
49
+ queue.pop(true)
50
+ rescue ThreadError
51
+ break
52
+ end
53
+ result = safe_serialize(req)
54
+ mutex.synchronize { results[i] = result }
55
+ end
56
+ end
57
+
58
+ def safe_serialize(data)
59
+ serialize_single(data)
60
+ rescue Exception # rubocop:disable Lint/RescueException -- worker threads must never die silently
61
+ id = data.is_a?(Hash) ? extract_id(data) : nil
62
+ begin
63
+ @json.generate(Core::Response.error(Core::INTERNAL_ERROR, id))
64
+ rescue Exception # rubocop:disable Lint/RescueException
65
+ id_json = format_fallback_id(id)
66
+ "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"},\"id\":#{id_json}}"
67
+ end
68
+ end
69
+
70
+ def format_fallback_id(id)
71
+ case id
72
+ when Numeric then id.to_s
73
+ when String then JSON.generate(id) rescue "null" # rubocop:disable Style/RescueModifier
74
+ else "null"
75
+ end
76
+ end
77
+
78
+ def batch_too_large?(requests) = @max_batch_size && requests.size > @max_batch_size
79
+ end
80
+ private_constant :BatchProcessor
81
+
82
+ module OpenRPCBuilder
83
+ private
84
+
85
+ def build_openrpc_document
86
+ doc = { "openrpc" => "1.3.2", "info" => build_info, "methods" => @handler.methods_info }
87
+ doc.delete("info") if doc["info"].empty?
88
+ doc["components"] = { "errors" => @error_catalog } if @error_catalog&.any?
89
+ doc
90
+ end
91
+
92
+ def build_info
93
+ { "title" => @name, "version" => @version, "description" => @description }.compact
94
+ end
95
+ end
96
+ private_constant :OpenRPCBuilder
97
+
98
+ module ServerExtensions
99
+ def register_error(code:, message:, description: nil)
100
+ raise ArgumentError, "error code must be an Integer" unless code.is_a?(Integer)
101
+
102
+ if code.between?(Core::RESERVED_ERROR_MIN, Core::RESERVED_ERROR_MAX)
103
+ raise ArgumentError,
104
+ "error code #{code} is in the reserved JSON-RPC range " \
105
+ "(#{Core::RESERVED_ERROR_MIN}..#{Core::RESERVED_ERROR_MAX})"
106
+ end
107
+
108
+ @error_catalog ||= []
109
+ raise ArgumentError, "error code #{code} is already registered" if @error_catalog.any? { |e| e["code"] == code }
110
+
111
+ entry = { "code" => code, "message" => message.to_s }
112
+ entry["data"] = description.to_s if description
113
+ @error_catalog << entry
114
+ self
115
+ end
116
+
117
+ def authorize(*patterns, code: 403, message: "Forbidden", &block)
118
+ raise ArgumentError, "block required" unless block
119
+
120
+ validate_authorize_code!(code)
121
+
122
+ opts = patterns.empty? ? {} : { only: patterns }
123
+ use(**opts) do |request, next_call|
124
+ raise Core::ApplicationError.new(code:, message:) unless block.call(request)
125
+
126
+ next_call.call
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def validate_authorize_code!(code)
133
+ raise ArgumentError, "authorize code must be an Integer" unless code.is_a?(Integer)
134
+ return unless code.between?(Core::RESERVED_ERROR_MIN, Core::RESERVED_ERROR_MAX)
135
+
136
+ raise ArgumentError,
137
+ "authorize code #{code} is in the reserved JSON-RPC range " \
138
+ "(#{Core::RESERVED_ERROR_MIN}..#{Core::RESERVED_ERROR_MAX})"
139
+ end
140
+ end
141
+ private_constant :ServerExtensions
142
+
143
+ class Server
144
+ include BatchProcessor
145
+ include OpenRPCBuilder
146
+ include ServerExtensions
147
+
148
+ attr_reader :name, :version, :description, :max_batch_size, :timeout, :json_adapter
149
+
150
+ HOOK_EVENTS = %i[request response error].freeze
151
+ private_constant :HOOK_EVENTS
152
+
153
+ def initialize(name: nil, version: nil, description: nil, max_batch_size: 100, expose_errors: false,
154
+ timeout: nil, concurrent_batches: false, max_concurrency: 8, json: JSON)
155
+ @name = name
156
+ @version = version
157
+ @description = description
158
+ @max_batch_size = max_batch_size
159
+ @expose_errors = expose_errors
160
+ @timeout = timeout
161
+ @concurrent_batches = concurrent_batches
162
+ @max_concurrency = max_concurrency
163
+ @json = @json_adapter = json
164
+ @handler = Core::Handler.new
165
+ @middleware = []
166
+ @hooks = HOOK_EVENTS.to_h { |e| [e, []] }
167
+ end
168
+
169
+ def expose(target, namespace: nil, only: nil, except: nil, descriptions: nil, returns: nil, deprecated: nil,
170
+ params_schema: nil)
171
+ @handler.expose(target, namespace:, only:, except:, descriptions:, returns:, deprecated:, params_schema:)
172
+ self
173
+ end
174
+
175
+ def expose_method(name, callable = nil, description: nil, returns: nil, deprecated: nil, params_schema: nil, &)
176
+ @handler.expose_method(name, callable, description:, returns:, deprecated:, params_schema:, &)
177
+ self
178
+ end
179
+
180
+ def use(only: nil, except: nil, &block)
181
+ raise ArgumentError, "block required" unless block
182
+ raise ArgumentError, "cannot use both :only and :except" if only && except
183
+
184
+ @middleware << [block, middleware_matcher(only, except)]
185
+ self
186
+ end
187
+
188
+ def on(event, &block)
189
+ raise ArgumentError, "block required" unless block
190
+ raise ArgumentError, "unknown event: #{event}" unless HOOK_EVENTS.include?(event)
191
+
192
+ @hooks[event] << block
193
+ self
194
+ end
195
+
196
+ def handle(json_string)
197
+ data = @json.parse(json_string)
198
+ rescue StandardError
199
+ @json.generate(Core::Response.error(Core::PARSE_ERROR, nil))
200
+ else
201
+ handle_parsed(data)
202
+ end
203
+
204
+ alias call handle
205
+
206
+ def handle_parsed(data)
207
+ case data
208
+ when Array then handle_batch(data)
209
+ when Hash then serialize_single(data)
210
+ else @json.generate(Core::Response.error(Core::INVALID_REQUEST, nil))
211
+ end
212
+ end
213
+
214
+ def to_proc = method(:call).to_proc
215
+ def inspect = "#<#{self.class}#{" name=#{@name.inspect}" if @name} methods=#{size} middleware=#{@middleware.size}>"
216
+ def expose_errors? = @expose_errors
217
+ def concurrent_batches? = @concurrent_batches
218
+
219
+ def freeze
220
+ return self if frozen?
221
+
222
+ @hooks.each_value(&:freeze)
223
+ [@handler, @middleware, @hooks].each(&:freeze)
224
+ @error_catalog&.each(&:freeze)
225
+ @error_catalog&.freeze
226
+ precompile_middleware
227
+ super
228
+ end
229
+
230
+ def methods_list = @handler.methods_list
231
+ def methods_info = @handler.methods_info
232
+ def method?(method_name) = @handler.method?(method_name)
233
+ def size = @handler.size
234
+ def empty? = @handler.empty?
235
+
236
+ private
237
+
238
+ def serialize_single(data)
239
+ request = parse_request(data)
240
+ return request unless request.is_a?(Core::Request)
241
+
242
+ response = execute_request(request)
243
+ return nil unless response
244
+
245
+ @json.generate(response)
246
+ rescue StandardError
247
+ return nil if request.is_a?(Core::Request) && request.notification?
248
+
249
+ id = request.is_a?(Core::Request) ? request.id : extract_id(data)
250
+ begin
251
+ @json.generate(Core::Response.error(Core::INTERNAL_ERROR, id))
252
+ rescue StandardError
253
+ id_json = format_fallback_id(id)
254
+ "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"},\"id\":#{id_json}}"
255
+ end
256
+ end
257
+
258
+ def parse_request(data)
259
+ Core::Request.new(data)
260
+ rescue Core::InvalidRequest
261
+ @json.generate(Core::Response.error(Core::INVALID_REQUEST, extract_id(data)))
262
+ end
263
+
264
+ def extract_id(data)
265
+ return nil unless data.is_a?(Hash)
266
+
267
+ data["id"].then { |id| id.nil? || id.is_a?(String) || id.is_a?(Numeric) ? id : nil }
268
+ end
269
+
270
+ def execute_request(request)
271
+ emit(:request, request)
272
+ result, error, _duration = timed_dispatch(request)
273
+ error ? handle_dispatch_error(request:, error:) : handle_dispatch_success(request, result)
274
+ end
275
+
276
+ def timed_dispatch(request)
277
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
278
+ result = with_timeout { build_chain(request).call }
279
+ duration = elapsed(start)
280
+ emit(:response, request, result, duration)
281
+ [result, nil, duration]
282
+ rescue StandardError => e
283
+ duration = elapsed(start)
284
+ emit(:error, request, e, duration)
285
+ [nil, e, duration]
286
+ end
287
+
288
+ def handle_dispatch_success(request, result)
289
+ request.notification? ? nil : Core::Response.success(result, request.id)
290
+ end
291
+
292
+ def handle_dispatch_error(request:, error:)
293
+ return nil if request.notification?
294
+
295
+ code, message, data = error_details(error)
296
+ Core::Response.error(code, request.id, data: data, message: message)
297
+ end
298
+
299
+ def emit(event, *args)
300
+ @hooks[event].each do |hook|
301
+ hook.call(*args)
302
+ rescue StandardError => e
303
+ Kernel.warn "UltimateJsonRpc: #{event} hook error: #{e.message}"
304
+ end
305
+ end
306
+
307
+ def elapsed(start) = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
308
+
309
+ def precompile_middleware
310
+ methods = @handler.methods_list + ["rpc.discover"]
311
+ @middleware_index = methods.to_h do |name|
312
+ [name, @middleware.select { |_, matcher| matcher.call(name) }.freeze]
313
+ end.freeze
314
+ end
315
+
316
+ def build_chain(request)
317
+ applicable = @middleware_index&.[](request.method_name) ||
318
+ @middleware.select { |_, matcher| matcher.call(request.method_name) }
319
+ applicable.reverse.reduce(-> { invoke_handler(request) }) do |core, (mw, _)|
320
+ -> { mw.call(request, core) }
321
+ end
322
+ end
323
+
324
+ def middleware_matcher(only, except)
325
+ return ->(_) { true } unless only || except
326
+
327
+ patterns = Array(only || except).map(&:to_s)
328
+ check = only ? :any? : :none?
329
+ ->(name) { patterns.public_send(check) { |p| p.include?("*") ? File.fnmatch(p, name) : p == name } }
330
+ end
331
+
332
+ def with_timeout(&)
333
+ return yield unless @timeout
334
+
335
+ Timeout.timeout(@timeout, &)
336
+ rescue Timeout::Error
337
+ raise Core::RequestTimeout
338
+ end
339
+
340
+ def invoke_handler(request)
341
+ return @handler.call(request.method_name, request.params) unless request.method_name == "rpc.discover"
342
+
343
+ build_openrpc_document
344
+ end
345
+
346
+ def error_details(err)
347
+ case err
348
+ when Core::MethodNotFound
349
+ [Core::METHOD_NOT_FOUND, Core::ERROR_MESSAGES[Core::METHOD_NOT_FOUND], generic_data(err)]
350
+ when Core::ApplicationError, Core::ServerError then [err.code, err.message, err.rpc_data]
351
+ when Core::InvalidParams then [Core::INVALID_PARAMS, nil, generic_data(err, GENERIC_PARAMS_DATA)]
352
+ when Core::InvalidRequest then [Core::INVALID_REQUEST, nil, generic_data(err)]
353
+ else [Core::INTERNAL_ERROR, nil, generic_data(err)]
354
+ end
355
+ end
356
+
357
+ def generic_data(err, fallback = GENERIC_ERROR_DATA) = @expose_errors ? err.message : fallback
358
+ end
359
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Transport
5
+ class Rack
6
+ CONTENT_TYPE = { "content-type" => "application/json" }.freeze
7
+ NOT_ALLOWED_BODY = '{"jsonrpc":"2.0","error":{"code":-32600,"message":"Method Not Allowed"},"id":null}'
8
+ NOT_ALLOWED_HEADERS = CONTENT_TYPE.merge("allow" => "POST").freeze
9
+ UNSUPPORTED_MEDIA_BODY = '{"jsonrpc":"2.0","error":{"code":-32600,"message":"Unsupported Media Type"},"id":null}'
10
+
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ def call(env)
16
+ return [200, CONTENT_TYPE.dup, []] if env["REQUEST_METHOD"] == "HEAD"
17
+ return method_not_allowed unless env["REQUEST_METHOD"] == "POST"
18
+ return unsupported_media_type unless acceptable_content_type?(env)
19
+
20
+ body = env["rack.input"]&.read.to_s
21
+ response = @server.handle(body)
22
+
23
+ if response
24
+ [200, CONTENT_TYPE.dup, [response]]
25
+ else
26
+ [204, {}, []]
27
+ end
28
+ end
29
+
30
+ def freeze
31
+ @server.freeze
32
+ super
33
+ end
34
+
35
+ private
36
+
37
+ def method_not_allowed
38
+ [405, NOT_ALLOWED_HEADERS.dup, [NOT_ALLOWED_BODY]]
39
+ end
40
+
41
+ def unsupported_media_type
42
+ [415, CONTENT_TYPE.dup, [UNSUPPORTED_MEDIA_BODY]]
43
+ end
44
+
45
+ def acceptable_content_type?(env)
46
+ ct = env["CONTENT_TYPE"]
47
+ ct.nil? || ct.empty? || ct.start_with?("application/json")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Transport
5
+ class Stdio
6
+ def initialize(server, input: $stdin, output: $stdout)
7
+ @server = server
8
+ @input = input
9
+ @output = output
10
+ @running = false
11
+ end
12
+
13
+ def run
14
+ @running = true
15
+ trap_signals
16
+ process_lines
17
+ ensure
18
+ @running = false
19
+ end
20
+
21
+ def stop
22
+ @running = false
23
+ end
24
+
25
+ def running? = @running
26
+
27
+ private
28
+
29
+ def process_lines
30
+ @input.each_line do |line|
31
+ break unless @running
32
+
33
+ line = line.chomp
34
+ next if line.empty?
35
+
36
+ response = @server.handle(line)
37
+ next unless response
38
+
39
+ break unless write_response(response)
40
+ end
41
+ end
42
+
43
+ def write_response(response)
44
+ @output.puts(response)
45
+ @output.flush
46
+ true
47
+ rescue Errno::EPIPE, IOError
48
+ false
49
+ end
50
+
51
+ def trap_signals
52
+ # Signal handlers set @running directly (no mutex). This is safe on MRI
53
+ # where boolean assignment is atomic. The process_lines loop checks
54
+ # @running between lines via each_line.
55
+ %w[INT TERM].each do |signal|
56
+ Signal.trap(signal) { @running = false }
57
+ rescue ArgumentError
58
+ # Signal not supported on this platform
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module UltimateJsonRpc
6
+ module Transport
7
+ # TCP adapter for UltimateJsonRpc servers.
8
+ # Thread-safety: each client gets its own thread. The @connection_count counter is protected
9
+ # by a Mutex. Under CRuby's GVL, Array/Hash reads are safe, but the Mutex ensures correctness
10
+ # on alternative Ruby implementations as well.
11
+ class TCP
12
+ DEFAULT_MAX_CONNECTIONS = 64
13
+
14
+ def initialize(server, port:, host: "127.0.0.1", max_connections: DEFAULT_MAX_CONNECTIONS)
15
+ @server = server
16
+ @host = host
17
+ @port = port
18
+ @max_connections = max_connections
19
+ @running = false
20
+ @tcp_server = nil
21
+ @connection_count = 0
22
+ @client_threads = []
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ MAX_LINE_BYTES = 4 * 1024 * 1024
27
+
28
+ def run
29
+ @mutex.synchronize do
30
+ @running = true
31
+ @tcp_server = TCPServer.new(@host, @port)
32
+ end
33
+ trap_signals
34
+ accept_loop
35
+ ensure
36
+ @mutex.synchronize { @running = false }
37
+ close_server
38
+ end
39
+
40
+ def stop
41
+ @mutex.synchronize { @running = false }
42
+ close_server
43
+ threads = @mutex.synchronize { @client_threads.dup }
44
+ threads.each do |t|
45
+ t.join(5)
46
+ t.kill if t.alive?
47
+ end
48
+ @mutex.synchronize do
49
+ @client_threads.clear
50
+ @connection_count = 0
51
+ end
52
+ end
53
+
54
+ def running? = @mutex.synchronize { @running }
55
+
56
+ def port
57
+ server = @mutex.synchronize { @tcp_server }
58
+ server&.addr&.[](1)
59
+ end
60
+
61
+ private
62
+
63
+ def accept_loop
64
+ while (server = @mutex.synchronize { @running && @tcp_server })
65
+ next unless server.wait_readable(0.5)
66
+
67
+ client = accept_client(server)
68
+ next unless client
69
+
70
+ accept_or_reject(client)
71
+ end
72
+ rescue Errno::EBADF, IOError
73
+ # Server socket was closed (e.g., via stop)
74
+ end
75
+
76
+ def accept_client(server)
77
+ server.accept
78
+ rescue IOError, Errno::EBADF, Errno::EMFILE, Errno::ENFILE, Errno::ECONNABORTED
79
+ nil
80
+ end
81
+
82
+ def accept_or_reject(client)
83
+ if acquire_connection_slot
84
+ begin
85
+ thread = Thread.new(client) { |c| handle_client(c) }
86
+ @mutex.synchronize { @client_threads << thread }
87
+ rescue ThreadError
88
+ client.close rescue nil # rubocop:disable Style/RescueModifier
89
+ decrement_connections
90
+ end
91
+ else
92
+ client.close
93
+ end
94
+ end
95
+
96
+ def acquire_connection_slot
97
+ @mutex.synchronize do
98
+ return false if @connection_count >= @max_connections
99
+
100
+ @connection_count += 1
101
+ true
102
+ end
103
+ end
104
+
105
+ def handle_client(client)
106
+ while (line = client.gets("\n", MAX_LINE_BYTES))
107
+ line = line.chomp
108
+ next if line.empty?
109
+
110
+ response = @server.handle(line)
111
+ next unless response
112
+
113
+ client.puts(response)
114
+ client.flush
115
+ end
116
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE
117
+ # Client disconnected
118
+ ensure
119
+ client.close unless client.closed?
120
+ decrement_connections
121
+ end
122
+
123
+ def decrement_connections
124
+ @mutex.synchronize do
125
+ @connection_count -= 1
126
+ @client_threads.delete(Thread.current)
127
+ end
128
+ end
129
+
130
+ def close_server
131
+ server = @mutex.synchronize do
132
+ s = @tcp_server
133
+ @tcp_server = nil
134
+ s
135
+ end
136
+ server&.close
137
+ rescue IOError, Errno::EBADF
138
+ # Already closed
139
+ end
140
+
141
+ def trap_signals
142
+ # Signal handlers set @running directly (no mutex). This is safe on MRI
143
+ # where boolean assignment is atomic. The accept_loop checks
144
+ # @running via mutex on each iteration, picking up the change.
145
+ %w[INT TERM].each do |signal|
146
+ Signal.trap(signal) { @running = false }
147
+ rescue ArgumentError
148
+ # Signal not supported on this platform
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ module Transport
5
+ class WebSocket
6
+ attr_reader :server
7
+
8
+ def initialize(server)
9
+ @server = server
10
+ end
11
+
12
+ def on_message(data)
13
+ @server.handle(data.to_s)
14
+ end
15
+
16
+ def call(_env, socket)
17
+ socket.on(:message) do |event|
18
+ response = on_message(event.respond_to?(:data) ? event.data : event)
19
+ socket.send(response) if response
20
+ end
21
+ end
22
+
23
+ def freeze
24
+ @server.freeze
25
+ super
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UltimateJsonRpc
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "ultimate_json_rpc/version"
5
+
6
+ module UltimateJsonRpc
7
+ module Core
8
+ class Error < StandardError; end
9
+ end
10
+ end
11
+
12
+ require_relative "ultimate_json_rpc/core/errors"
13
+ require_relative "ultimate_json_rpc/core/request"
14
+ require_relative "ultimate_json_rpc/core/response"
15
+ require_relative "ultimate_json_rpc/core/handler"
16
+ require_relative "ultimate_json_rpc/server"