clamo 0.5.0 → 0.6.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: 1c91f68aa4a88440e10a75a4ae3f138df400c10fd698b2d2b8c72990dc7e25f1
4
+ data.tar.gz: bef5052025e0714e0297b20d95d1b90dfed7fce4e30a2fecd24cff9e8a84913c
5
5
  SHA512:
6
- metadata.gz: 1c83b16bd8e702714fca22d0277206538f22c560f795ed01f8ae76268304209b2ae0669e988e3897725a62856fe286832ffe3f3993cdd99040659a4178373efb
7
- data.tar.gz: bed60d4e01db858ea8875c420258c5754efdaa21a34cdb35b337c13168c811f6c2f7945f0510680fb872644ddf9d872a9a30f7ecbd164010d4eddf3d8dac7edc
6
+ metadata.gz: 96fa52c9f35c408c60a6827fa7886ee35fcea05af69fc6dde54aca497f0347c8de76bfd163fdb6bb4595f498fe9b55e9059e32d7212f997ec43cf5e77fafafef
7
+ data.tar.gz: a7519eeba5bfd4074ada39cea72a03afa03cfc22809b53034eaf06c48d8a94a1122e136a6cf7b9452a27fb2f0ca54d9507db91a7bf498073b5f25e76cee74f45
data/.rubocop.yml CHANGED
@@ -1,6 +1,6 @@
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
data/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
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.6.0] - 2026-03-14
8
+
9
+ ### Added
10
+
11
+ - `Clamo::Server.handle` — JSON string in, JSON string out entry point for HTTP/socket integrations
12
+ - `Clamo::Server.on_error` callback for notification failure reporting
13
+ - `Clamo::Error` base exception class
14
+
15
+ ### Changed
16
+
17
+ - Notifications now dispatch synchronously instead of spawning a background thread per call; callers control their own concurrency
18
+ - `build_error_response_from` accepts explicit `descriptor:` and `id:` keyword arguments instead of `**opts`
19
+ - `build_error_response_parse_error` takes no arguments (always returns `id: nil`)
20
+ - `parallel` dependency relaxed from `~> 1.27.0` to `~> 1.27`
21
+ - Minimum Ruby version raised from 3.0 to 3.3
22
+
23
+ ### Removed
24
+
25
+ - `JSONRPC.valid_params?` (use `JSONRPC.proper_params_if_any?` directly)
26
+ - `JSONRPC::PROTOCOL_VERSION_PRAGMA` constant (unused)
27
+ - `JSONRPC::ProtocolErrors::SERVER_ERROR_CODE_RANGE` constant (unused)
28
+
29
+ ### Fixed
30
+
31
+ - **Security:** replaced `send` with `public_send` to prevent remote invocation of private methods
32
+ - **Security:** `method_known?` now uses `public_methods(false)` to expose only explicitly defined methods
33
+ - Notifications now validate method existence before dispatch (previously skipped validation)
34
+ - Empty batch requests correctly return Invalid Request error per spec
35
+ - All-notification batches return `nil` instead of empty array per spec
36
+ - `build_error_response_from` no longer leaks the `:descriptor` key into the error response builder
37
+
38
+ ### Internal
39
+
40
+ - `method_known?`, `dispatch_to_ruby`, `response_for` moved from public to private API
41
+ - `response_for_single_request` extracted into focused private helpers
42
+ - Test suite expanded from scaffold to 62 tests / 84 assertions covering validation, dispatch, security, error handling, batching, notifications, and argument edge cases
43
+ - CI matrix set to Ruby 3.3, 3.4, 4.0
44
+
45
+ ## [0.5.0] - 2025-02-07
46
+
47
+ ### Changed
48
+
49
+ - Updated README with detailed usage examples, error table, and batch/notification documentation
50
+
51
+ ## [0.4.0] - 2025-02-07
52
+
53
+ ### Fixed
54
+
55
+ - Corrected gem metadata URLs
56
+
57
+ ## [0.3.0] - 2025-02-06
58
+
59
+ ### Added
60
+
61
+ - Batch request support via `parallel` gem
62
+ - Named parameter (Hash) dispatch
63
+ - JSON-RPC 2.0 validation (pragma, method, id, params)
64
+ - Protocol error constants (`PARSE_ERROR`, `INVALID_REQUEST`, etc.)
65
+ - RuboCop configuration
66
+
67
+ ### Changed
68
+
69
+ - Multiple version bumps during initial development
70
+
71
+ ## [0.1.0] - 2025-02-06
72
+
73
+ ### Added
74
+
75
+ - Initial release
76
+ - Basic JSON-RPC 2.0 server with positional parameter dispatch
77
+ - `Clamo::JSONRPC` request/response builders
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,11 +15,11 @@ 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
@@ -55,7 +34,7 @@ response = Clamo::Server.unparsed_dispatch_to_object(
55
34
  )
56
35
 
57
36
  puts response
58
- # => {"jsonrpc":"2.0","result":3,"id":1}
37
+ # => {jsonrpc: "2.0", result: 3, id: 1}
59
38
  ```
60
39
 
61
40
  ### Handling Different Parameter Types
@@ -88,7 +67,7 @@ batch_response = Clamo::Server.unparsed_dispatch_to_object(
88
67
  )
89
68
 
90
69
  puts batch_response
91
- # => [{"jsonrpc":"2.0","result":3,"id":1},{"jsonrpc":"2.0","result":2,"id":2}]
70
+ # => [{jsonrpc: "2.0", result: 3, id: 1}, {jsonrpc: "2.0", result: 2, id: 2}]
92
71
  ```
93
72
 
94
73
  ### Notifications
@@ -118,7 +97,7 @@ request = Clamo::JSONRPC.build_request(
118
97
  )
119
98
 
120
99
  puts request
121
- # => {:jsonrpc=>"2.0", :method=>"add", :params=>[1, 2], :id=>1}
100
+ # => {jsonrpc: "2.0", method: "add", params: [1, 2], id: 1}
122
101
  ```
123
102
 
124
103
  ## Error Handling
@@ -156,12 +135,8 @@ To install this gem onto your local machine, run `bundle exec rake install`.
156
135
 
157
136
  ## Contributing
158
137
 
159
- Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/clamo.
138
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubakas/clamo.
160
139
 
161
140
  ## License
162
141
 
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
142
+ 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")
@@ -50,28 +47,24 @@ module Clamo
50
47
  proper_id_if_any?(request)
51
48
  end
52
49
 
53
- def valid_params?(request)
54
- proper_params_if_any?(request)
55
- end
50
+ def build_request(**opts)
51
+ raise ArgumentError, "method is required" unless opts.key?(:method)
56
52
 
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] } : {})
53
+ validate_params_type!(opts[:params]) if opts.key?(:params)
54
+
55
+ { jsonrpc: "2.0", method: opts[:method] }
56
+ .then { |r| opts.key?(:params) ? r.merge(params: opts[:params]) : r }
57
+ .then { |r| opts.key?(:id) ? r.merge(id: opts[:id]) : r }
64
58
  end
65
59
 
66
60
  def build_result_response(id:, result:)
67
- {}.merge(PROTOCOL_VERSION_PRAGMA)
68
- .merge({ result: result })
69
- .merge({ id: id })
61
+ { jsonrpc: "2.0", result: result, id: id }
70
62
  end
71
63
 
72
- def build_error_response **opts
73
- # raise if no error code present
74
- # raise if no error message present
64
+ def build_error_response(**opts)
65
+ raise ArgumentError, "error code is required" unless opts.dig(:error, :code)
66
+ raise ArgumentError, "error message is required" unless opts.dig(:error, :message)
67
+
75
68
  { jsonrpc: "2.0",
76
69
  id: opts[:id],
77
70
  error: {
@@ -81,23 +74,32 @@ module Clamo
81
74
  }.reject { |k, _| k == :data && !opts[:error].key?(:data) } }
82
75
  end
83
76
 
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 } }
77
+ def build_error_response_from(descriptor:, id: nil)
78
+ build_error_response(
79
+ id: id,
80
+ error: {
81
+ code: descriptor.code,
82
+ message: descriptor.message
83
+ }
90
84
  )
91
- .then { |hash| build_error_response(**hash) }
92
85
  end
93
86
 
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 } }
87
+ def build_error_response_parse_error
88
+ build_error_response(
89
+ id: nil,
90
+ error: {
91
+ code: ProtocolErrors::PARSE_ERROR.code,
92
+ message: ProtocolErrors::PARSE_ERROR.message
93
+ }
99
94
  )
100
- .then { |hash| build_error_response(**hash) }
95
+ end
96
+
97
+ private
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"
101
103
  end
102
104
  end
103
105
  end
data/lib/clamo/server.rb CHANGED
@@ -1,128 +1,138 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "parallel"
4
5
 
5
6
  module Clamo
6
7
  module Server
7
8
  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
16
+
17
+ # JSON string in, JSON string out. Full round-trip for HTTP/socket integrations.
18
+ #
19
+ # Clamo::Server.handle(request: body, object: MyService)
20
+ #
21
+ def handle(request:, object:, **)
22
+ response = unparsed_dispatch_to_object(request: request, object: object, **)
23
+ response&.to_json
24
+ end
25
+
8
26
  # Clamo::Server.unparsed_dispatch_to_object(
9
27
  # request: request_body,
10
28
  # object: MyModule
11
29
  # )
12
- def unparsed_dispatch_to_object(request:, object:, **opts)
13
- # TODO: raise unless object is present?
30
+ def unparsed_dispatch_to_object(request:, object:, **)
31
+ raise ArgumentError, "object is required" unless object
32
+
14
33
  begin
15
34
  parsed = JSON.parse(request)
16
- rescue JSON::JSONError # TODO: any error
35
+ rescue JSON::JSONError
17
36
  return JSONRPC.build_error_response_parse_error
18
37
  end
19
38
 
20
- parsed_dispatch_to_object(request: parsed, object: object, **opts)
39
+ parsed_dispatch_to_object(request: parsed, object: object, **)
21
40
  end
22
41
 
23
42
  def parsed_dispatch_to_object(request:, object:, **opts)
24
43
  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
- )
44
+ dispatch_to_ruby(object: object, method: method, params: params)
30
45
  end
31
46
  end
32
47
 
48
+ private
49
+
33
50
  def method_known?(object:, method:)
34
- (object.public_methods - Object.methods)
35
- .map(&:to_sym)
36
- .include?(method.to_sym)
51
+ object.public_methods(false).map(&:to_sym).include?(method.to_sym)
37
52
  end
38
53
 
39
54
  def dispatch_to_ruby(object:, method:, params:)
40
55
  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}"
60
+ end
61
+ end
62
+
63
+ def response_for(request:, object:, **, &block)
64
+ case request
41
65
  when Array
42
- object.send method.to_sym, *params
66
+ response_for_batch(request: request, object: object, block: block, **)
43
67
  when Hash
44
- object.send method.to_sym, **params.transform_keys(&:to_sym)
45
- when NilClass
46
- object.send method.to_sym
68
+ response_for_single_request(request: request, object: object, block: block)
47
69
  else
48
- # TODO: raise
49
- raise "WTF"
70
+ JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
50
71
  end
51
72
  end
52
73
 
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
- )
74
+ def response_for_single_request(request:, object:, block:)
75
+ error = validate_request_structure(request)
76
+ return error if error
77
+
78
+ unless method_known?(object: object, method: request["method"])
79
+ return request.key?("id") ? method_not_found_error(request) : nil
74
80
  end
81
+
82
+ return dispatch_notification(request, block) unless request.key?("id")
83
+
84
+ dispatch_request(request, block)
75
85
  end
76
86
 
77
- def yield_to_execution(block:, method:, params:)
78
- block.yield method, params
87
+ def response_for_batch(request:, object:, block:, **opts)
88
+ if request.empty?
89
+ return JSONRPC.build_error_response_from(id: nil, descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST)
90
+ end
91
+
92
+ result = Parallel.map(request, **opts) do |item|
93
+ response_for_single_request(request: item, object: object, block: block)
94
+ end.compact
95
+ result.empty? ? nil : result
79
96
  end
80
97
 
81
- def response_for_single_request(request:, object:, block:)
98
+ def validate_request_structure(request)
82
99
  unless JSONRPC.valid_request?(request)
83
100
  return JSONRPC.build_error_response_from(
84
- id: request["id"],
101
+ id: request.is_a?(Hash) ? request["id"] : nil,
85
102
  descriptor: JSONRPC::ProtocolErrors::INVALID_REQUEST
86
103
  )
87
104
  end
88
105
 
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
106
+ return if JSONRPC.proper_params_if_any?(request)
95
107
 
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
108
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::INVALID_PARAMS)
109
+ end
111
110
 
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
111
+ def method_not_found_error(request)
112
+ JSONRPC.build_error_response_from(id: request["id"], descriptor: JSONRPC::ProtocolErrors::METHOD_NOT_FOUND)
113
+ end
118
114
 
115
+ def dispatch_notification(request, block)
116
+ block.yield request["method"], request["params"]
117
+ nil
118
+ rescue StandardError => e
119
+ on_error&.call(e, request["method"], request["params"])
120
+ nil
121
+ end
122
+
123
+ def dispatch_request(request, block)
119
124
  JSONRPC.build_result_response(
120
125
  id: request["id"],
121
- result: yield_to_execution(
122
- block: block,
123
- method: request["method"],
124
- params: request["params"]
125
- )
126
+ result: block.yield(request["method"], request["params"])
127
+ )
128
+ rescue StandardError => e
129
+ JSONRPC.build_error_response(
130
+ id: request["id"],
131
+ error: {
132
+ code: JSONRPC::ProtocolErrors::INTERNAL_ERROR.code,
133
+ message: JSONRPC::ProtocolErrors::INTERNAL_ERROR.message,
134
+ data: e.message
135
+ }
126
136
  )
127
137
  end
128
138
  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.6.0"
5
5
  end
data/lib/clamo.rb CHANGED
@@ -5,5 +5,5 @@ require_relative "clamo/jsonrpc"
5
5
  require_relative "clamo/server"
6
6
 
7
7
  module Clamo
8
- # class Error < StandardError; end
8
+ class Error < StandardError; end
9
9
  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.6.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,15 @@ 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: JSON-RPC 2.0 server toolkit for Ruby
27
27
  email:
28
28
  - Andriy.Tyurnikov@gmail.com
29
29
  executables: []
@@ -31,11 +31,10 @@ extensions: []
31
31
  extra_rdoc_files: []
32
32
  files:
33
33
  - ".rubocop.yml"
34
+ - CHANGELOG.md
34
35
  - README.md
35
36
  - Rakefile
36
37
  - lib/clamo.rb
37
- - lib/clamo/client.rb
38
- - lib/clamo/gpt_json_rpc_server.rb
39
38
  - lib/clamo/jsonrpc.rb
40
39
  - lib/clamo/server.rb
41
40
  - lib/clamo/version.rb
@@ -45,7 +44,7 @@ licenses: []
45
44
  metadata:
46
45
  homepage_uri: https://github.com/rubakas/clamo
47
46
  source_code_uri: https://github.com/rubakas/clamo
48
- changelog_uri: https://github.com/rubakas/clamo
47
+ changelog_uri: https://github.com/rubakas/clamo/blob/main/CHANGELOG.md
49
48
  rubygems_mfa_required: 'true'
50
49
  rdoc_options: []
51
50
  require_paths:
@@ -54,14 +53,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
54
53
  requirements:
55
54
  - - ">="
56
55
  - !ruby/object:Gem::Version
57
- version: 3.0.0
56
+ version: 3.3.0
58
57
  required_rubygems_version: !ruby/object:Gem::Requirement
59
58
  requirements:
60
59
  - - ">="
61
60
  - !ruby/object:Gem::Version
62
61
  version: '0'
63
62
  requirements: []
64
- rubygems_version: 3.6.5
63
+ rubygems_version: 4.0.3
65
64
  specification_version: 4
66
- summary: JSON-RPC Client/Server tooling for Ruby
65
+ summary: JSON-RPC 2.0 server toolkit for Ruby
67
66
  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}