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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +478 -0
- data/Rakefile +12 -0
- data/examples/error_handling.rb +51 -0
- data/examples/mcp_server.rb +42 -0
- data/examples/multi_namespace.rb +49 -0
- data/examples/rack_server.ru +24 -0
- data/examples/testing_patterns.rb +49 -0
- data/examples/websocket_server.ru +42 -0
- data/lib/ultimate_json_rpc/core/errors.rb +85 -0
- data/lib/ultimate_json_rpc/core/handler.rb +308 -0
- data/lib/ultimate_json_rpc/core/request.rb +74 -0
- data/lib/ultimate_json_rpc/core/response.rb +28 -0
- data/lib/ultimate_json_rpc/extras/docs.rb +107 -0
- data/lib/ultimate_json_rpc/extras/logging.rb +21 -0
- data/lib/ultimate_json_rpc/extras/mcp.rb +144 -0
- data/lib/ultimate_json_rpc/extras/profiler.rb +87 -0
- data/lib/ultimate_json_rpc/extras/rate_limit.rb +57 -0
- data/lib/ultimate_json_rpc/extras/recorder.rb +67 -0
- data/lib/ultimate_json_rpc/extras/test_helpers.rb +53 -0
- data/lib/ultimate_json_rpc/server.rb +359 -0
- data/lib/ultimate_json_rpc/transport/rack.rb +51 -0
- data/lib/ultimate_json_rpc/transport/stdio.rb +63 -0
- data/lib/ultimate_json_rpc/transport/tcp.rb +153 -0
- data/lib/ultimate_json_rpc/transport/websocket.rb +29 -0
- data/lib/ultimate_json_rpc/version.rb +5 -0
- data/lib/ultimate_json_rpc.rb +16 -0
- data/sig/ultimate_json_rpc.rbs +208 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 57a4e2297d9a88e829782f6ba971a4d358cf342cf608e85cdd45b4532f1d5cc5
|
|
4
|
+
data.tar.gz: 561c9b24aaf6369bc54429592ac5a3d3ef21577eba4df66591badbbca6f4f274
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a66e18a36696028d540dfdeffbf3136b2b413fd0c326ffc0ca4878b5c24eb96c19820f765491739ebc0d9a33076d68856e54f9808364e3615a9ed754f2106ca5
|
|
7
|
+
data.tar.gz: 26f3451b0dc98ea82c04051a415efa68cf7d26131718ad051cf1842e898cb9a8b86b00ce3fe6685d3d191c4d5e9d61147727076abf41d3e3bf1a6238f8247b59
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-03-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `TCP#port` accessor for discovering the bound port at runtime
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Gem renamed from `reclamo` to `ultimate_json_rpc` (module `Reclamo` → `UltimateJsonRpc`)
|
|
17
|
+
- `InvalidParams` error data is now gated by `expose_errors` (same as `InvalidRequest` and `ArgumentError`). `InvalidParams` is a protocol-level schema check, not an application validation error — use `ApplicationError` for business rule violations.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `Server#freeze` is now idempotent — calling freeze on an already-frozen server no longer raises `FrozenError`
|
|
21
|
+
- RBS type signatures now correctly reflect Core/Extras/Transport namespace structure
|
|
22
|
+
- RBS `Server` class no longer declares conditional `include` for extras
|
|
23
|
+
- Recorder `max_exchanges:` and TCP `MAX_LINE_BYTES` added to RBS type signatures
|
|
24
|
+
- `Docs#escape_cell` now escapes newlines in Markdown table cells
|
|
25
|
+
- Flaky profiler test with timing-dependent percentile assertion
|
|
26
|
+
- Batch-too-large error now uses standard `"Invalid Request"` message with reason in `data` field (JSON-RPC 2.0 conformance)
|
|
27
|
+
|
|
28
|
+
## [0.3.0] - 2026-03-18
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Method-level middleware: `server.use(only: ["admin.*"])` and `server.use(except: ["ping"])` to scope middleware to specific methods or namespaces, with glob pattern support
|
|
32
|
+
- `UltimateJsonRpc::Transport::Rack` built-in Rack adapter: Content-Type handling, 200/204/405 responses, `require "ultimate_json_rpc/transport/rack"` to opt in
|
|
33
|
+
- Test assertion helpers: `assert_rpc_success`, `assert_rpc_error`, `assert_rpc_notification` in `ultimate_json_rpc/test_helpers`
|
|
34
|
+
- `UltimateJsonRpc::Transport::Stdio` adapter: newline-delimited JSON-RPC over stdin/stdout with signal handling, `require "ultimate_json_rpc/transport/stdio"`
|
|
35
|
+
- Return type annotations: `returns:` keyword on `expose_method` and `returns:` hash on `expose`, appears in `rpc.discover`
|
|
36
|
+
- OpenRPC 1.3.2 schema: `rpc.discover` now returns a full OpenRPC document with `openrpc` version, `info` object, and `result` contentDescriptors
|
|
37
|
+
- Instrumentation hooks: `server.on(:request)`, `on(:response)`, `on(:error)` for read-only lifecycle observability with duration timing
|
|
38
|
+
- Request timeout: `Server.new(timeout: 5)` wraps dispatch in `Timeout.timeout`, returns `-32001 Request timeout` on expiry
|
|
39
|
+
- `RequestTimeout` error class and `REQUEST_TIMEOUT` (-32001) constant
|
|
40
|
+
- Method deprecation markers: `deprecated: true` or `deprecated: "Use v2"` on `expose_method` and `expose`, appears in `rpc.discover`
|
|
41
|
+
- Parameter validation: `params_schema:` on `expose_method` and `expose` for type and enum validation before dispatch, returns `-32602 Invalid params` on mismatch, schemas appear in `rpc.discover` param descriptors
|
|
42
|
+
- Concurrent batch execution: `Server.new(concurrent_batches: true)` processes batch items in parallel using threads
|
|
43
|
+
- Error catalog: `server.register_error(code, name, description)` registers application error codes that appear in `rpc.discover` under `components.errors`
|
|
44
|
+
- Custom JSON serializer: `Server.new(json: Oj)` to swap JSON encoder/decoder, any object responding to `parse` and `generate`
|
|
45
|
+
- Method-level authorization: `server.authorize("admin.*") { |req| req.context[:role] == :admin }` with glob patterns and custom error codes
|
|
46
|
+
- Structured logging: `require "ultimate_json_rpc/extras/logging"` provides `UltimateJsonRpc::Extras::Logging.new(server, logger)` for logger-agnostic observability
|
|
47
|
+
- Request/response recorder: `require "ultimate_json_rpc/extras/recorder"` provides `UltimateJsonRpc::Extras::Recorder.new(server)` capturing exchanges for replay testing, with optional JSONL file output
|
|
48
|
+
- MCP (Model Context Protocol) adapter: `require "ultimate_json_rpc/extras/mcp"` provides `UltimateJsonRpc::Extras::MCP.new(server)` for AI tool integration over stdio, mapping methods to MCP tools with schema support
|
|
49
|
+
- Rate limiting: `require "ultimate_json_rpc/extras/rate_limit"` provides `UltimateJsonRpc::Extras::RateLimiter.new(server, max:, period:)` with sliding window, per-caller keying, and per-method scoping
|
|
50
|
+
- Per-method profiling: `require "ultimate_json_rpc/extras/profiler"` provides `UltimateJsonRpc::Extras::Profiler.new(server)` collecting count, min/max/avg, and p50/p95/p99 per method
|
|
51
|
+
- TCP server adapter: `require "ultimate_json_rpc/transport/tcp"` provides `UltimateJsonRpc::Transport::TCP.new(server, port: 4000)` for newline-delimited JSON-RPC over TCP with multi-client threading
|
|
52
|
+
- API documentation generation: `require "ultimate_json_rpc/extras/docs"` provides `UltimateJsonRpc::Extras::Docs.new(server).to_markdown` generating Markdown from OpenRPC schema
|
|
53
|
+
- Usage examples: `examples/` directory with runnable patterns for Rack, MCP, multi-namespace/versioning, error handling, and testing
|
|
54
|
+
- WebSocket adapter: `require "ultimate_json_rpc/transport/websocket"` provides `UltimateJsonRpc::Transport::WebSocket` for JSON-RPC over WebSockets with any Rack-compatible library
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- MCP `inputSchema` now always included for zero-parameter tools (MCP spec compliance)
|
|
58
|
+
- `handle_parsed` no longer crashes with `TypeError` on Symbol-keyed hashes
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- `rpc.discover` output restructured: service metadata moved to `info` object (`name` → `info.title`), method `returns` → `result` in OpenRPC contentDescriptor format
|
|
62
|
+
|
|
63
|
+
## [0.2.0] - 2026-03-18
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
- `InvalidParams` error class for explicit -32602 errors from user code
|
|
67
|
+
- `expose_method` accepts callable objects (Method, Proc, lambda) as first argument
|
|
68
|
+
- `Server#empty?` and `Handler#empty?` convenience methods
|
|
69
|
+
- Callable validation: `expose_method` checks that callable responds to `#call`
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
- `MethodNotFound` now has a descriptive Ruby exception message ("Method not found: name")
|
|
73
|
+
- `ApplicationError` validates that error code is an Integer
|
|
74
|
+
- Request params and id are frozen after construction for immutability
|
|
75
|
+
- `ServerError` uses `between?` for range validation (consistency)
|
|
76
|
+
|
|
77
|
+
## [0.1.0] - 2025-05-01
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- Initial release
|
|
81
|
+
- JSON-RPC 2.0 server with `expose`, `expose_method`, and middleware support
|
|
82
|
+
- `rpc.discover` built-in introspection
|
|
83
|
+
- Batch request handling
|
|
84
|
+
- `Server#freeze` for thread-safe immutability
|
|
85
|
+
- `Server#call` alias and `#to_proc` for Rack-like usage
|
|
86
|
+
- `only:`/`except:` method filtering
|
|
87
|
+
- Method descriptions via `descriptions:` option
|
|
88
|
+
- Service metadata via `name:` and `version:` options
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andriy Tyurnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# UltimateJsonRpc
|
|
2
|
+
|
|
3
|
+
[](https://github.com/rubakas/ultimate_json_rpc/actions/workflows/main.yml)
|
|
4
|
+
|
|
5
|
+
Network-agnostic JSON-RPC 2.0 server that exposes Ruby modules, classes, and instances as callable RPC endpoints. UltimateJsonRpc handles JSON-RPC message parsing, method dispatch, and response serialization — transport (HTTP, WebSocket, stdio, TCP, etc.) is the caller's responsibility.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "ultimate_json_rpc"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "ultimate_json_rpc"
|
|
19
|
+
|
|
20
|
+
# Define your service
|
|
21
|
+
module Calculator
|
|
22
|
+
def self.add(a, b) = a + b
|
|
23
|
+
def self.divide(a, b) = a.to_f / b
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Create a server and expose objects
|
|
27
|
+
server = UltimateJsonRpc::Server.new(name: "My API", version: "1.0")
|
|
28
|
+
server.expose(Calculator, descriptions: { add: "Add two numbers" })
|
|
29
|
+
server.expose(some_instance, namespace: "greeter")
|
|
30
|
+
|
|
31
|
+
# Handle a JSON-RPC request string, get a JSON-RPC response string
|
|
32
|
+
request = '{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}'
|
|
33
|
+
response = server.handle(request)
|
|
34
|
+
# => '{"jsonrpc":"2.0","result":5,"id":1}'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Expose individual methods
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
server.expose_method("double", description: "Double a number") { |n| n * 2 }
|
|
41
|
+
server.expose_method("greet") { |name:, greeting: "Hello"| "#{greeting}, #{name}!" }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Chainable API
|
|
45
|
+
|
|
46
|
+
All setup methods return `self`, and the server is callable via `to_proc`:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
server = UltimateJsonRpc::Server.new
|
|
50
|
+
.expose(Calculator, namespace: "calc")
|
|
51
|
+
.expose_method("ping") { "pong" }
|
|
52
|
+
.use { |req, next_call| next_call.call }
|
|
53
|
+
|
|
54
|
+
# Use to_proc for mapping over requests
|
|
55
|
+
responses = json_requests.map(&server)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Return type annotations
|
|
59
|
+
|
|
60
|
+
Declare return types for discovery metadata (purely informational, no runtime enforcement):
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
server.expose_method("add", returns: { "type" => "number" }) { |a, b| a + b }
|
|
64
|
+
server.expose(Calculator, returns: { add: { "type" => "number" } })
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Service discovery
|
|
68
|
+
|
|
69
|
+
Built-in `rpc.discover` returns an [OpenRPC](https://open-rpc.org/) 1.3.2-compatible document:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
request = '{"jsonrpc":"2.0","method":"rpc.discover","id":1}'
|
|
73
|
+
server.handle(request)
|
|
74
|
+
# => '{"jsonrpc":"2.0","result":{"openrpc":"1.3.2","info":{"title":"My API","version":"1.0"},"methods":[...]},"id":1}'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Middleware
|
|
78
|
+
|
|
79
|
+
Add cross-cutting concerns like logging, auth, or rate limiting:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
server.use do |request, next_call|
|
|
83
|
+
puts "Calling #{request.method_name}"
|
|
84
|
+
result = next_call.call
|
|
85
|
+
puts "Done"
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Reject unauthorized calls
|
|
90
|
+
server.use do |request, next_call|
|
|
91
|
+
raise UltimateJsonRpc::ApplicationError.new(code: 403, message: "Forbidden") unless authorized?(request)
|
|
92
|
+
|
|
93
|
+
next_call.call
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Middleware runs in registration order (first registered = outermost wrapper).
|
|
98
|
+
|
|
99
|
+
#### Scoped middleware
|
|
100
|
+
|
|
101
|
+
Target specific methods or namespaces with `only:` / `except:`, supporting glob patterns:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# Only run for admin namespace methods
|
|
105
|
+
server.use(only: ["admin.*"]) do |request, next_call|
|
|
106
|
+
raise UltimateJsonRpc::ApplicationError.new(code: 403, message: "Forbidden") unless admin?(request)
|
|
107
|
+
next_call.call
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Run for everything except health checks
|
|
111
|
+
server.use(except: ["ping", "health"]) do |request, next_call|
|
|
112
|
+
log(request)
|
|
113
|
+
next_call.call
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Middleware can pass data to handlers via `request.context`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
server.use do |request, next_call|
|
|
121
|
+
request.context[:user] = authenticate(request)
|
|
122
|
+
next_call.call
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Instrumentation hooks
|
|
127
|
+
|
|
128
|
+
Read-only lifecycle hooks for observability — they can't alter responses or swallow errors:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
server.on(:request) { |request| puts "→ #{request.method_name}" }
|
|
132
|
+
server.on(:response) { |request, result, duration| puts "← #{request.method_name} (#{duration}s)" }
|
|
133
|
+
server.on(:error) { |request, error, duration| log_error(error, method: request.method_name) }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Hook errors are rescued and logged via `Kernel.warn`, never breaking dispatch.
|
|
137
|
+
|
|
138
|
+
### Custom JSON serializer
|
|
139
|
+
|
|
140
|
+
Swap the JSON encoder/decoder (defaults to stdlib `JSON`):
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
require "oj"
|
|
144
|
+
Oj.mimic_JSON
|
|
145
|
+
server = UltimateJsonRpc::Server.new(json: Oj)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Any object responding to `parse(string)` and `generate(object)` works.
|
|
149
|
+
|
|
150
|
+
### Concurrent batches
|
|
151
|
+
|
|
152
|
+
Opt-in parallel processing for batch requests:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
server = UltimateJsonRpc::Server.new(concurrent_batches: true)
|
|
156
|
+
# Batch items are processed in parallel using threads
|
|
157
|
+
# Respects max_batch_size and request timeout
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Request timeout
|
|
161
|
+
|
|
162
|
+
Set a per-server timeout (in seconds) to prevent slow handlers from blocking:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
server = UltimateJsonRpc::Server.new(timeout: 5)
|
|
166
|
+
# Handlers exceeding 5 seconds receive a -32001 "Request timeout" error
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Parameter validation
|
|
170
|
+
|
|
171
|
+
Declare parameter schemas to validate incoming params before dispatch:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
server.expose_method("add", params_schema: {
|
|
175
|
+
a: { "type" => "number" },
|
|
176
|
+
b: { "type" => "number" }
|
|
177
|
+
}) { |a, b| a + b }
|
|
178
|
+
|
|
179
|
+
server.expose(Calculator, params_schema: {
|
|
180
|
+
add: { left: { "type" => "number" }, right: { "type" => "number" } }
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Supported JSON Schema keywords: `type` (`string`, `number`, `integer`, `boolean`, `array`, `object`, `null`) and `enum`. On mismatch, returns `-32602 Invalid params` with a descriptive message. Schemas also appear in `rpc.discover` output.
|
|
185
|
+
|
|
186
|
+
### Method deprecation
|
|
187
|
+
|
|
188
|
+
Mark methods as deprecated in discovery metadata (purely informational, no runtime enforcement):
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
server.expose_method("old_add", deprecated: true) { |a, b| a + b }
|
|
192
|
+
server.expose_method("old_multiply", deprecated: "Use multiply_v2 instead") { |a, b| a * b }
|
|
193
|
+
server.expose(Calculator, deprecated: { add: "Use add_v2" })
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Deprecated methods still work normally but appear flagged in `rpc.discover` output.
|
|
197
|
+
|
|
198
|
+
### Per-method profiling
|
|
199
|
+
|
|
200
|
+
Collect per-method timing statistics:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
require "ultimate_json_rpc/extras/profiler"
|
|
204
|
+
|
|
205
|
+
profiler = UltimateJsonRpc::Extras::Profiler.new(server)
|
|
206
|
+
# ... handle requests ...
|
|
207
|
+
profiler["add"] # => {count: 150, min: 0.0001, max: 0.05, avg: 0.002, p50: ..., p95: ..., p99: ...}
|
|
208
|
+
profiler.stats # => all methods
|
|
209
|
+
profiler.reset # clear data
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Structured logging
|
|
213
|
+
|
|
214
|
+
Logger-agnostic observability:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
require "ultimate_json_rpc/extras/logging"
|
|
218
|
+
|
|
219
|
+
UltimateJsonRpc::Extras::Logging.new(server, Logger.new($stdout)) # logs at INFO by default
|
|
220
|
+
UltimateJsonRpc::Extras::Logging.new(server, Rails.logger, level: :debug) # custom level
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Successful responses log at the specified level; errors always log at ERROR.
|
|
224
|
+
|
|
225
|
+
### Rate limiting
|
|
226
|
+
|
|
227
|
+
Sliding-window rate limiting with per-caller keying:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
require "ultimate_json_rpc/extras/rate_limit"
|
|
231
|
+
|
|
232
|
+
UltimateJsonRpc::Extras::RateLimiter.new(server, max: 100, period: 60) # 100 req/min global
|
|
233
|
+
UltimateJsonRpc::Extras::RateLimiter.new(server, max: 10, period: 60, only: ["expensive.*"]) # per-method
|
|
234
|
+
UltimateJsonRpc::Extras::RateLimiter.new(server, max: 50, period: 60, key: :api_key) # per-caller via context
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Authorization
|
|
238
|
+
|
|
239
|
+
Declarative access control with glob patterns:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
server.authorize("admin.*") { |req| req.context[:role] == :admin }
|
|
243
|
+
server.authorize("users.delete", code: 1001, message: "Insufficient permissions") do |req|
|
|
244
|
+
req.context[:permissions]&.include?("delete")
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Returns `ApplicationError` (code 403 by default) when the block returns falsy.
|
|
249
|
+
|
|
250
|
+
### Error catalog
|
|
251
|
+
|
|
252
|
+
Register application-specific error codes for discovery:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
server.register_error(code: 42, message: "InsufficientFunds", description: "Account balance too low")
|
|
256
|
+
server.register_error(code: 43, message: "AccountLocked")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Registered errors appear in `rpc.discover` under `components.errors`, so consumers know which error codes to expect.
|
|
260
|
+
|
|
261
|
+
### Error handling
|
|
262
|
+
|
|
263
|
+
Raise `ApplicationError` for custom error codes, or `ServerError` for implementation-defined errors:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
raise UltimateJsonRpc::ApplicationError.new(code: 42, message: "Custom error", data: { "detail" => "info" })
|
|
267
|
+
raise UltimateJsonRpc::ServerError.new(code: -32_001, message: "Server shutting down")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Ruby's `ArgumentError` automatically maps to JSON-RPC Invalid params (`-32602`). All other exceptions become Internal error (`-32603`).
|
|
271
|
+
|
|
272
|
+
### Error visibility
|
|
273
|
+
|
|
274
|
+
By default, internal error details (exception messages) are hidden from clients:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
server = UltimateJsonRpc::Server.new # expose_errors: false (default)
|
|
278
|
+
# Internal errors return generic "Internal server error" in the data field
|
|
279
|
+
|
|
280
|
+
server = UltimateJsonRpc::Server.new(expose_errors: true)
|
|
281
|
+
# Internal errors include the actual exception message in the data field
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`ApplicationError`, `ServerError`, and `MethodNotFound` always expose their details regardless of this setting, since those are intentionally raised by your code.
|
|
285
|
+
|
|
286
|
+
Protocol-level errors (`InvalidRequest`, `InvalidParams`, `ArgumentError`, and unhandled exceptions) are gated by `expose_errors`. `InvalidParams` is a JSON-RPC schema check (type mismatch, enum violation) — for application-level business validation, raise `ApplicationError` instead.
|
|
287
|
+
|
|
288
|
+
### Freezing
|
|
289
|
+
|
|
290
|
+
Lock the server after setup to prevent accidental modifications:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
server.freeze # expose, expose_method, use will now raise FrozenError
|
|
294
|
+
server.handle(request) # still works
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Features
|
|
298
|
+
|
|
299
|
+
- **JSON-RPC 2.0** compliant (requests, notifications, batch requests)
|
|
300
|
+
- **Expose modules** — singleton methods become RPC methods
|
|
301
|
+
- **Expose instances** — public methods become RPC methods
|
|
302
|
+
- **Expose blocks** — `server.expose_method("name") { ... }` for standalone methods
|
|
303
|
+
- **Namespacing** — `server.expose(obj, namespace: "ns")` makes methods callable as `ns.method_name`
|
|
304
|
+
- **Method filtering** — `server.expose(obj, only: [:add])` or `except: [:internal]`
|
|
305
|
+
- **Method descriptions** — `descriptions:` hash or `description:` keyword for discovery
|
|
306
|
+
- **Return type annotations** — `returns:` hash or keyword for discovery metadata
|
|
307
|
+
- **Service metadata** — `name:` and `version:` appear in `rpc.discover` responses
|
|
308
|
+
- **Positional and keyword params** — arrays map to positional args, objects map to keyword args
|
|
309
|
+
- **Service discovery** — built-in `rpc.discover` returns OpenRPC 1.3.2-compatible schema
|
|
310
|
+
- **Middleware** — `server.use { |request, next_call| ... }` for cross-cutting concerns
|
|
311
|
+
- **Scoped middleware** — `only:` / `except:` with glob patterns to target specific methods or namespaces
|
|
312
|
+
- **Instrumentation hooks** — `on(:request)`, `on(:response)`, `on(:error)` for read-only observability
|
|
313
|
+
- **Parameter validation** — `params_schema:` with JSON Schema types and enum constraints
|
|
314
|
+
- **Request timeout** — `Server.new(timeout: 5)` prevents slow handlers from blocking
|
|
315
|
+
- **Method deprecation** — mark methods as deprecated in discovery metadata
|
|
316
|
+
- **Error catalog** — `register_error` documents app error codes in `rpc.discover`
|
|
317
|
+
- **Authorization** — `authorize("admin.*") { |req| ... }` for declarative access control
|
|
318
|
+
- **Error handling** — standard JSON-RPC error codes, `ApplicationError`, and `ServerError`
|
|
319
|
+
- **Error visibility** — `expose_errors: true` to include exception messages in error responses
|
|
320
|
+
- **Rate limiting** — sliding-window `rate_limit(max:, period:)` with per-caller keying
|
|
321
|
+
- **Security** — dangerous methods (eval, system, exec, etc.) are automatically blocked
|
|
322
|
+
- **Chainable API** — all setup methods return `self`
|
|
323
|
+
- **Callable** — `to_proc` enables `requests.map(&server)`
|
|
324
|
+
- **Rack adapter** — `UltimateJsonRpc::Transport::Rack.new(server)` for instant HTTP deployment
|
|
325
|
+
- **MCP adapter** — `UltimateJsonRpc::Extras::MCP.new(server).run` for AI tool integration (Claude, Cursor, etc.)
|
|
326
|
+
- **stdio adapter** — `UltimateJsonRpc::Transport::Stdio.new(server).run` for CLI/MCP-style integrations
|
|
327
|
+
- **Test helpers** — `rpc_call`, `assert_rpc_success`, `assert_rpc_error` for cleaner tests
|
|
328
|
+
- **Concurrent batches** — `concurrent_batches: true` processes batch items in parallel
|
|
329
|
+
- **Custom JSON** — `json: Oj` to swap the JSON encoder/decoder
|
|
330
|
+
- **Batch size limit** — `max_batch_size: 100` (default) prevents oversized batch requests
|
|
331
|
+
- **Freezable** — `server.freeze` locks configuration after setup
|
|
332
|
+
|
|
333
|
+
### Transport examples
|
|
334
|
+
|
|
335
|
+
UltimateJsonRpc is transport-agnostic. Here are common setups:
|
|
336
|
+
|
|
337
|
+
#### Rack (HTTP)
|
|
338
|
+
|
|
339
|
+
Use the built-in Rack adapter:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# config.ru
|
|
343
|
+
require "ultimate_json_rpc"
|
|
344
|
+
require "ultimate_json_rpc/transport/rack"
|
|
345
|
+
|
|
346
|
+
server = UltimateJsonRpc::Server.new(name: "My API")
|
|
347
|
+
server.expose(Calculator)
|
|
348
|
+
|
|
349
|
+
run UltimateJsonRpc::Transport::Rack.new(server)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`UltimateJsonRpc::Transport::Rack` handles Content-Type, returns 200 for responses, 204 for notifications, and 405 for non-POST requests.
|
|
353
|
+
|
|
354
|
+
#### MCP (Model Context Protocol)
|
|
355
|
+
|
|
356
|
+
Expose methods as AI tools via MCP:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
require "ultimate_json_rpc/extras/mcp"
|
|
360
|
+
|
|
361
|
+
server = UltimateJsonRpc::Server.new(name: "My Tools", version: "1.0")
|
|
362
|
+
server.expose(Calculator, descriptions: { add: "Add two numbers" })
|
|
363
|
+
|
|
364
|
+
UltimateJsonRpc::Extras::MCP.new(server).run
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`UltimateJsonRpc::Extras::MCP` handles the MCP lifecycle (`initialize`, `tools/list`, `tools/call`), maps methods to tool definitions with input schemas, and runs over stdio for integration with Claude, Cursor, and other AI tools.
|
|
368
|
+
|
|
369
|
+
#### WebSocket
|
|
370
|
+
|
|
371
|
+
Use the WebSocket adapter with any Rack-compatible library (e.g., `faye-websocket`):
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
require "ultimate_json_rpc/transport/websocket"
|
|
375
|
+
|
|
376
|
+
ws_handler = UltimateJsonRpc::Transport::WebSocket.new(server)
|
|
377
|
+
|
|
378
|
+
# In your Rack app:
|
|
379
|
+
ws = Faye::WebSocket.new(env)
|
|
380
|
+
ws_handler.call(env, ws)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
See `examples/websocket_server.ru` for a complete runnable example.
|
|
384
|
+
|
|
385
|
+
#### TCP
|
|
386
|
+
|
|
387
|
+
Use the built-in TCP adapter for internal microservices:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
require "ultimate_json_rpc/transport/tcp"
|
|
391
|
+
|
|
392
|
+
server = UltimateJsonRpc::Server.new
|
|
393
|
+
server.expose(Calculator)
|
|
394
|
+
|
|
395
|
+
UltimateJsonRpc::Transport::TCP.new(server, port: 4000).run
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`UltimateJsonRpc::Transport::TCP` accepts newline-delimited JSON-RPC over TCP, handles multiple concurrent clients via threads, and supports `SIGINT`/`SIGTERM` for graceful shutdown.
|
|
399
|
+
|
|
400
|
+
#### stdio
|
|
401
|
+
|
|
402
|
+
Use the built-in stdio adapter:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
require "ultimate_json_rpc"
|
|
406
|
+
require "ultimate_json_rpc/transport/stdio"
|
|
407
|
+
|
|
408
|
+
server = UltimateJsonRpc::Server.new
|
|
409
|
+
server.expose(Calculator)
|
|
410
|
+
|
|
411
|
+
UltimateJsonRpc::Transport::Stdio.new(server).run
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`UltimateJsonRpc::Transport::Stdio` reads newline-delimited JSON-RPC from stdin, writes responses to stdout, skips empty lines, and handles `SIGINT`/`SIGTERM` for graceful shutdown.
|
|
415
|
+
|
|
416
|
+
### Pre-parsed input
|
|
417
|
+
|
|
418
|
+
If you've already parsed the JSON (e.g. from a WebSocket frame), use `handle_parsed` to skip the parse step:
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
data = JSON.parse(raw_json)
|
|
422
|
+
response = server.handle_parsed(data)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Request/response recording
|
|
426
|
+
|
|
427
|
+
Capture exchanges for replay and regression testing:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
require "ultimate_json_rpc/extras/recorder"
|
|
431
|
+
|
|
432
|
+
recorder = UltimateJsonRpc::Extras::Recorder.new(server)
|
|
433
|
+
server.handle(request_json)
|
|
434
|
+
recorder.exchanges # => [{"method" => "add", "params" => [2, 3], "result" => 5, "duration" => 0.001}]
|
|
435
|
+
|
|
436
|
+
# Write JSONL to a file
|
|
437
|
+
recorder = UltimateJsonRpc::Extras::Recorder.new(server, output: File.open("exchanges.jsonl", "a"))
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Test helpers
|
|
441
|
+
|
|
442
|
+
UltimateJsonRpc ships with optional test helpers for cleaner assertions:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
require "ultimate_json_rpc/extras/test_helpers"
|
|
446
|
+
|
|
447
|
+
class MyTest < Minitest::Test
|
|
448
|
+
include UltimateJsonRpc::Extras::TestHelpers
|
|
449
|
+
|
|
450
|
+
def test_addition
|
|
451
|
+
response = rpc_call(server, "add", params: [2, 3])
|
|
452
|
+
assert_rpc_success response, 5
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def test_method_not_found
|
|
456
|
+
response = rpc_call(server, "nonexistent")
|
|
457
|
+
assert_rpc_error response, code: -32_601
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def test_notification
|
|
461
|
+
assert_rpc_notification server, "add", params: [1, 2]
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## Development
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
bin/setup # Install dependencies
|
|
470
|
+
rake test # Run tests
|
|
471
|
+
rake rubocop # Run linter
|
|
472
|
+
rake # Run both
|
|
473
|
+
bin/console # Interactive console
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
## Contributing
|
|
477
|
+
|
|
478
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rubakas/ultimate_json_rpc.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Error handling patterns
|
|
5
|
+
#
|
|
6
|
+
# Run: ruby examples/error_handling.rb
|
|
7
|
+
|
|
8
|
+
require "ultimate_json_rpc"
|
|
9
|
+
require "ultimate_json_rpc/extras/logging"
|
|
10
|
+
require "json"
|
|
11
|
+
require "logger"
|
|
12
|
+
|
|
13
|
+
module BankService
|
|
14
|
+
def self.transfer(from:, to:, amount:)
|
|
15
|
+
raise UltimateJsonRpc::Core::ApplicationError.new(code: 1001, message: "Insufficient funds", data: { balance: 50 }) if amount > 100
|
|
16
|
+
raise UltimateJsonRpc::Core::ApplicationError.new(code: 1002, message: "Account frozen") if from == "frozen"
|
|
17
|
+
|
|
18
|
+
{ from:, to:, amount:, status: "completed" }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
server = UltimateJsonRpc::Server.new(name: "Bank API", version: "1.0", expose_errors: true)
|
|
23
|
+
server.expose(BankService, descriptions: { transfer: "Transfer money between accounts" })
|
|
24
|
+
server.register_error(code: 1001, message: "InsufficientFunds", description: "Account balance too low for transfer")
|
|
25
|
+
server.register_error(code: 1002, message: "AccountFrozen", description: "Account is frozen and cannot transact")
|
|
26
|
+
UltimateJsonRpc::Extras::Logging.new(server, Logger.new($stdout, level: :info))
|
|
27
|
+
|
|
28
|
+
# Successful transfer
|
|
29
|
+
puts "=== Successful transfer ==="
|
|
30
|
+
puts server.handle(JSON.generate({
|
|
31
|
+
"jsonrpc" => "2.0", "method" => "transfer",
|
|
32
|
+
"params" => { "from" => "alice", "to" => "bob", "amount" => 50 }, "id" => 1
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
# Application error — insufficient funds
|
|
36
|
+
puts "\n=== Insufficient funds ==="
|
|
37
|
+
puts server.handle(JSON.generate({
|
|
38
|
+
"jsonrpc" => "2.0", "method" => "transfer",
|
|
39
|
+
"params" => { "from" => "alice", "to" => "bob", "amount" => 200 }, "id" => 2
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
# Application error — frozen account
|
|
43
|
+
puts "\n=== Frozen account ==="
|
|
44
|
+
puts server.handle(JSON.generate({
|
|
45
|
+
"jsonrpc" => "2.0", "method" => "transfer",
|
|
46
|
+
"params" => { "from" => "frozen", "to" => "bob", "amount" => 10 }, "id" => 3
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
# Method not found
|
|
50
|
+
puts "\n=== Method not found ==="
|
|
51
|
+
puts server.handle(JSON.generate({ "jsonrpc" => "2.0", "method" => "withdraw", "id" => 4 }))
|