clamo 0.7.0 → 0.8.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: 97084b2761bd6af3549fd2444f981da0a23ce6f1ebbc740f4c2fb237e90ee1fe
4
+ data.tar.gz: dde0cc4f7187bc58aeb2d1be4169f3cc6331d4008c7a10539317cb80e2a68480
5
5
  SHA512:
6
- metadata.gz: 385175b28fea11461b35a98860a6b8099bd9c88bc8dfef4f2603dc911a3d9e1709b51343d2b22ebb524ffc82aa03de7d8886a50b078c258d5fe28ec897a17296
7
- data.tar.gz: 0c37f0971e24855610a91c8687d8e55ff32a03accf1effc73b6a1090118b46e3d78ffd9f0a7531418aa97a3154969bf7fb4d2dd0da9dc0cfe1b2b7aa94778cda
6
+ metadata.gz: 18a87542a9340025ff18f60594ae32e941bca85f4f268c2dd99ca207dc2c4296c74f2809531e7e43034f27219b4d43fec4f380f61def8e37af6477fa979c273e
7
+ data.tar.gz: 976d81d554ff643ddfcbbaa5185094fda802657b5adee586d60e7c31e7aee27b0f6f141263f23dc8ebbc4d7b0a288beb3e1dc78a41b4209c18c266f2c64931a8
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,21 @@ 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.8.0] - 2026-03-14
8
+
9
+ ### Added
10
+
11
+ - `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)`.
12
+ - 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.
13
+ - `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.
14
+
15
+ ### Changed
16
+
17
+ - 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.
18
+ - `parsed_dispatch_to_object` normalizes request keys to strings at the boundary, fixing silent dispatch failures when callers pass symbol-key hashes.
19
+ - `on_error` is now called for request dispatch errors (previously only for notification errors).
20
+ - Test suite expanded to 95 tests / 138 assertions.
21
+
7
22
  ## [0.7.0] - 2026-03-14
8
23
 
9
24
  ### Added
data/README.md CHANGED
@@ -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/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,16 +46,33 @@ 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
77
  object.public_methods(false).map(&:to_sym).include?(method.to_sym)
68
78
  end
@@ -78,18 +88,18 @@ module Clamo
78
88
  # Extra keyword arguments (**) are forwarded to response_for_batch only,
79
89
  # where they become options for Parallel.map (e.g., in_processes: 4).
80
90
  # For single requests they are silently ignored.
81
- def response_for(request:, object:, **, &block)
91
+ def response_for(request:, object:, config:, **, &block)
82
92
  case request
83
93
  when Array
84
- response_for_batch(request: request, object: object, block: block, **)
94
+ response_for_batch(request: request, object: object, block: block, config: config, **)
85
95
  when Hash
86
- response_for_single_request(request: request, object: object, block: block)
96
+ response_for_single_request(request: request, object: object, block: block, config: config)
87
97
  else
88
98
  JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
89
99
  end
90
100
  end
91
101
 
92
- def response_for_single_request(request:, object:, block:)
102
+ def response_for_single_request(request:, object:, block:, config:)
93
103
  error = validate_request_structure(request)
94
104
  return error if error
95
105
 
@@ -97,23 +107,23 @@ module Clamo
97
107
  return request.key?("id") ? method_not_found_error(request) : nil
98
108
  end
99
109
 
100
- return dispatch_notification(request, block) unless request.key?("id")
110
+ return dispatch_notification(request, block, config) unless request.key?("id")
101
111
 
102
- dispatch_request(request, block)
112
+ dispatch_request(request, block, config)
103
113
  end
104
114
 
105
- def response_for_batch(request:, object:, block:, **opts)
115
+ def response_for_batch(request:, object:, block:, config:, **opts)
106
116
  if request.empty?
107
117
  return JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
108
118
  end
109
119
 
110
120
  if request.size == 1
111
- result = response_for_single_request(request: request.first, object: object, block: block)
121
+ result = response_for_single_request(request: request.first, object: object, block: block, config: config)
112
122
  return result ? [result] : nil
113
123
  end
114
124
 
115
125
  result = Parallel.map(request, **opts) do |item|
116
- response_for_single_request(request: item, object: object, block: block)
126
+ response_for_single_request(request: item, object: object, block: block, config: config)
117
127
  end.compact
118
128
  result.empty? ? nil : result
119
129
  end
@@ -138,20 +148,33 @@ module Clamo
138
148
  JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
139
149
  end
140
150
 
141
- def dispatch_notification(request, block)
142
- with_timeout { block.yield request["method"], request["params"] }
151
+ def dispatch_notification(request, block, config)
152
+ method = request["method"]
153
+ params = request["params"]
154
+ config.before_dispatch&.call(method, params)
155
+ with_timeout(config.timeout) { block.yield(method, params) }
156
+ config.after_dispatch&.call(method, params, nil)
143
157
  nil
144
158
  rescue StandardError => e
145
- on_error&.call(e, request["method"], request["params"])
159
+ config.on_error&.call(e, method, params)
146
160
  nil
147
161
  end
148
162
 
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
- )
163
+ def dispatch_request(request, block, config)
164
+ method = request["method"]
165
+ params = request["params"]
166
+ config.before_dispatch&.call(method, params)
167
+ result = with_timeout(config.timeout) { block.yield(method, params) }
168
+ config.after_dispatch&.call(method, params, result)
169
+ JSONRPC.build_result_response(id: request["id"], result: result)
154
170
  rescue Timeout::Error
171
+ timeout_error(request)
172
+ rescue StandardError => e
173
+ config.on_error&.call(e, method, params)
174
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INTERNAL_ERROR)
175
+ end
176
+
177
+ def timeout_error(request)
155
178
  JSONRPC.build_error_response(
156
179
  id: request["id"],
157
180
  error: {
@@ -160,20 +183,11 @@ module Clamo
160
183
  data: "Request timed out"
161
184
  }
162
185
  )
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
186
  end
173
187
 
174
- def with_timeout(&block)
175
- if timeout
176
- Timeout.timeout(timeout, &block)
188
+ def with_timeout(seconds, &block)
189
+ if seconds
190
+ Timeout.timeout(seconds, &block)
177
191
  else
178
192
  block.call
179
193
  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.8.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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andriy Tyurnikov