clamo 0.9.0 → 0.12.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: e1a61a818aab987fe6d621d2f3bf642fd7993044933b0b258f2a515d896bb7a5
4
- data.tar.gz: b68b313c737400cda3d739902d1d2d498a2d28344cf9cc0afc5c8b481850bcb5
3
+ metadata.gz: 7596405122290bf9bce3c04760bbb5e03eef24f8e5e6b3c6e99105c8faa7d14e
4
+ data.tar.gz: 219e2f1f54ffd99c03dcde0c18508d24625904daa2aa65f5c7c1aabbfacee11c
5
5
  SHA512:
6
- metadata.gz: b2a1ac30f5f40c035d683d54a36c0164ea24dc56b8ca9af694de499862732faf22cc572029e6bfac1f05413c4d4cf56a737b9b29af96b870fcf23eee5305e756
7
- data.tar.gz: ccb5a26f16ab0492e3d77e27eeed648e26b6fdcb9bda59c37a264cc3e419ba45a77bede078863e9ea18dface1f9f5c0be13a1629e6b37d4a8fb2da2f8cc291b4
6
+ metadata.gz: 7d6a2c1407df2fc1427a7c95500d07f4873660c2322bcd4ae1a4f642550e89d6a723e7854f2126404f985446432a8e59e2690873d28ff25b218e1f01e260898a
7
+ data.tar.gz: eb941c6f05333536695f9f3d6531de593b592653b17c6fb5a10e18c5cca693c6cb2fe3e922a25f17c5a1bf3d90fae8b518674b14fc78b7b2926deff1f3d7223b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ 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.12.0] - 2026-03-14
8
+
9
+ ### Changed
10
+
11
+ - Extracted `error_for` helper to centralize notification-aware error responses, replacing scattered `request.key?("id") ? error : nil` pattern.
12
+ - Split `validate_request_structure` into envelope validation and `validate_params_type`, making the single-request pipeline a clear 5-stage chain.
13
+ - Removed `method_not_found_error` and `arity_mismatch_error` one-liner wrappers (replaced by `error_for` calls).
14
+
15
+ ### Added
16
+
17
+ - `server_error_response` test helper, completing the error response helper set.
18
+ - Test for arity-mismatch notification returning nil.
19
+
20
+ ## [0.11.0] - 2026-03-14
21
+
22
+ ### Changed
23
+
24
+ - **Breaking:** `dispatch` is now the canonical name for `parsed_dispatch_to_object`. The old name remains as a deprecated alias.
25
+ - **Breaking:** `dispatch_json` is now the canonical name for `unparsed_dispatch_to_object`. The old name remains as a deprecated alias.
26
+ - **Breaking:** `handle_json` is now the canonical name for `handle`. The old name remains as a deprecated alias.
27
+
28
+ ## [0.10.0] - 2026-03-14
29
+
30
+ ### Removed
31
+
32
+ - **Breaking:** `timeout` configuration and `Timeout.timeout` wrapping — Ruby's `Timeout.timeout` is unsafe (can fire during `ensure`, IO, or lock acquisition). Callers who need timeouts should wrap dispatch externally.
33
+ - **Breaking:** `before_dispatch` and `after_dispatch` hooks — callers can wrap `dispatch` calls directly for the same effect with less coupling.
34
+ - `parallel` as a runtime dependency — it is now loaded on demand. Batch requests fall back to sequential `map` when the gem is not installed.
35
+
36
+ ### Added
37
+
38
+ - **Arity validation** — parameter count and keyword names are checked against the Ruby method signature before dispatch. Mismatches return `-32602 Invalid params` instead of the previous `-32603 Internal error`.
39
+ - `Clamo::Server.dispatch` — alias for `parsed_dispatch_to_object`.
40
+ - `Clamo::Server.dispatch_json` — alias for `unparsed_dispatch_to_object`.
41
+ - Concurrency tests for thread-safe dispatch.
42
+
43
+ ### Changed
44
+
45
+ - `Config` simplified to `Data.define(:on_error)` (was `Data.define(:timeout, :on_error, :before_dispatch, :after_dispatch)`).
46
+ - `parsed_dispatch_to_object` signature reduced to `(request:, object:, on_error:, **opts)`.
47
+
7
48
  ## [0.9.0] - 2026-03-14
8
49
 
9
50
  ### Changed
data/README.md CHANGED
@@ -28,30 +28,32 @@ end
28
28
 
29
29
  # JSON string in, JSON string out — the primary entry point for HTTP/socket integrations.
30
30
  # Returns nil for notifications (no response expected).
31
- json_response = Clamo::Server.handle(
31
+ json_response = Clamo::Server.handle_json(
32
32
  request: '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}',
33
33
  object: MyService
34
34
  )
35
35
  # => '{"jsonrpc":"2.0","result":3,"id":1}'
36
36
  ```
37
37
 
38
- If you need the parsed hash instead of a JSON string, use the lower-level methods:
38
+ If you need the parsed hash instead of a JSON string, use the lower-level methods directly or via their shorter aliases:
39
39
 
40
40
  ```ruby
41
41
  # From a JSON string
42
- response = Clamo::Server.unparsed_dispatch_to_object(
42
+ response = Clamo::Server.dispatch_json(
43
43
  request: '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}',
44
44
  object: MyService
45
45
  )
46
46
  # => {"jsonrpc" => "2.0", "result" => 3, "id" => 1}
47
47
 
48
48
  # From a pre-parsed hash
49
- response = Clamo::Server.parsed_dispatch_to_object(
49
+ response = Clamo::Server.dispatch(
50
50
  request: { "jsonrpc" => "2.0", "method" => "add", "params" => [1, 2], "id" => 1 },
51
51
  object: MyService
52
52
  )
53
53
  ```
54
54
 
55
+ The longer names `parsed_dispatch_to_object`, `unparsed_dispatch_to_object`, and `handle` still work as deprecated aliases.
56
+
55
57
  ### Handling Different Parameter Types
56
58
 
57
59
  Clamo supports both positional (array) and named (object/hash) parameters:
@@ -76,7 +78,7 @@ batch_request = <<~JSON
76
78
  ]
77
79
  JSON
78
80
 
79
- batch_response = Clamo::Server.unparsed_dispatch_to_object(
81
+ batch_response = Clamo::Server.dispatch_json(
80
82
  request: batch_request,
81
83
  object: MyService
82
84
  )
@@ -91,7 +93,7 @@ Notifications are requests without an ID field. They don't produce a response:
91
93
 
92
94
  ```ruby
93
95
  notification = '{"jsonrpc": "2.0", "method": "add", "params": [1, 2]}'
94
- response = Clamo::Server.unparsed_dispatch_to_object(
96
+ response = Clamo::Server.dispatch_json(
95
97
  request: notification,
96
98
  object: MyService
97
99
  )
@@ -124,24 +126,17 @@ Clamo follows the JSON-RPC 2.0 specification for error handling:
124
126
  | -32700 | Parse error | Invalid JSON was received |
125
127
  | -32600 | Invalid request | The JSON sent is not a valid Request object |
126
128
  | -32601 | Method not found | The method does not exist / is not available |
127
- | -32602 | Invalid params | Invalid method parameter(s) |
129
+ | -32602 | Invalid params | Invalid method parameter(s) or arity mismatch |
128
130
  | -32603 | Internal error | Internal JSON-RPC error |
129
- | -32000 | Server error | Reserved for implementation-defined server errors |
130
-
131
- ## Configuration
132
-
133
- ### Timeout
131
+ | -32000 | Server error | Exception raised by dispatched method |
134
132
 
135
- Every method dispatch is wrapped in a timeout. The default is 30 seconds. Timed-out requests return a `-32000 Server error` response.
133
+ Parameter arity is validated before dispatch. If the number of positional arguments or keyword arguments doesn't match the Ruby method signature, a `-32602 Invalid params` error is returned.
136
134
 
137
- ```ruby
138
- Clamo::Server.timeout = 10 # seconds
139
- Clamo::Server.timeout = nil # disable timeout
140
- ```
135
+ ## Configuration
141
136
 
142
137
  ### Error Callback
143
138
 
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:
139
+ Errors during dispatch are reported through `on_error`, which is called for both requests and notifications. Notifications are silent by default (no response is sent to the client, but `on_error` still fires); requests return a generic `-32000 Server error` without leaking exception details. Use `on_error` to capture the full exception for logging:
145
140
 
146
141
  ```ruby
147
142
  Clamo::Server.on_error = ->(exception, method, params) {
@@ -149,32 +144,15 @@ Clamo::Server.on_error = ->(exception, method, params) {
149
144
  }
150
145
  ```
151
146
 
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
147
  ### Per-Call Configuration
167
148
 
168
- All configuration options can be overridden per-call. Module-level settings serve as defaults:
149
+ Configuration can be overridden per-call. Module-level settings serve as defaults:
169
150
 
170
151
  ```ruby
171
- Clamo::Server.handle(
152
+ Clamo::Server.handle_json(
172
153
  request: body,
173
154
  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) }
155
+ on_error: ->(e, method, params) { MyLogger.error(e) }
178
156
  )
179
157
  ```
180
158
 
@@ -184,10 +162,10 @@ Per-call config is snapshotted at the start of each dispatch, so concurrent muta
184
162
 
185
163
  ### Parallel Processing
186
164
 
187
- Batch requests are processed in parallel using the [parallel](https://github.com/grosser/parallel) gem. You can pass options to `Parallel.map`:
165
+ Batch requests are processed in parallel when the [parallel](https://github.com/grosser/parallel) gem is available. If `parallel` is not installed, batches fall back to sequential processing. You can pass options to `Parallel.map`:
188
166
 
189
167
  ```ruby
190
- Clamo::Server.parsed_dispatch_to_object(
168
+ Clamo::Server.dispatch(
191
169
  request: batch_request,
192
170
  object: MyService,
193
171
  in_processes: 4 # Parallel processing option
data/lib/clamo/jsonrpc.rb CHANGED
@@ -14,22 +14,6 @@ module Clamo
14
14
  end
15
15
 
16
16
  class << self
17
- def proper_params_if_any?(request)
18
- if key_indifferent?(request, "params")
19
- params = fetch_indifferent(request, "params")
20
- params.is_a?(Array) || params.is_a?(Hash)
21
- else
22
- true
23
- end
24
- end
25
-
26
- def valid_request?(request)
27
- request.is_a?(Hash) &&
28
- proper_pragma?(request) &&
29
- proper_method?(request) &&
30
- proper_id_if_any?(request)
31
- end
32
-
33
17
  def build_request(**opts)
34
18
  raise ArgumentError, "method is required" unless opts.key?(:method)
35
19
 
@@ -77,6 +61,24 @@ module Clamo
77
61
  )
78
62
  end
79
63
 
64
+ # Used internally by Clamo::Server — not part of the public API.
65
+ def proper_params_if_any?(request)
66
+ if key_indifferent?(request, "params")
67
+ params = fetch_indifferent(request, "params")
68
+ params.is_a?(Array) || params.is_a?(Hash)
69
+ else
70
+ true
71
+ end
72
+ end
73
+
74
+ # Used internally by Clamo::Server — not part of the public API.
75
+ def valid_request?(request)
76
+ request.is_a?(Hash) &&
77
+ proper_pragma?(request) &&
78
+ proper_method?(request) &&
79
+ proper_id_if_any?(request)
80
+ end
81
+
80
82
  private
81
83
 
82
84
  def proper_pragma?(request)
data/lib/clamo/server.rb CHANGED
@@ -1,40 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "parallel"
5
- require "timeout"
6
4
 
7
5
  module Clamo
8
6
  module Server
9
- Config = Data.define(:timeout, :on_error, :before_dispatch, :after_dispatch)
7
+ Config = Data.define(:on_error)
10
8
 
11
9
  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
16
- attr_writer :timeout
17
-
18
- def timeout
19
- return @timeout if defined?(@timeout)
20
-
21
- 30
22
- end
10
+ attr_accessor :on_error
23
11
 
24
12
  # JSON string in, JSON string out. Full round-trip for HTTP/socket integrations.
25
13
  #
26
- # Clamo::Server.handle(request: body, object: MyService)
14
+ # Clamo::Server.handle_json(request: body, object: MyService)
27
15
  #
28
- def handle(request:, object:, **)
29
- response = unparsed_dispatch_to_object(request: request, object: object, **)
16
+ def handle_json(request:, object:, **)
17
+ response = dispatch_json(request: request, object: object, **)
30
18
  response&.to_json
31
19
  end
32
20
 
33
- # Clamo::Server.unparsed_dispatch_to_object(
34
- # request: request_body,
35
- # object: MyModule
36
- # )
37
- def unparsed_dispatch_to_object(request:, object:, **)
21
+ alias handle handle_json
22
+
23
+ # JSON string in, parsed response out.
24
+ #
25
+ # Clamo::Server.dispatch_json(request: json_string, object: MyModule)
26
+ #
27
+ def dispatch_json(request:, object:, **)
38
28
  raise ArgumentError, "object is required" unless object
39
29
 
40
30
  begin
@@ -43,26 +33,30 @@ module Clamo
43
33
  return JSONRPC.build_error_response_parse_error
44
34
  end
45
35
 
46
- parsed_dispatch_to_object(request: parsed, object: object, **)
36
+ dispatch(request: parsed, object: object, **)
47
37
  end
48
38
 
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)
39
+ alias unparsed_dispatch_to_object dispatch_json
40
+
41
+ # Parsed hash in, parsed response out.
42
+ #
43
+ # Clamo::Server.dispatch(request: hash_or_array, object: MyModule)
44
+ #
45
+ def dispatch(request:, object:,
46
+ on_error: self.on_error,
47
+ **opts)
55
48
  raise ArgumentError, "object is required" unless object
56
49
 
57
50
  request = normalize_request_keys(request)
58
- config = Config.new(timeout: timeout, on_error: on_error,
59
- before_dispatch: before_dispatch, after_dispatch: after_dispatch)
51
+ config = Config.new(on_error: on_error)
60
52
 
61
53
  response_for(request: request, object: object, config: config, **opts) do |method, params|
62
54
  dispatch_to_ruby(object: object, method: method, params: params)
63
55
  end
64
56
  end
65
57
 
58
+ alias parsed_dispatch_to_object dispatch
59
+
66
60
  private
67
61
 
68
62
  def normalize_request_keys(request)
@@ -109,8 +103,15 @@ module Clamo
109
103
  error = validate_request_structure(request)
110
104
  return error if error
111
105
 
106
+ error = validate_params_type(request)
107
+ return error if error
108
+
112
109
  unless method_known?(object: object, method: request["method"])
113
- return request.key?("id") ? method_not_found_error(request) : nil
110
+ return error_for(request, JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
111
+ end
112
+
113
+ unless params_match_arity?(object: object, method: request["method"], params: request["params"])
114
+ return error_for(request, JSONRPC::ProtocolErrors::INVALID_PARAMS)
114
115
  end
115
116
 
116
117
  return dispatch_notification(request, block, config) unless request.key?("id")
@@ -128,38 +129,44 @@ module Clamo
128
129
  return result ? [result] : nil
129
130
  end
130
131
 
131
- result = Parallel.map(request, **opts) do |item|
132
+ result = map_batch(request, **opts) do |item|
132
133
  response_for_single_request(request: item, object: object, block: block, config: config)
133
134
  end.compact
134
135
  result.empty? ? nil : result
135
136
  end
136
137
 
137
- def validate_request_structure(request)
138
- unless JSONRPC.valid_request?(request)
139
- return JSONRPC.build_error_response_from(
140
- id: request.is_a?(Hash) ? request["id"] : nil,
141
- descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST
142
- )
143
- end
138
+ def map_batch(items, **, &)
139
+ require "parallel"
140
+ Parallel.map(items, **, &)
141
+ rescue LoadError
142
+ items.map(&)
143
+ end
144
144
 
145
- return if JSONRPC.proper_params_if_any?(request)
145
+ def error_for(request, descriptor)
146
+ return unless request.key?("id")
146
147
 
147
- # Notifications must never produce a response, even for invalid params
148
- return nil unless request.key?("id")
148
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: descriptor)
149
+ end
149
150
 
150
- JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INVALID_PARAMS)
151
+ def validate_request_structure(request)
152
+ return if JSONRPC.valid_request?(request)
153
+
154
+ JSONRPC.build_error_response_from(
155
+ id: request.is_a?(Hash) ? request["id"] : nil,
156
+ descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST
157
+ )
151
158
  end
152
159
 
153
- def method_not_found_error(request)
154
- JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
160
+ def validate_params_type(request)
161
+ return if JSONRPC.proper_params_if_any?(request)
162
+
163
+ error_for(request, JSONRPC::ProtocolErrors::INVALID_PARAMS)
155
164
  end
156
165
 
157
166
  def dispatch_notification(request, block, config)
158
167
  method = request["method"]
159
168
  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)
169
+ block.yield(method, params)
163
170
  nil
164
171
  rescue StandardError => e
165
172
  config.on_error&.call(e, method, params)
@@ -169,35 +176,59 @@ module Clamo
169
176
  def dispatch_request(request, block, config)
170
177
  method = request["method"]
171
178
  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)
179
+ result = block.yield(method, params)
175
180
  JSONRPC.build_result_response(id: request["id"], result: result)
176
- rescue Timeout::Error
177
- timeout_error(request)
178
181
  rescue StandardError => e
179
182
  config.on_error&.call(e, method, params)
180
- JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INTERNAL_ERROR)
183
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::SERVER_ERROR)
181
184
  end
182
185
 
183
- def timeout_error(request)
184
- JSONRPC.build_error_response(
185
- id: request["id"],
186
- error: {
187
- code: JSONRPC::ProtocolErrors::SERVER_ERROR.code,
188
- message: JSONRPC::ProtocolErrors::SERVER_ERROR.message,
189
- data: "Request timed out"
190
- }
191
- )
192
- end
186
+ def params_match_arity?(object:, method:, params:)
187
+ ruby_method = resolve_method(object, method)
188
+ return true unless ruby_method
193
189
 
194
- def with_timeout(seconds, &block)
195
- if seconds
196
- Timeout.timeout(seconds, &block)
197
- else
198
- block.call
190
+ parameters = ruby_method.parameters
191
+
192
+ case params
193
+ when Array then array_params_match?(parameters, params.size)
194
+ when Hash then hash_params_match?(parameters, params.keys.map(&:to_sym))
195
+ when nil then nil_params_match?(parameters)
196
+ else true
199
197
  end
200
198
  end
199
+
200
+ def resolve_method(object, method_name)
201
+ object.method(method_name.to_sym)
202
+ rescue NameError
203
+ nil
204
+ end
205
+
206
+ def array_params_match?(parameters, count)
207
+ by_type = parameters.group_by(&:first)
208
+
209
+ return false if by_type.key?(:keyreq)
210
+ return true if by_type.key?(:rest)
211
+
212
+ required = by_type.fetch(:req, []).size
213
+ count.between?(required, required + by_type.fetch(:opt, []).size)
214
+ end
215
+
216
+ def hash_params_match?(parameters, keys)
217
+ by_type = parameters.group_by(&:first)
218
+ required = by_type.fetch(:keyreq, []).map(&:last)
219
+ allowed = required + by_type.fetch(:key, []).map(&:last)
220
+
221
+ return false if by_type.key?(:req)
222
+ return false unless (required - keys).empty?
223
+
224
+ by_type.key?(:keyrest) || (keys - allowed).empty?
225
+ end
226
+
227
+ def nil_params_match?(parameters)
228
+ by_type = parameters.group_by(&:first)
229
+
230
+ by_type.fetch(:req, []).empty? && !by_type.key?(:keyreq)
231
+ end
201
232
  end
202
233
  end
203
234
  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.9.0"
4
+ VERSION = "0.12.0"
5
5
  end
metadata CHANGED
@@ -1,28 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clamo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andriy Tyurnikov
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: parallel
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '1.27'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - "~>"
24
- - !ruby/object:Gem::Version
25
- version: '1.27'
11
+ dependencies: []
26
12
  description: Minimal, spec-compliant JSON-RPC 2.0 server for Ruby with request validation,
27
13
  method dispatch, batch processing, and notification support.
28
14
  email: