clamo 0.5.0 → 0.7.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: 93acbb6067140d2268cec5379f519ab6e693949f36f76e3933f8a93c246dbd96
4
- data.tar.gz: 6811ac69e7b7b405991d2b1ce73e7138f4fb0709101e482da7f3744826dec592
3
+ metadata.gz: 410f469e07b16f8273149b1eded89c94692a91c8b41cd7c47642e897ca8d97fb
4
+ data.tar.gz: 2d6be4be393cfd70d5bf22109943b7735de5ca0abe93b65a888fe1a372640faa
5
5
  SHA512:
6
- metadata.gz: 1c83b16bd8e702714fca22d0277206538f22c560f795ed01f8ae76268304209b2ae0669e988e3897725a62856fe286832ffe3f3993cdd99040659a4178373efb
7
- data.tar.gz: bed60d4e01db858ea8875c420258c5754efdaa21a34cdb35b337c13168c811f6c2f7945f0510680fb872644ddf9d872a9a30f7ecbd164010d4eddf3d8dac7edc
6
+ metadata.gz: 385175b28fea11461b35a98860a6b8099bd9c88bc8dfef4f2603dc911a3d9e1709b51343d2b22ebb524ffc82aa03de7d8886a50b078c258d5fe28ec897a17296
7
+ data.tar.gz: 0c37f0971e24855610a91c8687d8e55ff32a03accf1effc73b6a1090118b46e3d78ffd9f0a7531418aa97a3154969bf7fb4d2dd0da9dc0cfe1b2b7aa94778cda
data/.rubocop.yml CHANGED
@@ -1,10 +1,16 @@
1
1
  AllCops:
2
2
  NewCops: enable
3
- TargetRubyVersion: 3.0
3
+ TargetRubyVersion: 3.3
4
4
 
5
5
  Metrics/MethodLength:
6
6
  Enabled: false
7
7
 
8
+ Metrics/ModuleLength:
9
+ Enabled: false
10
+
11
+ Metrics/ClassLength:
12
+ Enabled: false
13
+
8
14
  Style/Documentation:
9
15
  Enabled: false
10
16
 
data/CHANGELOG.md ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [0.7.0] - 2026-03-14
8
+
9
+ ### Added
10
+
11
+ - `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.
12
+ - MIT LICENSE file and `spec.license` in gemspec
13
+ - Indifferent key access in JSONRPC validators (symbol and string keys both accepted)
14
+ - Tests for string ids, arity mismatch, single-item batches, and handle+timeout (77 tests / 105 assertions)
15
+
16
+ ### Changed
17
+
18
+ - `proper_pragma?`, `proper_method?`, `proper_id_if_any?` moved from public to private API on `Clamo::JSONRPC`
19
+ - Notifications with invalid params type now return `nil` instead of an error response (spec compliance)
20
+ - `parsed_dispatch_to_object` now validates `object:` argument (raises `ArgumentError` if nil)
21
+ - Single-item batches skip `Parallel.map` overhead
22
+ - Gemspec description expanded (no longer identical to summary)
23
+ - README updated with `Server.handle`, `timeout`, and `on_error` documentation
24
+
25
+ ### Removed
26
+
27
+ - `Clamo::Error` base exception class (unused)
28
+ - `sig/clamo.rbs` type signatures (misleadingly incomplete)
29
+ - Dead `else` branch in `dispatch_to_ruby`
30
+
31
+ ## [0.6.0] - 2026-03-14
32
+
33
+ ### Added
34
+
35
+ - `Clamo::Server.handle` — JSON string in, JSON string out entry point for HTTP/socket integrations
36
+ - `Clamo::Server.on_error` callback for notification failure reporting
37
+ - `Clamo::Error` base exception class
38
+
39
+ ### Changed
40
+
41
+ - Notifications now dispatch synchronously instead of spawning a background thread per call; callers control their own concurrency
42
+ - `build_error_response_from` accepts explicit `descriptor:` and `id:` keyword arguments instead of `**opts`
43
+ - `build_error_response_parse_error` takes no arguments (always returns `id: nil`)
44
+ - `parallel` dependency relaxed from `~> 1.27.0` to `~> 1.27`
45
+ - Minimum Ruby version raised from 3.0 to 3.3
46
+
47
+ ### Removed
48
+
49
+ - `JSONRPC.valid_params?` (use `JSONRPC.proper_params_if_any?` directly)
50
+ - `JSONRPC::PROTOCOL_VERSION_PRAGMA` constant (unused)
51
+ - `JSONRPC::ProtocolErrors::SERVER_ERROR_CODE_RANGE` constant (unused)
52
+
53
+ ### Fixed
54
+
55
+ - **Security:** replaced `send` with `public_send` to prevent remote invocation of private methods
56
+ - **Security:** `method_known?` now uses `public_methods(false)` to expose only explicitly defined methods
57
+ - Notifications now validate method existence before dispatch (previously skipped validation)
58
+ - Empty batch requests correctly return Invalid Request error per spec
59
+ - All-notification batches return `nil` instead of empty array per spec
60
+ - `build_error_response_from` no longer leaks the `:descriptor` key into the error response builder
61
+
62
+ ### Internal
63
+
64
+ - `method_known?`, `dispatch_to_ruby`, `response_for` moved from public to private API
65
+ - `response_for_single_request` extracted into focused private helpers
66
+ - Test suite expanded from scaffold to 62 tests / 84 assertions covering validation, dispatch, security, error handling, batching, notifications, and argument edge cases
67
+ - CI matrix set to Ruby 3.3, 3.4, 4.0
68
+
69
+ ## [0.5.0] - 2025-02-07
70
+
71
+ ### Changed
72
+
73
+ - Updated README with detailed usage examples, error table, and batch/notification documentation
74
+
75
+ ## [0.4.0] - 2025-02-07
76
+
77
+ ### Fixed
78
+
79
+ - Corrected gem metadata URLs
80
+
81
+ ## [0.3.0] - 2025-02-06
82
+
83
+ ### Added
84
+
85
+ - Batch request support via `parallel` gem
86
+ - Named parameter (Hash) dispatch
87
+ - JSON-RPC 2.0 validation (pragma, method, id, params)
88
+ - Protocol error constants (`PARSE_ERROR`, `INVALID_REQUEST`, etc.)
89
+ - RuboCop configuration
90
+
91
+ ### Changed
92
+
93
+ - Multiple version bumps during initial development
94
+
95
+ ## [0.1.0] - 2025-02-06
96
+
97
+ ### Added
98
+
99
+ - Initial release
100
+ - Basic JSON-RPC 2.0 server with positional parameter dispatch
101
+ - `Clamo::JSONRPC` request/response builders
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
@@ -1,26 +1,5 @@
1
1
  # Clamo
2
2
 
3
- JSON-RPC protocol toolkit for Ruby.
4
-
5
- Consume, Serve or test JSON-RPC endpoints with Clamo.
6
-
7
- ## Installation
8
-
9
- Install the gem and add to the application's Gemfile by executing:
10
-
11
- $ bundle add clamo
12
-
13
- If bundler is not being used to manage dependencies, install the gem by executing:
14
-
15
- $ gem install clamo
16
-
17
-
18
- ## Development
19
-
20
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
21
-
22
- To install this gem onto your local machi# Clamo
23
-
24
3
  A Ruby implementation of [JSON-RPC 2.0](https://www.jsonrpc.org/specification) designed for simplicity and compliance with the specification.
25
4
 
26
5
 
@@ -36,26 +15,41 @@ module MyService
36
15
  def self.add(a, b)
37
16
  a + b
38
17
  end
39
-
18
+
40
19
  def self.subtract(a:, b:)
41
20
  a - b
42
21
  end
43
-
22
+
44
23
  # Private methods won't be accessible via JSON-RPC
45
24
  private_class_method def self.internal_method
46
25
  # This won't be exposed
47
26
  end
48
27
  end
49
28
 
50
- # Handle a JSON-RPC request
51
- request_body = '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}'
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
+ object: MyService
34
+ )
35
+ # => '{"jsonrpc":"2.0","result":3,"id":1}'
36
+ ```
37
+
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
52
42
  response = Clamo::Server.unparsed_dispatch_to_object(
53
- request: request_body,
43
+ request: '{"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}',
54
44
  object: MyService
55
45
  )
46
+ # => {jsonrpc: "2.0", result: 3, id: 1}
56
47
 
57
- puts response
58
- # => {"jsonrpc":"2.0","result":3,"id":1}
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
+ )
59
53
  ```
60
54
 
61
55
  ### Handling Different Parameter Types
@@ -88,7 +82,7 @@ batch_response = Clamo::Server.unparsed_dispatch_to_object(
88
82
  )
89
83
 
90
84
  puts batch_response
91
- # => [{"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}]
92
86
  ```
93
87
 
94
88
  ### Notifications
@@ -118,7 +112,7 @@ request = Clamo::JSONRPC.build_request(
118
112
  )
119
113
 
120
114
  puts request
121
- # => {:jsonrpc=>"2.0", :method=>"add", :params=>[1, 2], :id=>1}
115
+ # => {jsonrpc: "2.0", method: "add", params: [1, 2], id: 1}
122
116
  ```
123
117
 
124
118
  ## Error Handling
@@ -134,11 +128,32 @@ Clamo follows the JSON-RPC 2.0 specification for error handling:
134
128
  | -32603 | Internal error | Internal JSON-RPC error |
135
129
  | -32000 | Server error | Reserved for implementation-defined server errors |
136
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
+ Notifications don't return responses, so errors during notification dispatch are silent by default. Use `on_error` to capture them:
145
+
146
+ ```ruby
147
+ Clamo::Server.on_error = ->(exception, method, params) {
148
+ Rails.logger.error("#{method} failed: #{exception.message}")
149
+ }
150
+ ```
151
+
137
152
  ## Advanced Features
138
153
 
139
154
  ### Parallel Processing
140
155
 
141
- 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:
156
+ Batch requests are processed in parallel using the [parallel](https://github.com/grosser/parallel) gem. You can pass options to `Parallel.map`:
142
157
 
143
158
  ```ruby
144
159
  Clamo::Server.parsed_dispatch_to_object(
@@ -156,12 +171,8 @@ To install this gem onto your local machine, run `bundle exec rake install`.
156
171
 
157
172
  ## Contributing
158
173
 
159
- Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/clamo.
174
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubakas/clamo.
160
175
 
161
176
  ## License
162
177
 
163
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).ne, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
164
-
165
- ## Contributing
166
-
167
- Bug reports and pull requests are welcome on GitHub at https://github.com/rubakas/clamo
178
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/lib/clamo/jsonrpc.rb CHANGED
@@ -2,11 +2,8 @@
2
2
 
3
3
  module Clamo
4
4
  module JSONRPC
5
- PROTOCOL_VERSION_PRAGMA = { jsonrpc: "2.0" }.freeze
6
-
7
5
  module ProtocolErrors
8
6
  ErrorDescriptor = Data.define(:code, :message)
9
- SERVER_ERROR_CODE_RANGE = ((-32_099)..(-32_000))
10
7
 
11
8
  PARSE_ERROR = ErrorDescriptor.new(code: -32_700, message: "Parse error")
12
9
  INVALID_REQUEST = ErrorDescriptor.new(code: -32_600, message: "Invalid request")
@@ -17,27 +14,10 @@ module Clamo
17
14
  end
18
15
 
19
16
  class << self
20
- def proper_pragma?(request)
21
- request["jsonrpc"] == "2.0"
22
- end
23
-
24
- def proper_method?(request)
25
- request["method"].is_a?(String)
26
- end
27
-
28
- def proper_id_if_any?(request)
29
- if request.key?("id")
30
- request["id"].is_a?(String) ||
31
- request["id"].is_a?(Integer) ||
32
- request["id"].is_a?(NilClass)
33
- else
34
- true
35
- end
36
- end
37
-
38
17
  def proper_params_if_any?(request)
39
- if request.key?("params")
40
- 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)
41
21
  else
42
22
  true
43
23
  end
@@ -50,28 +30,24 @@ module Clamo
50
30
  proper_id_if_any?(request)
51
31
  end
52
32
 
53
- def valid_params?(request)
54
- proper_params_if_any?(request)
55
- end
33
+ def build_request(**opts)
34
+ raise ArgumentError, "method is required" unless opts.key?(:method)
56
35
 
57
- def build_request **opts
58
- # raise if no method present
59
- # raise if params present, but not an array
60
- { jsonrpc: "2.0",
61
- method: opts[:method] }
62
- .merge({ params: opts[:params] })
63
- .merge(opts.key?(:id) ? { id: opts[:id] } : {})
36
+ validate_params_type!(opts[:params]) if opts.key?(:params)
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 }
64
41
  end
65
42
 
66
43
  def build_result_response(id:, result:)
67
- {}.merge(PROTOCOL_VERSION_PRAGMA)
68
- .merge({ result: result })
69
- .merge({ id: id })
44
+ { jsonrpc: "2.0", result: result, id: id }
70
45
  end
71
46
 
72
- def build_error_response **opts
73
- # raise if no error code present
74
- # raise if no error message present
47
+ def build_error_response(**opts)
48
+ raise ArgumentError, "error code is required" unless opts.dig(:error, :code)
49
+ raise ArgumentError, "error message is required" unless opts.dig(:error, :message)
50
+
75
51
  { jsonrpc: "2.0",
76
52
  id: opts[:id],
77
53
  error: {
@@ -81,23 +57,57 @@ module Clamo
81
57
  }.reject { |k, _| k == :data && !opts[:error].key?(:data) } }
82
58
  end
83
59
 
84
- def build_error_response_from **opts
85
- # raise unless opts[:descriptor]
86
- opts.merge(
87
- { error:
88
- { code: opts[:descriptor].code,
89
- message: opts[:descriptor].message } }
60
+ def build_error_response_from(descriptor:, id: nil)
61
+ build_error_response(
62
+ id: id,
63
+ error: {
64
+ code: descriptor.code,
65
+ message: descriptor.message
66
+ }
90
67
  )
91
- .then { |hash| build_error_response(**hash) }
92
68
  end
93
69
 
94
- def build_error_response_parse_error **opts
95
- opts.merge(
96
- { error:
97
- { code: ProtocolErrors::PARSE_ERROR.code,
98
- message: ProtocolErrors::PARSE_ERROR.message } }
70
+ def build_error_response_parse_error
71
+ build_error_response(
72
+ id: nil,
73
+ error: {
74
+ code: ProtocolErrors::PARSE_ERROR.code,
75
+ message: ProtocolErrors::PARSE_ERROR.message
76
+ }
99
77
  )
100
- .then { |hash| build_error_response(**hash) }
78
+ end
79
+
80
+ private
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
+ def validate_params_type!(params)
100
+ return if params.is_a?(Array) || params.is_a?(Hash)
101
+
102
+ raise ArgumentError, "params must be an Array or Hash"
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)
101
111
  end
102
112
  end
103
113
  end
data/lib/clamo/server.rb CHANGED
@@ -1,129 +1,182 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "parallel"
5
+ require "timeout"
4
6
 
5
7
  module Clamo
6
8
  module Server
7
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
17
+
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
+ #
23
+ attr_writer :timeout
24
+
25
+ def timeout
26
+ return @timeout if defined?(@timeout)
27
+
28
+ 30
29
+ end
30
+
31
+ # JSON string in, JSON string out. Full round-trip for HTTP/socket integrations.
32
+ #
33
+ # Clamo::Server.handle(request: body, object: MyService)
34
+ #
35
+ def handle(request:, object:, **)
36
+ response = unparsed_dispatch_to_object(request: request, object: object, **)
37
+ response&.to_json
38
+ end
39
+
8
40
  # Clamo::Server.unparsed_dispatch_to_object(
9
41
  # request: request_body,
10
42
  # object: MyModule
11
43
  # )
12
- def unparsed_dispatch_to_object(request:, object:, **opts)
13
- # TODO: raise unless object is present?
44
+ def unparsed_dispatch_to_object(request:, object:, **)
45
+ raise ArgumentError, "object is required" unless object
46
+
14
47
  begin
15
48
  parsed = JSON.parse(request)
16
- rescue JSON::JSONError # TODO: any error
49
+ rescue JSON::JSONError
17
50
  return JSONRPC.build_error_response_parse_error
18
51
  end
19
52
 
20
- parsed_dispatch_to_object(request: parsed, object: object, **opts)
53
+ parsed_dispatch_to_object(request: parsed, object: object, **)
21
54
  end
22
55
 
23
56
  def parsed_dispatch_to_object(request:, object:, **opts)
57
+ raise ArgumentError, "object is required" unless object
58
+
24
59
  response_for(request: request, object: object, **opts) do |method, params|
25
- dispatch_to_ruby(
26
- object: object,
27
- method: method,
28
- params: params # consider splating
29
- )
60
+ dispatch_to_ruby(object: object, method: method, params: params)
30
61
  end
31
62
  end
32
63
 
64
+ private
65
+
33
66
  def method_known?(object:, method:)
34
- (object.public_methods - Object.methods)
35
- .map(&:to_sym)
36
- .include?(method.to_sym)
67
+ object.public_methods(false).map(&:to_sym).include?(method.to_sym)
37
68
  end
38
69
 
39
70
  def dispatch_to_ruby(object:, method:, params:)
40
71
  case params
72
+ when Array then object.public_send(method.to_sym, *params)
73
+ when Hash then object.public_send(method.to_sym, **params.transform_keys(&:to_sym))
74
+ when NilClass then object.public_send(method.to_sym)
75
+ end
76
+ end
77
+
78
+ # Extra keyword arguments (**) are forwarded to response_for_batch only,
79
+ # where they become options for Parallel.map (e.g., in_processes: 4).
80
+ # For single requests they are silently ignored.
81
+ def response_for(request:, object:, **, &block)
82
+ case request
41
83
  when Array
42
- object.send method.to_sym, *params
84
+ response_for_batch(request: request, object: object, block: block, **)
43
85
  when Hash
44
- object.send method.to_sym, **params.transform_keys(&:to_sym)
45
- when NilClass
46
- object.send method.to_sym
86
+ response_for_single_request(request: request, object: object, block: block)
47
87
  else
48
- # TODO: raise
49
- raise "WTF"
88
+ JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
50
89
  end
51
90
  end
52
91
 
53
- def response_for(request:, object:, **opts, &block)
54
- case request
55
- when Array # batch request
56
- Parallel.map(request, **opts) do |item|
57
- response_for_single_request(
58
- request: item,
59
- object: object,
60
- block: block
61
- )
62
- end.compact
63
- when Hash # single request
64
- response_for_single_request(
65
- request: request,
66
- object: object,
67
- block: block
68
- )
69
- else
70
- JSONRPC.build_error_response_from(
71
- id: nil,
72
- descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST
73
- )
92
+ def response_for_single_request(request:, object:, block:)
93
+ error = validate_request_structure(request)
94
+ return error if error
95
+
96
+ unless method_known?(object: object, method: request["method"])
97
+ return request.key?("id") ? method_not_found_error(request) : nil
74
98
  end
99
+
100
+ return dispatch_notification(request, block) unless request.key?("id")
101
+
102
+ dispatch_request(request, block)
75
103
  end
76
104
 
77
- def yield_to_execution(block:, method:, params:)
78
- block.yield method, params
105
+ def response_for_batch(request:, object:, block:, **opts)
106
+ if request.empty?
107
+ return JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
108
+ end
109
+
110
+ if request.size == 1
111
+ result = response_for_single_request(request: request.first, object: object, block: block)
112
+ return result ? [result] : nil
113
+ end
114
+
115
+ result = Parallel.map(request, **opts) do |item|
116
+ response_for_single_request(request: item, object: object, block: block)
117
+ end.compact
118
+ result.empty? ? nil : result
79
119
  end
80
120
 
81
- def response_for_single_request(request:, object:, block:)
121
+ def validate_request_structure(request)
82
122
  unless JSONRPC.valid_request?(request)
83
123
  return JSONRPC.build_error_response_from(
84
- id: request["id"],
124
+ id: request.is_a?(Hash) ? request["id"] : nil,
85
125
  descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST
86
126
  )
87
127
  end
88
128
 
89
- unless JSONRPC.valid_params?(request)
90
- return JSONRPC.build_error_response_from(
91
- id: request["id"],
92
- descriptor: JSONRPC::ProtocolErrors::INVALID_PARAMS
93
- )
94
- end
129
+ return if JSONRPC.proper_params_if_any?(request)
95
130
 
96
- unless request.key?("id") # notification - no result needed
97
- # TODO: block.call off the current thread
98
- Thread.new do
99
- yield_to_execution(
100
- block: block,
101
- method: request["method"],
102
- params: request["params"]
103
- )
104
- rescue StandardError
105
- # TODO: add exception handler
106
- nil
107
- end
108
-
109
- return nil
110
- end
131
+ # Notifications must never produce a response, even for invalid params
132
+ return nil unless request.key?("id")
111
133
 
112
- unless method_known?(object: object, method: request["method"])
113
- return JSONRPC.build_error_response_from(
114
- id: request["id"],
115
- descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND
116
- )
117
- end
134
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INVALID_PARAMS)
135
+ end
118
136
 
137
+ def method_not_found_error(request)
138
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
139
+ end
140
+
141
+ def dispatch_notification(request, block)
142
+ with_timeout { block.yield request["method"], request["params"] }
143
+ nil
144
+ rescue StandardError => e
145
+ on_error&.call(e, request["method"], request["params"])
146
+ nil
147
+ end
148
+
149
+ def dispatch_request(request, block)
119
150
  JSONRPC.build_result_response(
120
151
  id: request["id"],
121
- result: yield_to_execution(
122
- block: block,
123
- method: request["method"],
124
- params: request["params"]
125
- )
152
+ result: with_timeout { block.yield(request["method"], request["params"]) }
153
+ )
154
+ rescue Timeout::Error
155
+ JSONRPC.build_error_response(
156
+ id: request["id"],
157
+ error: {
158
+ code: JSONRPC::ProtocolErrors::SERVER_ERROR.code,
159
+ message: JSONRPC::ProtocolErrors::SERVER_ERROR.message,
160
+ data: "Request timed out"
161
+ }
126
162
  )
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
+ end
173
+
174
+ def with_timeout(&block)
175
+ if timeout
176
+ Timeout.timeout(timeout, &block)
177
+ else
178
+ block.call
179
+ end
127
180
  end
128
181
  end
129
182
  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.5.0"
4
+ VERSION = "0.7.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,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clamo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andriy Tyurnikov
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-04-23 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: parallel
@@ -15,15 +15,16 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 1.27.0
18
+ version: '1.27'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 1.27.0
26
- description: JSON-RPC Client/Server tooling for Ruby
25
+ version: '1.27'
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: []
@@ -31,21 +32,21 @@ extensions: []
31
32
  extra_rdoc_files: []
32
33
  files:
33
34
  - ".rubocop.yml"
35
+ - CHANGELOG.md
36
+ - LICENSE
34
37
  - README.md
35
38
  - Rakefile
36
39
  - lib/clamo.rb
37
- - lib/clamo/client.rb
38
- - lib/clamo/gpt_json_rpc_server.rb
39
40
  - lib/clamo/jsonrpc.rb
40
41
  - lib/clamo/server.rb
41
42
  - lib/clamo/version.rb
42
- - sig/clamo.rbs
43
43
  homepage: https://github.com/rubakas/clamo
44
- licenses: []
44
+ licenses:
45
+ - MIT
45
46
  metadata:
46
47
  homepage_uri: https://github.com/rubakas/clamo
47
48
  source_code_uri: https://github.com/rubakas/clamo
48
- changelog_uri: https://github.com/rubakas/clamo
49
+ changelog_uri: https://github.com/rubakas/clamo/blob/main/CHANGELOG.md
49
50
  rubygems_mfa_required: 'true'
50
51
  rdoc_options: []
51
52
  require_paths:
@@ -54,14 +55,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
54
55
  requirements:
55
56
  - - ">="
56
57
  - !ruby/object:Gem::Version
57
- version: 3.0.0
58
+ version: 3.3.0
58
59
  required_rubygems_version: !ruby/object:Gem::Requirement
59
60
  requirements:
60
61
  - - ">="
61
62
  - !ruby/object:Gem::Version
62
63
  version: '0'
63
64
  requirements: []
64
- rubygems_version: 3.6.5
65
+ rubygems_version: 4.0.3
65
66
  specification_version: 4
66
- summary: JSON-RPC Client/Server tooling for Ruby
67
+ summary: JSON-RPC 2.0 server toolkit for Ruby
67
68
  test_files: []
data/lib/clamo/client.rb DELETED
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Client
4
- ANSWER = 42
5
- end
@@ -1,186 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module GPTJsonRpcServer
6
- PROTOCOL_VERSION = "2.0"
7
-
8
- def self.handle_request(object:, request_json:)
9
- requests = JSON.parse(request_json)
10
- if requests.is_a?(Array)
11
- responses = requests.map do |request|
12
- Thread.new { process_request(object, request) }
13
- end.map(&:value).compact
14
- responses.empty? ? nil : responses.to_json
15
- else
16
- response = process_request(object, requests)
17
- response&.to_json
18
- end
19
- rescue JSON::ParserError
20
- { jsonrpc: PROTOCOL_VERSION, error: { code: -32_700, message: "Parse error" }, id: nil }.to_json
21
- end
22
-
23
- def self.process_request(object, request)
24
- method = request["method"]
25
- params = request["params"]
26
- id = request["id"]
27
-
28
- unless valid_id?(id)
29
- return { jsonrpc: PROTOCOL_VERSION, error: { code: -32_600, message: "Invalid Request" }, id: nil }
30
- end
31
-
32
- unless method.is_a?(String) && object.public_methods(false).include?(method.to_sym)
33
- return { jsonrpc: PROTOCOL_VERSION, error: { code: -32_601, message: "Method not found" }, id: id }
34
- end
35
-
36
- method_object = object.method(method)
37
- param_list = method_object.parameters
38
- valid_params = case params
39
- when Array
40
- param_list.none? { |type, _| type == :rest } && params.size == param_list.count do |type, _|
41
- %i[req opt rest].include?(type)
42
- end
43
- when Hash
44
- required_params = param_list.slice(:keyreq).map(&:last)
45
- required_params.all? { |key| params.key?(key) } && param_list.all? do |type, name|
46
- type != :keyreq || params.key?(name)
47
- end
48
- when NilClass
49
- param_list.empty?
50
- else
51
- false
52
- end
53
-
54
- unless valid_params
55
- return { jsonrpc: PROTOCOL_VERSION, error: { code: -32_602, message: "Invalid params" },
56
- id: id }
57
- end
58
-
59
- if id.nil?
60
- # Treat id: null as a notification
61
- Thread.new { safe_call_method(method_object, params) }
62
- return nil
63
- end
64
-
65
- begin
66
- result = safe_call_method(method_object, params)
67
- { jsonrpc: PROTOCOL_VERSION, result: result, id: id }
68
- rescue StandardError => e
69
- { jsonrpc: PROTOCOL_VERSION, error: { code: -32_603, message: "Internal error", data: e.message }, id: id }
70
- end
71
- end
72
-
73
- def self.safe_call_method(method_object, params)
74
- if params.is_a?(Array)
75
- method_object.call(*params)
76
- elsif params.is_a?(Hash)
77
- method_object.call(**params)
78
- else
79
- method_object.call
80
- end
81
- end
82
-
83
- def self.valid_id?(id)
84
- return false unless id.is_a?(String) || id.is_a?(Numeric) || id.nil?
85
- return false if id.is_a?(Numeric) && id != id.to_i
86
-
87
- true
88
- end
89
- end
90
-
91
- # Example usage
92
-
93
- class MyService
94
- def add(a, b)
95
- a + b
96
- end
97
-
98
- def subtract(a:, b:)
99
- a - b
100
- end
101
-
102
- private
103
-
104
- def private_method
105
- "This should not be exposed"
106
- end
107
- end
108
-
109
- service = MyService.new
110
-
111
- # Example JSON-RPC requests
112
- single_request_positional = {
113
- jsonrpc: "2.0",
114
- method: "add",
115
- params: [1, 2],
116
- id: 1
117
- }.to_json
118
-
119
- single_request_keyword = {
120
- jsonrpc: "2.0",
121
- method: "subtract",
122
- params: { a: 5, b: 3 },
123
- id: 2
124
- }.to_json
125
-
126
- batch_request = [
127
- { jsonrpc: "2.0", method: "add", params: [1, 2], id: 1 },
128
- { jsonrpc: "2.0", method: "subtract", params: { a: 5, b: 3 }, id: 2 },
129
- { jsonrpc: "2.0", method: "add", params: [7, 3], id: 3 }
130
- ].to_json
131
-
132
- notification_request = {
133
- jsonrpc: "2.0",
134
- method: "add",
135
- params: [1, 2]
136
- }.to_json
137
-
138
- invalid_id_null_request = {
139
- jsonrpc: "2.0",
140
- method: "add",
141
- params: [1, 2],
142
- id: nil
143
- }.to_json
144
-
145
- invalid_id_object_request = {
146
- jsonrpc: "2.0",
147
- method: "add",
148
- params: [1, 2],
149
- id: {}
150
- }.to_json
151
-
152
- invalid_method_request = {
153
- jsonrpc: "2.0",
154
- method: {},
155
- params: [1, 2],
156
- id: 1
157
- }.to_json
158
-
159
- # Handling single request with positional parameters
160
- response_json_positional = GPTJsonRpcServer.handle_request(object: service, request_json: single_request_positional)
161
- puts response_json_positional # Output: {"jsonrpc":"2.0","result":3,"id":1}
162
-
163
- # Handling single request with keyword parameters
164
- response_json_keyword = GPTJsonRpcServer.handle_request(object: service, request_json: single_request_keyword)
165
- puts response_json_keyword # Output: {"jsonrpc":"2.0","result":2,"id":2}
166
-
167
- # Handling batch request
168
- batch_response_json = GPTJsonRpcServer.handle_request(object: service, request_json: batch_request)
169
- puts batch_response_json # Output: [{"jsonrpc":"2.0","result":3,"id":1},{"jsonrpc":"2.0","result":2,"id":2},{"jsonrpc":"2.0","result":10,"id":3}]
170
-
171
- # Handling notification request (no response expected)
172
- notification_response_json = GPTJsonRpcServer.handle_request(object: service, request_json: notification_request)
173
- puts notification_response_json.nil? # Output: true
174
-
175
- # Handling request with id = null (treated as a notification)
176
- invalid_id_null_response_json = GPTJsonRpcServer.handle_request(object: service, request_json: invalid_id_null_request)
177
- puts invalid_id_null_response_json.nil? # Output: true
178
-
179
- # Handling request with id as an object (should return an error)
180
- invalid_id_object_response_json = GPTJsonRpcServer.handle_request(object: service,
181
- request_json: invalid_id_object_request)
182
- puts invalid_id_object_response_json # Output: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":null}
183
-
184
- # Handling request with method as an object (should return an error)
185
- invalid_method_response_json = GPTJsonRpcServer.handle_request(object: service, request_json: invalid_method_request)
186
- puts invalid_method_response_json # Output: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":1}
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