clamo 0.6.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: 1c91f68aa4a88440e10a75a4ae3f138df400c10fd698b2d2b8c72990dc7e25f1
4
- data.tar.gz: bef5052025e0714e0297b20d95d1b90dfed7fce4e30a2fecd24cff9e8a84913c
3
+ metadata.gz: 97084b2761bd6af3549fd2444f981da0a23ce6f1ebbc740f4c2fb237e90ee1fe
4
+ data.tar.gz: dde0cc4f7187bc58aeb2d1be4169f3cc6331d4008c7a10539317cb80e2a68480
5
5
  SHA512:
6
- metadata.gz: 96fa52c9f35c408c60a6827fa7886ee35fcea05af69fc6dde54aca497f0347c8de76bfd163fdb6bb4595f498fe9b55e9059e32d7212f997ec43cf5e77fafafef
7
- data.tar.gz: a7519eeba5bfd4074ada39cea72a03afa03cfc22809b53034eaf06c48d8a94a1122e136a6cf7b9452a27fb2f0ca54d9507db91a7bf498073b5f25e76cee74f45
6
+ metadata.gz: 18a87542a9340025ff18f60594ae32e941bca85f4f268c2dd99ca207dc2c4296c74f2809531e7e43034f27219b4d43fec4f380f61def8e37af6477fa979c273e
7
+ data.tar.gz: 976d81d554ff643ddfcbbaa5185094fda802657b5adee586d60e7c31e7aee27b0f6f141263f23dc8ebbc4d7b0a288beb3e1dc78a41b4209c18c266f2c64931a8
data/.rubocop.yml CHANGED
@@ -5,6 +5,15 @@ AllCops:
5
5
  Metrics/MethodLength:
6
6
  Enabled: false
7
7
 
8
+ Metrics/ModuleLength:
9
+ Enabled: false
10
+
11
+ Metrics/ClassLength:
12
+ Enabled: false
13
+
14
+ Metrics/ParameterLists:
15
+ CountKeywordArgs: false
16
+
8
17
  Style/Documentation:
9
18
  Enabled: false
10
19
 
data/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ 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
+
22
+ ## [0.7.0] - 2026-03-14
23
+
24
+ ### Added
25
+
26
+ - `Clamo::Server.timeout` — per-dispatch timeout with 30-second default; returns `-32000 Server error` on timeout for requests, calls `on_error` for notifications. Set to `nil` to disable.
27
+ - MIT LICENSE file and `spec.license` in gemspec
28
+ - Indifferent key access in JSONRPC validators (symbol and string keys both accepted)
29
+ - Tests for string ids, arity mismatch, single-item batches, and handle+timeout (77 tests / 105 assertions)
30
+
31
+ ### Changed
32
+
33
+ - `proper_pragma?`, `proper_method?`, `proper_id_if_any?` moved from public to private API on `Clamo::JSONRPC`
34
+ - Notifications with invalid params type now return `nil` instead of an error response (spec compliance)
35
+ - `parsed_dispatch_to_object` now validates `object:` argument (raises `ArgumentError` if nil)
36
+ - Single-item batches skip `Parallel.map` overhead
37
+ - Gemspec description expanded (no longer identical to summary)
38
+ - README updated with `Server.handle`, `timeout`, and `on_error` documentation
39
+
40
+ ### Removed
41
+
42
+ - `Clamo::Error` base exception class (unused)
43
+ - `sig/clamo.rbs` type signatures (misleadingly incomplete)
44
+ - Dead `else` branch in `dispatch_to_ruby`
45
+
7
46
  ## [0.6.0] - 2026-03-14
8
47
 
9
48
  ### Added
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -26,15 +26,30 @@ module MyService
26
26
  end
27
27
  end
28
28
 
29
- # Handle a JSON-RPC request
30
- request_body = '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}'
31
- response = Clamo::Server.unparsed_dispatch_to_object(
32
- request: request_body,
29
+ # JSON string in, JSON string out — the primary entry point for HTTP/socket integrations.
30
+ # Returns nil for notifications (no response expected).
31
+ json_response = Clamo::Server.handle(
32
+ request: '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}',
33
33
  object: MyService
34
34
  )
35
+ # => '{"jsonrpc":"2.0","result":3,"id":1}'
36
+ ```
35
37
 
36
- puts response
38
+ If you need the parsed hash instead of a JSON string, use the lower-level methods:
39
+
40
+ ```ruby
41
+ # From a JSON string
42
+ response = Clamo::Server.unparsed_dispatch_to_object(
43
+ request: '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}',
44
+ object: MyService
45
+ )
37
46
  # => {jsonrpc: "2.0", result: 3, id: 1}
47
+
48
+ # From a pre-parsed hash
49
+ response = Clamo::Server.parsed_dispatch_to_object(
50
+ request: { "jsonrpc" => "2.0", "method" => "add", "params" => [1, 2], "id" => 1 },
51
+ object: MyService
52
+ )
38
53
  ```
39
54
 
40
55
  ### Handling Different Parameter Types
@@ -113,11 +128,63 @@ Clamo follows the JSON-RPC 2.0 specification for error handling:
113
128
  | -32603 | Internal error | Internal JSON-RPC error |
114
129
  | -32000 | Server error | Reserved for implementation-defined server errors |
115
130
 
131
+ ## Configuration
132
+
133
+ ### Timeout
134
+
135
+ Every method dispatch is wrapped in a timeout. The default is 30 seconds. Timed-out requests return a `-32000 Server error` response.
136
+
137
+ ```ruby
138
+ Clamo::Server.timeout = 10 # seconds
139
+ Clamo::Server.timeout = nil # disable timeout
140
+ ```
141
+
142
+ ### Error Callback
143
+
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
+
146
+ ```ruby
147
+ Clamo::Server.on_error = ->(exception, method, params) {
148
+ Rails.logger.error("#{method} failed: #{exception.message}")
149
+ }
150
+ ```
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
+
116
183
  ## Advanced Features
117
184
 
118
185
  ### Parallel Processing
119
186
 
120
- Batch requests are processed in parallel using the [parallel](https://github.com/grosser/parallel) gem. You can pass options to `Parallel.map` via the `parsed_dispatch_to_object` method:
187
+ Batch requests are processed in parallel using the [parallel](https://github.com/grosser/parallel) gem. You can pass options to `Parallel.map`:
121
188
 
122
189
  ```ruby
123
190
  Clamo::Server.parsed_dispatch_to_object(
data/lib/clamo/jsonrpc.rb CHANGED
@@ -14,27 +14,10 @@ module Clamo
14
14
  end
15
15
 
16
16
  class << self
17
- def proper_pragma?(request)
18
- request["jsonrpc"] == "2.0"
19
- end
20
-
21
- def proper_method?(request)
22
- request["method"].is_a?(String)
23
- end
24
-
25
- def proper_id_if_any?(request)
26
- if request.key?("id")
27
- request["id"].is_a?(String) ||
28
- request["id"].is_a?(Integer) ||
29
- request["id"].is_a?(NilClass)
30
- else
31
- true
32
- end
33
- end
34
-
35
17
  def proper_params_if_any?(request)
36
- if request.key?("params")
37
- request["params"].is_a?(Array) || request["params"].is_a?(Hash)
18
+ if key_indifferent?(request, "params")
19
+ params = fetch_indifferent(request, "params")
20
+ params.is_a?(Array) || params.is_a?(Hash)
38
21
  else
39
22
  true
40
23
  end
@@ -96,11 +79,36 @@ module Clamo
96
79
 
97
80
  private
98
81
 
82
+ def proper_pragma?(request)
83
+ fetch_indifferent(request, "jsonrpc") == "2.0"
84
+ end
85
+
86
+ def proper_method?(request)
87
+ fetch_indifferent(request, "method").is_a?(String)
88
+ end
89
+
90
+ def proper_id_if_any?(request)
91
+ if key_indifferent?(request, "id")
92
+ id = fetch_indifferent(request, "id")
93
+ id.is_a?(String) || id.is_a?(Integer) || id.is_a?(NilClass)
94
+ else
95
+ true
96
+ end
97
+ end
98
+
99
99
  def validate_params_type!(params)
100
100
  return if params.is_a?(Array) || params.is_a?(Hash)
101
101
 
102
102
  raise ArgumentError, "params must be an Array or Hash"
103
103
  end
104
+
105
+ def fetch_indifferent(hash, key)
106
+ hash.fetch(key.to_s) { hash.fetch(key.to_sym, nil) }
107
+ end
108
+
109
+ def key_indifferent?(hash, key)
110
+ hash.key?(key.to_s) || hash.key?(key.to_sym)
111
+ end
104
112
  end
105
113
  end
106
114
  end
data/lib/clamo/server.rb CHANGED
@@ -2,17 +2,24 @@
2
2
 
3
3
  require "json"
4
4
  require "parallel"
5
+ require "timeout"
5
6
 
6
7
  module Clamo
7
8
  module Server
9
+ Config = Data.define(:timeout, :on_error, :before_dispatch, :after_dispatch)
10
+
8
11
  class << self
9
- # Global error callback for notification failures.
10
- # This is module-level state shared across all callers.
11
- # Set to any callable (lambda, method, proc) that accepts (exception, method, params).
12
- #
13
- # Clamo::Server.on_error = ->(e, method, params) { Rails.logger.error(e) }
14
- #
15
- attr_accessor :on_error
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
16
+ attr_writer :timeout
17
+
18
+ def timeout
19
+ return @timeout if defined?(@timeout)
20
+
21
+ 30
22
+ end
16
23
 
17
24
  # JSON string in, JSON string out. Full round-trip for HTTP/socket integrations.
18
25
  #
@@ -39,39 +46,60 @@ module Clamo
39
46
  parsed_dispatch_to_object(request: parsed, object: object, **)
40
47
  end
41
48
 
42
- def parsed_dispatch_to_object(request:, object:, **opts)
43
- response_for(request: request, object: object, **opts) do |method, params|
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)
55
+ raise ArgumentError, "object is required" unless object
56
+
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|
44
62
  dispatch_to_ruby(object: object, method: method, params: params)
45
63
  end
46
64
  end
47
65
 
48
66
  private
49
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
+
50
76
  def method_known?(object:, method:)
51
77
  object.public_methods(false).map(&:to_sym).include?(method.to_sym)
52
78
  end
53
79
 
54
80
  def dispatch_to_ruby(object:, method:, params:)
55
81
  case params
56
- when Array then object.public_send(method.to_sym, *params)
57
- when Hash then object.public_send(method.to_sym, **params.transform_keys(&:to_sym))
58
- when NilClass then object.public_send(method.to_sym)
59
- else raise ArgumentError, "Unsupported params type: #{params.class}"
82
+ when Array then object.public_send(method.to_sym, *params)
83
+ when Hash then object.public_send(method.to_sym, **params.transform_keys(&:to_sym))
84
+ when NilClass then object.public_send(method.to_sym)
60
85
  end
61
86
  end
62
87
 
63
- def response_for(request:, object:, **, &block)
88
+ # Extra keyword arguments (**) are forwarded to response_for_batch only,
89
+ # where they become options for Parallel.map (e.g., in_processes: 4).
90
+ # For single requests they are silently ignored.
91
+ def response_for(request:, object:, config:, **, &block)
64
92
  case request
65
93
  when Array
66
- response_for_batch(request: request, object: object, block: block, **)
94
+ response_for_batch(request: request, object: object, block: block, config: config, **)
67
95
  when Hash
68
- response_for_single_request(request: request, object: object, block: block)
96
+ response_for_single_request(request: request, object: object, block: block, config: config)
69
97
  else
70
98
  JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
71
99
  end
72
100
  end
73
101
 
74
- def response_for_single_request(request:, object:, block:)
102
+ def response_for_single_request(request:, object:, block:, config:)
75
103
  error = validate_request_structure(request)
76
104
  return error if error
77
105
 
@@ -79,18 +107,23 @@ module Clamo
79
107
  return request.key?("id") ? method_not_found_error(request) : nil
80
108
  end
81
109
 
82
- return dispatch_notification(request, block) unless request.key?("id")
110
+ return dispatch_notification(request, block, config) unless request.key?("id")
83
111
 
84
- dispatch_request(request, block)
112
+ dispatch_request(request, block, config)
85
113
  end
86
114
 
87
- def response_for_batch(request:, object:, block:, **opts)
115
+ def response_for_batch(request:, object:, block:, config:, **opts)
88
116
  if request.empty?
89
117
  return JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
90
118
  end
91
119
 
120
+ if request.size == 1
121
+ result = response_for_single_request(request: request.first, object: object, block: block, config: config)
122
+ return result ? [result] : nil
123
+ end
124
+
92
125
  result = Parallel.map(request, **opts) do |item|
93
- response_for_single_request(request: item, object: object, block: block)
126
+ response_for_single_request(request: item, object: object, block: block, config: config)
94
127
  end.compact
95
128
  result.empty? ? nil : result
96
129
  end
@@ -105,6 +138,9 @@ module Clamo
105
138
 
106
139
  return if JSONRPC.proper_params_if_any?(request)
107
140
 
141
+ # Notifications must never produce a response, even for invalid params
142
+ return nil unless request.key?("id")
143
+
108
144
  JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INVALID_PARAMS)
109
145
  end
110
146
 
@@ -112,29 +148,50 @@ module Clamo
112
148
  JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
113
149
  end
114
150
 
115
- def dispatch_notification(request, block)
116
- 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)
117
157
  nil
118
158
  rescue StandardError => e
119
- on_error&.call(e, request["method"], request["params"])
159
+ config.on_error&.call(e, method, params)
120
160
  nil
121
161
  end
122
162
 
123
- def dispatch_request(request, block)
124
- JSONRPC.build_result_response(
125
- id: request["id"],
126
- result: block.yield(request["method"], request["params"])
127
- )
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)
170
+ rescue Timeout::Error
171
+ timeout_error(request)
128
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)
129
178
  JSONRPC.build_error_response(
130
179
  id: request["id"],
131
180
  error: {
132
- code: JSONRPC::ProtocolErrors::INTERNAL_ERROR.code,
133
- message: JSONRPC::ProtocolErrors::INTERNAL_ERROR.message,
134
- data: e.message
181
+ code: JSONRPC::ProtocolErrors::SERVER_ERROR.code,
182
+ message: JSONRPC::ProtocolErrors::SERVER_ERROR.message,
183
+ data: "Request timed out"
135
184
  }
136
185
  )
137
186
  end
187
+
188
+ def with_timeout(seconds, &block)
189
+ if seconds
190
+ Timeout.timeout(seconds, &block)
191
+ else
192
+ block.call
193
+ end
194
+ end
138
195
  end
139
196
  end
140
197
  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.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/clamo.rb CHANGED
@@ -5,5 +5,4 @@ require_relative "clamo/jsonrpc"
5
5
  require_relative "clamo/server"
6
6
 
7
7
  module Clamo
8
- class Error < StandardError; end
9
8
  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.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andriy Tyurnikov
@@ -23,7 +23,8 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.27'
26
- description: JSON-RPC 2.0 server toolkit for Ruby
26
+ description: Minimal, spec-compliant JSON-RPC 2.0 server for Ruby with request validation,
27
+ method dispatch, batch processing, and notification support.
27
28
  email:
28
29
  - Andriy.Tyurnikov@gmail.com
29
30
  executables: []
@@ -32,15 +33,16 @@ extra_rdoc_files: []
32
33
  files:
33
34
  - ".rubocop.yml"
34
35
  - CHANGELOG.md
36
+ - LICENSE
35
37
  - README.md
36
38
  - Rakefile
37
39
  - lib/clamo.rb
38
40
  - lib/clamo/jsonrpc.rb
39
41
  - lib/clamo/server.rb
40
42
  - lib/clamo/version.rb
41
- - sig/clamo.rbs
42
43
  homepage: https://github.com/rubakas/clamo
43
- licenses: []
44
+ licenses:
45
+ - MIT
44
46
  metadata:
45
47
  homepage_uri: https://github.com/rubakas/clamo
46
48
  source_code_uri: https://github.com/rubakas/clamo
data/sig/clamo.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Clamo
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end