clamo 0.7.0 → 0.9.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 +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +30 -0
- data/README.md +35 -4
- data/lib/clamo/jsonrpc.rb +11 -11
- data/lib/clamo/server.rb +65 -45
- data/lib/clamo/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1a61a818aab987fe6d621d2f3bf642fd7993044933b0b258f2a515d896bb7a5
|
|
4
|
+
data.tar.gz: b68b313c737400cda3d739902d1d2d498a2d28344cf9cc0afc5c8b481850bcb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2a1ac30f5f40c035d683d54a36c0164ea24dc56b8ca9af694de499862732faf22cc572029e6bfac1f05413c4d4cf56a737b9b29af96b870fcf23eee5305e756
|
|
7
|
+
data.tar.gz: ccb5a26f16ab0492e3d77e27eeed648e26b6fdcb9bda59c37a264cc3e419ba45a77bede078863e9ea18dface1f9f5c0be13a1629e6b37d4a8fb2da2f8cc291b4
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [0.9.0] - 2026-03-14
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Breaking:** All response and request builder hashes now use string keys (`"jsonrpc"`, `"result"`, `"id"`, `"error"`) instead of symbol keys. This makes the entire pipeline consistent — `JSON.parse` produces string keys, `normalize_request_keys` uses string keys, and now responses match. Callers using the lower-level `parsed_dispatch_to_object` or `unparsed_dispatch_to_object` must update hash access from `response[:result]` to `response["result"]`. The `handle` method (JSON string in/out) is unaffected.
|
|
12
|
+
- `after_dispatch` hook now receives the actual return value for notifications. Previously always passed `nil`; now passes the value returned by the dispatched method.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Security:** `method_known?` no longer exposes inherited `Module` methods (`define_method`, `class_eval`, `const_set`, `freeze`, `include`, and 55 others) on module-based service objects. The previous `public_methods(false)` implementation included these; the new `public_method_defined?` check restricts dispatch to methods explicitly defined on the service object.
|
|
17
|
+
|
|
18
|
+
### Internal
|
|
19
|
+
|
|
20
|
+
- `method_known?` replaced array-allocating `public_methods(false).map(&:to_sym).include?` with zero-allocation `public_method_defined?` lookups. Handles both module targets (singleton class) and class instance targets (class + singleton class).
|
|
21
|
+
|
|
22
|
+
## [0.8.0] - 2026-03-14
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `before_dispatch` and `after_dispatch` hooks — run around every method call (requests and notifications). Raise in `before_dispatch` to halt execution; `after_dispatch` fires only on success with `(method, params, result)`.
|
|
27
|
+
- Per-call configuration — `timeout`, `on_error`, `before_dispatch`, and `after_dispatch` can be passed as keyword arguments to `handle`, `unparsed_dispatch_to_object`, and `parsed_dispatch_to_object`, overriding module-level defaults.
|
|
28
|
+
- `Clamo::Server::Config` — immutable `Data` struct that snapshots configuration at the start of each dispatch, eliminating race conditions from concurrent mutations to module-level settings.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Internal error responses no longer include `e.message` in the `data` field, preventing leakage of internal details to clients. Exceptions are routed through `on_error` instead.
|
|
33
|
+
- `parsed_dispatch_to_object` normalizes request keys to strings at the boundary, fixing silent dispatch failures when callers pass symbol-key hashes.
|
|
34
|
+
- `on_error` is now called for request dispatch errors (previously only for notification errors).
|
|
35
|
+
- Test suite expanded to 95 tests / 138 assertions.
|
|
36
|
+
|
|
7
37
|
## [0.7.0] - 2026-03-14
|
|
8
38
|
|
|
9
39
|
### Added
|
data/README.md
CHANGED
|
@@ -43,7 +43,7 @@ response = Clamo::Server.unparsed_dispatch_to_object(
|
|
|
43
43
|
request: '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}',
|
|
44
44
|
object: MyService
|
|
45
45
|
)
|
|
46
|
-
# => {jsonrpc
|
|
46
|
+
# => {"jsonrpc" => "2.0", "result" => 3, "id" => 1}
|
|
47
47
|
|
|
48
48
|
# From a pre-parsed hash
|
|
49
49
|
response = Clamo::Server.parsed_dispatch_to_object(
|
|
@@ -82,7 +82,7 @@ batch_response = Clamo::Server.unparsed_dispatch_to_object(
|
|
|
82
82
|
)
|
|
83
83
|
|
|
84
84
|
puts batch_response
|
|
85
|
-
# => [{jsonrpc
|
|
85
|
+
# => [{"jsonrpc" => "2.0", "result" => 3, "id" => 1}, {"jsonrpc" => "2.0", "result" => 2, "id" => 2}]
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
### Notifications
|
|
@@ -112,7 +112,7 @@ request = Clamo::JSONRPC.build_request(
|
|
|
112
112
|
)
|
|
113
113
|
|
|
114
114
|
puts request
|
|
115
|
-
# => {jsonrpc
|
|
115
|
+
# => {"jsonrpc" => "2.0", "method" => "add", "params" => [1, 2], "id" => 1}
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
## Error Handling
|
|
@@ -141,7 +141,7 @@ Clamo::Server.timeout = nil # disable timeout
|
|
|
141
141
|
|
|
142
142
|
### Error Callback
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
Errors during dispatch are reported through `on_error`. Notifications are silent by default (no response is sent); requests return a generic `-32603 Internal error` without leaking exception details. Use `on_error` to capture the full exception for logging:
|
|
145
145
|
|
|
146
146
|
```ruby
|
|
147
147
|
Clamo::Server.on_error = ->(exception, method, params) {
|
|
@@ -149,6 +149,37 @@ Clamo::Server.on_error = ->(exception, method, params) {
|
|
|
149
149
|
}
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
### Dispatch Hooks
|
|
153
|
+
|
|
154
|
+
`before_dispatch` and `after_dispatch` run around every method call (requests and notifications). Raise in `before_dispatch` to halt execution:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
Clamo::Server.before_dispatch = ->(method, params) {
|
|
158
|
+
raise "unauthorized" unless allowed?(method)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
Clamo::Server.after_dispatch = ->(method, params, result) {
|
|
162
|
+
Rails.logger.info("#{method} completed")
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Per-Call Configuration
|
|
167
|
+
|
|
168
|
+
All configuration options can be overridden per-call. Module-level settings serve as defaults:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
Clamo::Server.handle(
|
|
172
|
+
request: body,
|
|
173
|
+
object: MyService,
|
|
174
|
+
timeout: 5,
|
|
175
|
+
on_error: ->(e, method, params) { MyLogger.error(e) },
|
|
176
|
+
before_dispatch: ->(method, params) { authorize!(method) },
|
|
177
|
+
after_dispatch: ->(method, params, result) { track(method) }
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Per-call config is snapshotted at the start of each dispatch, so concurrent mutations to module-level settings cannot affect in-flight requests.
|
|
182
|
+
|
|
152
183
|
## Advanced Features
|
|
153
184
|
|
|
154
185
|
### Parallel Processing
|
data/lib/clamo/jsonrpc.rb
CHANGED
|
@@ -35,26 +35,26 @@ module Clamo
|
|
|
35
35
|
|
|
36
36
|
validate_params_type!(opts[:params]) if opts.key?(:params)
|
|
37
37
|
|
|
38
|
-
{ jsonrpc
|
|
39
|
-
.then { |r| opts.key?(:params) ? r.merge(params
|
|
40
|
-
.then { |r| opts.key?(:id) ? r.merge(id
|
|
38
|
+
{ "jsonrpc" => "2.0", "method" => opts[:method] }
|
|
39
|
+
.then { |r| opts.key?(:params) ? r.merge("params" => opts[:params]) : r }
|
|
40
|
+
.then { |r| opts.key?(:id) ? r.merge("id" => opts[:id]) : r }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def build_result_response(id:, result:)
|
|
44
|
-
{ jsonrpc
|
|
44
|
+
{ "jsonrpc" => "2.0", "result" => result, "id" => id }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def build_error_response(**opts)
|
|
48
48
|
raise ArgumentError, "error code is required" unless opts.dig(:error, :code)
|
|
49
49
|
raise ArgumentError, "error message is required" unless opts.dig(:error, :message)
|
|
50
50
|
|
|
51
|
-
{ jsonrpc
|
|
52
|
-
id
|
|
53
|
-
error
|
|
54
|
-
code
|
|
55
|
-
message
|
|
56
|
-
data
|
|
57
|
-
}.reject { |k, _| k ==
|
|
51
|
+
{ "jsonrpc" => "2.0",
|
|
52
|
+
"id" => opts[:id],
|
|
53
|
+
"error" => {
|
|
54
|
+
"code" => opts.dig(:error, :code),
|
|
55
|
+
"message" => opts.dig(:error, :message),
|
|
56
|
+
"data" => opts.dig(:error, :data)
|
|
57
|
+
}.reject { |k, _| k == "data" && !opts[:error].key?(:data) } }
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def build_error_response_from(descriptor:, id: nil)
|
data/lib/clamo/server.rb
CHANGED
|
@@ -6,20 +6,13 @@ require "timeout"
|
|
|
6
6
|
|
|
7
7
|
module Clamo
|
|
8
8
|
module Server
|
|
9
|
-
|
|
10
|
-
# Global error callback for notification failures.
|
|
11
|
-
# This is module-level state shared across all callers.
|
|
12
|
-
# Set to any callable (lambda, method, proc) that accepts (exception, method, params).
|
|
13
|
-
#
|
|
14
|
-
# Clamo::Server.on_error = ->(e, method, params) { Rails.logger.error(e) }
|
|
15
|
-
#
|
|
16
|
-
attr_accessor :on_error
|
|
9
|
+
Config = Data.define(:timeout, :on_error, :before_dispatch, :after_dispatch)
|
|
17
10
|
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
11
|
+
class << self
|
|
12
|
+
# Module-level defaults. These are snapshotted at the start of each
|
|
13
|
+
# dispatch call, so mutations mid-request do not affect in-flight work.
|
|
14
|
+
# All four can be overridden per-call via keyword arguments.
|
|
15
|
+
attr_accessor :on_error, :before_dispatch, :after_dispatch
|
|
23
16
|
attr_writer :timeout
|
|
24
17
|
|
|
25
18
|
def timeout
|
|
@@ -53,18 +46,41 @@ module Clamo
|
|
|
53
46
|
parsed_dispatch_to_object(request: parsed, object: object, **)
|
|
54
47
|
end
|
|
55
48
|
|
|
56
|
-
def parsed_dispatch_to_object(request:, object:,
|
|
49
|
+
def parsed_dispatch_to_object(request:, object:,
|
|
50
|
+
timeout: self.timeout,
|
|
51
|
+
on_error: self.on_error,
|
|
52
|
+
before_dispatch: self.before_dispatch,
|
|
53
|
+
after_dispatch: self.after_dispatch,
|
|
54
|
+
**opts)
|
|
57
55
|
raise ArgumentError, "object is required" unless object
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
request = normalize_request_keys(request)
|
|
58
|
+
config = Config.new(timeout: timeout, on_error: on_error,
|
|
59
|
+
before_dispatch: before_dispatch, after_dispatch: after_dispatch)
|
|
60
|
+
|
|
61
|
+
response_for(request: request, object: object, config: config, **opts) do |method, params|
|
|
60
62
|
dispatch_to_ruby(object: object, method: method, params: params)
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
private
|
|
65
67
|
|
|
68
|
+
def normalize_request_keys(request)
|
|
69
|
+
case request
|
|
70
|
+
when Hash then request.transform_keys(&:to_s)
|
|
71
|
+
when Array then request.map { |r| r.is_a?(Hash) ? r.transform_keys(&:to_s) : r }
|
|
72
|
+
else request
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
66
76
|
def method_known?(object:, method:)
|
|
67
|
-
|
|
77
|
+
name = method.to_sym
|
|
78
|
+
if object.is_a?(Module)
|
|
79
|
+
object.singleton_class.public_method_defined?(name, false)
|
|
80
|
+
else
|
|
81
|
+
object.class.public_method_defined?(name, false) ||
|
|
82
|
+
object.singleton_class.public_method_defined?(name, false)
|
|
83
|
+
end
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
def dispatch_to_ruby(object:, method:, params:)
|
|
@@ -78,18 +94,18 @@ module Clamo
|
|
|
78
94
|
# Extra keyword arguments (**) are forwarded to response_for_batch only,
|
|
79
95
|
# where they become options for Parallel.map (e.g., in_processes: 4).
|
|
80
96
|
# For single requests they are silently ignored.
|
|
81
|
-
def response_for(request:, object:, **, &block)
|
|
97
|
+
def response_for(request:, object:, config:, **, &block)
|
|
82
98
|
case request
|
|
83
99
|
when Array
|
|
84
|
-
response_for_batch(request: request, object: object, block: block, **)
|
|
100
|
+
response_for_batch(request: request, object: object, block: block, config: config, **)
|
|
85
101
|
when Hash
|
|
86
|
-
response_for_single_request(request: request, object: object, block: block)
|
|
102
|
+
response_for_single_request(request: request, object: object, block: block, config: config)
|
|
87
103
|
else
|
|
88
104
|
JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
|
|
89
105
|
end
|
|
90
106
|
end
|
|
91
107
|
|
|
92
|
-
def response_for_single_request(request:, object:, block:)
|
|
108
|
+
def response_for_single_request(request:, object:, block:, config:)
|
|
93
109
|
error = validate_request_structure(request)
|
|
94
110
|
return error if error
|
|
95
111
|
|
|
@@ -97,23 +113,23 @@ module Clamo
|
|
|
97
113
|
return request.key?("id") ? method_not_found_error(request) : nil
|
|
98
114
|
end
|
|
99
115
|
|
|
100
|
-
return dispatch_notification(request, block) unless request.key?("id")
|
|
116
|
+
return dispatch_notification(request, block, config) unless request.key?("id")
|
|
101
117
|
|
|
102
|
-
dispatch_request(request, block)
|
|
118
|
+
dispatch_request(request, block, config)
|
|
103
119
|
end
|
|
104
120
|
|
|
105
|
-
def response_for_batch(request:, object:, block:, **opts)
|
|
121
|
+
def response_for_batch(request:, object:, block:, config:, **opts)
|
|
106
122
|
if request.empty?
|
|
107
123
|
return JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
|
|
108
124
|
end
|
|
109
125
|
|
|
110
126
|
if request.size == 1
|
|
111
|
-
result = response_for_single_request(request: request.first, object: object, block: block)
|
|
127
|
+
result = response_for_single_request(request: request.first, object: object, block: block, config: config)
|
|
112
128
|
return result ? [result] : nil
|
|
113
129
|
end
|
|
114
130
|
|
|
115
131
|
result = Parallel.map(request, **opts) do |item|
|
|
116
|
-
response_for_single_request(request: item, object: object, block: block)
|
|
132
|
+
response_for_single_request(request: item, object: object, block: block, config: config)
|
|
117
133
|
end.compact
|
|
118
134
|
result.empty? ? nil : result
|
|
119
135
|
end
|
|
@@ -138,20 +154,33 @@ module Clamo
|
|
|
138
154
|
JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
|
|
139
155
|
end
|
|
140
156
|
|
|
141
|
-
def dispatch_notification(request, block)
|
|
142
|
-
|
|
157
|
+
def dispatch_notification(request, block, config)
|
|
158
|
+
method = request["method"]
|
|
159
|
+
params = request["params"]
|
|
160
|
+
config.before_dispatch&.call(method, params)
|
|
161
|
+
result = with_timeout(config.timeout) { block.yield(method, params) }
|
|
162
|
+
config.after_dispatch&.call(method, params, result)
|
|
143
163
|
nil
|
|
144
164
|
rescue StandardError => e
|
|
145
|
-
on_error&.call(e,
|
|
165
|
+
config.on_error&.call(e, method, params)
|
|
146
166
|
nil
|
|
147
167
|
end
|
|
148
168
|
|
|
149
|
-
def dispatch_request(request, block)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
169
|
+
def dispatch_request(request, block, config)
|
|
170
|
+
method = request["method"]
|
|
171
|
+
params = request["params"]
|
|
172
|
+
config.before_dispatch&.call(method, params)
|
|
173
|
+
result = with_timeout(config.timeout) { block.yield(method, params) }
|
|
174
|
+
config.after_dispatch&.call(method, params, result)
|
|
175
|
+
JSONRPC.build_result_response(id: request["id"], result: result)
|
|
154
176
|
rescue Timeout::Error
|
|
177
|
+
timeout_error(request)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
config.on_error&.call(e, method, params)
|
|
180
|
+
JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INTERNAL_ERROR)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def timeout_error(request)
|
|
155
184
|
JSONRPC.build_error_response(
|
|
156
185
|
id: request["id"],
|
|
157
186
|
error: {
|
|
@@ -160,20 +189,11 @@ module Clamo
|
|
|
160
189
|
data: "Request timed out"
|
|
161
190
|
}
|
|
162
191
|
)
|
|
163
|
-
rescue StandardError => e
|
|
164
|
-
JSONRPC.build_error_response(
|
|
165
|
-
id: request["id"],
|
|
166
|
-
error: {
|
|
167
|
-
code: JSONRPC::ProtocolErrors::INTERNAL_ERROR.code,
|
|
168
|
-
message: JSONRPC::ProtocolErrors::INTERNAL_ERROR.message,
|
|
169
|
-
data: e.message
|
|
170
|
-
}
|
|
171
|
-
)
|
|
172
192
|
end
|
|
173
193
|
|
|
174
|
-
def with_timeout(&block)
|
|
175
|
-
if
|
|
176
|
-
Timeout.timeout(
|
|
194
|
+
def with_timeout(seconds, &block)
|
|
195
|
+
if seconds
|
|
196
|
+
Timeout.timeout(seconds, &block)
|
|
177
197
|
else
|
|
178
198
|
block.call
|
|
179
199
|
end
|
data/lib/clamo/version.rb
CHANGED