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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 410f469e07b16f8273149b1eded89c94692a91c8b41cd7c47642e897ca8d97fb
4
- data.tar.gz: 2d6be4be393cfd70d5bf22109943b7735de5ca0abe93b65a888fe1a372640faa
3
+ metadata.gz: e1a61a818aab987fe6d621d2f3bf642fd7993044933b0b258f2a515d896bb7a5
4
+ data.tar.gz: b68b313c737400cda3d739902d1d2d498a2d28344cf9cc0afc5c8b481850bcb5
5
5
  SHA512:
6
- metadata.gz: 385175b28fea11461b35a98860a6b8099bd9c88bc8dfef4f2603dc911a3d9e1709b51343d2b22ebb524ffc82aa03de7d8886a50b078c258d5fe28ec897a17296
7
- data.tar.gz: 0c37f0971e24855610a91c8687d8e55ff32a03accf1effc73b6a1090118b46e3d78ffd9f0a7531418aa97a3154969bf7fb4d2dd0da9dc0cfe1b2b7aa94778cda
6
+ metadata.gz: b2a1ac30f5f40c035d683d54a36c0164ea24dc56b8ca9af694de499862732faf22cc572029e6bfac1f05413c4d4cf56a737b9b29af96b870fcf23eee5305e756
7
+ data.tar.gz: ccb5a26f16ab0492e3d77e27eeed648e26b6fdcb9bda59c37a264cc3e419ba45a77bede078863e9ea18dface1f9f5c0be13a1629e6b37d4a8fb2da2f8cc291b4
data/.rubocop.yml CHANGED
@@ -11,6 +11,9 @@ Metrics/ModuleLength:
11
11
  Metrics/ClassLength:
12
12
  Enabled: false
13
13
 
14
+ Metrics/ParameterLists:
15
+ CountKeywordArgs: false
16
+
14
17
  Style/Documentation:
15
18
  Enabled: false
16
19
 
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: "2.0", result: 3, id: 1}
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: "2.0", result: 3, id: 1}, {jsonrpc: "2.0", result: 2, id: 2}]
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: "2.0", method: "add", params: [1, 2], id: 1}
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
- Notifications don't return responses, so errors during notification dispatch are silent by default. Use `on_error` to capture them:
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: "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 }
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: "2.0", result: result, id: id }
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: "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) } }
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
- class << self
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
- # Maximum seconds allowed for a single method dispatch. Defaults to 30.
19
- # Set to nil to disable.
20
- #
21
- # Clamo::Server.timeout = 10
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:, **opts)
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
- response_for(request: request, object: object, **opts) do |method, params|
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
- object.public_methods(false).map(&:to_sym).include?(method.to_sym)
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
- with_timeout { block.yield request["method"], request["params"] }
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, request["method"], request["params"])
165
+ config.on_error&.call(e, method, params)
146
166
  nil
147
167
  end
148
168
 
149
- def dispatch_request(request, block)
150
- JSONRPC.build_result_response(
151
- id: request["id"],
152
- result: with_timeout { block.yield(request["method"], request["params"]) }
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 timeout
176
- Timeout.timeout(timeout, &block)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clamo
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clamo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andriy Tyurnikov