jsonrpc-middleware 0.4.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.
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ gemfile(true) do
6
+ source 'https://rubygems.org'
7
+
8
+ gem 'rails', '~> 8.0.2'
9
+ gem 'puma', '~> 6.6.0'
10
+ gem 'jsonrpc-middleware', path: '../../', require: 'jsonrpc'
11
+ end
12
+
13
+ require 'rails'
14
+ require 'action_controller/railtie'
15
+
16
+ JSONRPC.configure do |config|
17
+ config.rescue_internal_errors = true # set to +false+ if you want to raise JSONRPC::InternalError manually
18
+
19
+ # Define the allowed JSON-RPC methods. Calls to methods absent from this list will return a method not found error.
20
+ procedure 'on'
21
+ procedure 'off'
22
+ procedure 'lights.on'
23
+ procedure 'lights.off'
24
+ procedure 'climate.on'
25
+ procedure 'climate.off'
26
+ procedure 'climate.fan.on'
27
+ procedure 'climate.fan.off'
28
+ end
29
+
30
+ # Define the application
31
+ class App < Rails::Application
32
+ config.root = __dir__
33
+ config.cache_classes = true
34
+ config.eager_load = true
35
+ config.active_support.deprecation = :stderr
36
+ config.consider_all_requests_local = true
37
+ config.active_support.to_time_preserves_timezone = :zone
38
+ config.logger = nil
39
+ config.hosts.clear
40
+
41
+ routes.append do
42
+ jsonrpc '/' do
43
+ # Handle batch requests with a dedicated controller
44
+ batch to: 'batch#handle'
45
+
46
+ method :on, to: 'main#on'
47
+ method :off, to: 'main#off'
48
+
49
+ namespace 'lights' do
50
+ method :on, to: 'lights#on' # becomes lights.on
51
+ method :off, to: 'lights#off' # becomes lights.off
52
+ end
53
+
54
+ namespace 'climate' do
55
+ method :on, to: 'climate#on' # becomes climate.on
56
+ method :off, to: 'climate#off' # becomes climate.off
57
+
58
+ namespace 'fan' do
59
+ method :on, to: 'fan#on' # becomes climate.fan.on
60
+ method :off, to: 'fan#off' # becomes climate.fan.off
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ # Controller for main system operations
68
+ class MainController < ActionController::Base
69
+ def on
70
+ render jsonrpc: { device: 'main_system', status: 'on' }
71
+ end
72
+
73
+ def off
74
+ render jsonrpc: { device: 'main_system', status: 'off' }
75
+ end
76
+ end
77
+
78
+ # Controller for lights operations
79
+ class LightsController < ActionController::Base
80
+ def on
81
+ render jsonrpc: { device: 'lights', status: 'on' }
82
+ end
83
+
84
+ def off
85
+ render jsonrpc: { device: 'lights', status: 'off' }
86
+ end
87
+ end
88
+
89
+ # Controller for climate operations
90
+ class ClimateController < ActionController::Base
91
+ def on
92
+ render jsonrpc: { device: 'climate_system', status: 'on' }
93
+ end
94
+
95
+ def off
96
+ render jsonrpc: { device: 'climate_system', status: 'off' }
97
+ end
98
+ end
99
+
100
+ # Controller for climate fan operations
101
+ class FanController < ActionController::Base
102
+ def on
103
+ render jsonrpc: { device: 'fan', status: 'on' }
104
+ end
105
+
106
+ def off
107
+ render jsonrpc: { device: 'fan', status: 'off' }
108
+ end
109
+ end
110
+
111
+ # Controller for batch operations
112
+ class BatchController < ActionController::Base
113
+ def handle
114
+ # Process each request in the batch and collect results
115
+ results = jsonrpc_batch.process_each do |request_or_notification|
116
+ result = case request_or_notification.method
117
+ when 'on'
118
+ { device: 'main_system', status: 'on' }
119
+ when 'off'
120
+ { device: 'main_system', status: 'off' }
121
+ when 'lights.on'
122
+ { device: 'lights', status: 'on' }
123
+ when 'lights.off'
124
+ { device: 'lights', status: 'off' }
125
+ when 'climate.on'
126
+ { device: 'climate_system', status: 'on' }
127
+ when 'climate.off'
128
+ { device: 'climate_system', status: 'off' }
129
+ when 'climate.fan.on'
130
+ { device: 'fan', status: 'on' }
131
+ when 'climate.fan.off'
132
+ { device: 'fan', status: 'off' }
133
+ else
134
+ { error: 'Unknown method', method: request_or_notification.method }
135
+ end
136
+
137
+ result
138
+ end
139
+
140
+ render jsonrpc: results
141
+ end
142
+ end
143
+
144
+ App.initialize!
145
+
146
+ run App
@@ -10,6 +10,10 @@ Uses constraints to route JSON-RPC requests to different Rails controller action
10
10
  class App < Rails::Application
11
11
  # ...
12
12
  routes.append do
13
+ # Handle batch requests
14
+ post '/', to: 'jsonrpc#ping_or_echo', constraints: JSONRPC::BatchConstraint.new
15
+
16
+ # Handle individual method requests
13
17
  post '/', to: 'jsonrpc#echo', constraints: JSONRPC::MethodConstraint.new('echo')
14
18
  post '/', to: 'jsonrpc#ping', constraints: JSONRPC::MethodConstraint.new('ping')
15
19
  end
@@ -17,7 +21,7 @@ end
17
21
 
18
22
  class JsonrpcController < ActionController::Base
19
23
  # POST /
20
- def echoc
24
+ def echo
21
25
  render jsonrpc: jsonrpc_request.params
22
26
  end
23
27
 
@@ -25,6 +29,20 @@ class JsonrpcController < ActionController::Base
25
29
  def ping
26
30
  render jsonrpc: 'pong'
27
31
  end
32
+
33
+ # POST /
34
+ def ping_or_echo
35
+ results = jsonrpc_batch.process_each do |request_or_notification|
36
+ case request_or_notification.method
37
+ when 'echo'
38
+ request_or_notification.params
39
+ when 'ping'
40
+ 'pong'
41
+ end
42
+ end
43
+
44
+ render jsonrpc: results
45
+ end
28
46
  end
29
47
  ```
30
48
 
@@ -36,18 +54,35 @@ bundle exec rackup
36
54
 
37
55
  ## API
38
56
 
39
- The server implements an echo API with these procedures:
57
+ The server implements these procedures:
40
58
 
41
59
  - `echo` - Returns the input message
42
- - `ping` - Returns "pong"
60
+ - `ping` - Returns `'pong'`
43
61
 
44
62
  ## Example Requests
45
63
 
64
+ Echo request:
46
65
  ```sh
47
66
  curl -X POST http://localhost:9292 \
48
67
  -H "Content-Type: application/json" \
49
68
  -d '{"jsonrpc": "2.0", "method": "echo", "params": {"message": "Hello, World!"}, "id": 1}'
69
+ ```
70
+
71
+ Ping request:
72
+ ```sh
73
+ curl -X POST http://localhost:9292 \
74
+ -H "Content-Type: application/json" \
75
+ -d '{"jsonrpc": "2.0", "method": "ping", "params": {}, "id": 2}'
76
+ ```
77
+
78
+ Batch request with multiple methods:
79
+ ```sh
80
+ # Batch request with multiple methods
50
81
  curl -X POST http://localhost:9292 \
51
82
  -H "Content-Type: application/json" \
52
- -d '{"jsonrpc": "2.0""method": "ping", "params": {}, "id": 2}'
83
+ -d '[
84
+ {"jsonrpc": "2.0", "method": "echo", "params": {"message": "Hello from batch!"}, "id": 1},
85
+ {"jsonrpc": "2.0", "method": "ping", "params": {}, "id": 2},
86
+ {"jsonrpc": "2.0", "method": "echo", "params": {"message": "Another echo"}, "id": 3}
87
+ ]'
53
88
  ```
@@ -22,11 +22,7 @@ JSONRPC.configure do |config|
22
22
  end
23
23
  end
24
24
 
25
- procedure(:ping) do
26
- params do
27
- # no params
28
- end
29
- end
25
+ procedure(:ping)
30
26
  end
31
27
 
32
28
  # Define the application
@@ -41,6 +37,10 @@ class App < Rails::Application
41
37
  config.hosts.clear
42
38
 
43
39
  routes.append do
40
+ # Handle batch requests
41
+ post '/', to: 'jsonrpc#ping_or_echo', constraints: JSONRPC::BatchConstraint.new
42
+
43
+ # Handle individual method requests
44
44
  post '/', to: 'jsonrpc#echo', constraints: JSONRPC::MethodConstraint.new('echo')
45
45
  post '/', to: 'jsonrpc#ping', constraints: JSONRPC::MethodConstraint.new('ping')
46
46
  end
@@ -55,6 +55,19 @@ class JsonrpcController < ActionController::Base
55
55
  def ping
56
56
  render jsonrpc: 'pong'
57
57
  end
58
+
59
+ def ping_or_echo
60
+ results = jsonrpc_batch.process_each do |request_or_notification|
61
+ case request_or_notification.method
62
+ when 'echo'
63
+ request_or_notification.params
64
+ when 'ping'
65
+ 'pong'
66
+ end
67
+ end
68
+
69
+ render jsonrpc: results
70
+ end
58
71
  end
59
72
 
60
73
  App.initialize!
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: ../..
3
3
  specs:
4
- jsonrpc-middleware (0.2.0)
4
+ jsonrpc-middleware (0.5.0)
5
5
  dry-validation (~> 1.11)
6
+ multi_json (~> 1.17)
6
7
  zeitwerk (~> 2.7)
7
8
 
8
9
  GEM
@@ -47,7 +48,7 @@ GEM
47
48
  dry-schema (~> 1.14)
48
49
  zeitwerk (~> 2.6)
49
50
  logger (1.7.0)
50
- multi_json (1.16.0)
51
+ multi_json (1.17.0)
51
52
  mustermann (3.0.3)
52
53
  ruby2_keywords (~> 0.0.1)
53
54
  nio4r (2.7.4)
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: ../..
3
3
  specs:
4
- jsonrpc-middleware (0.2.0)
4
+ jsonrpc-middleware (0.5.0)
5
5
  dry-validation (~> 1.11)
6
+ multi_json (~> 1.17)
6
7
  zeitwerk (~> 2.7)
7
8
 
8
9
  GEM
@@ -47,7 +48,7 @@ GEM
47
48
  dry-schema (~> 1.14)
48
49
  zeitwerk (~> 2.6)
49
50
  logger (1.7.0)
50
- multi_json (1.16.0)
51
+ multi_json (1.17.0)
51
52
  mustermann (3.0.3)
52
53
  ruby2_keywords (~> 0.0.1)
53
54
  nio4r (2.7.4)
@@ -75,7 +75,7 @@ module JSONRPC
75
75
  # @return [String] the JSON-formatted batch
76
76
  #
77
77
  def to_json(*)
78
- to_h.to_json(*)
78
+ MultiJson.dump(to_h, *)
79
79
  end
80
80
 
81
81
  # Implements the Enumerable contract by yielding each request in the batch
@@ -124,19 +124,6 @@ module JSONRPC
124
124
  #
125
125
  alias length size
126
126
 
127
- # Returns true if the batch contains no requests
128
- #
129
- # @api public
130
- #
131
- # @example Check if batch is empty
132
- # batch.empty? # => false
133
- #
134
- # @return [Boolean] true if the batch is empty, false otherwise
135
- #
136
- def empty?
137
- requests.empty?
138
- end
139
-
140
127
  # Handles each request/notification in the batch and returns responses
141
128
  #
142
129
  # @api public
@@ -75,7 +75,7 @@ module JSONRPC
75
75
  # @return [String] the JSON-formatted batch response
76
76
  #
77
77
  def to_json(*)
78
- to_h.to_json(*)
78
+ MultiJson.dump(to_h, *)
79
79
  end
80
80
 
81
81
  # Implements the Enumerable contract by yielding each response in the batch
@@ -101,6 +101,19 @@ module JSONRPC
101
101
  #
102
102
  attr_reader :validate_procedure_signatures
103
103
 
104
+ # JSON adapter to use (optional)
105
+ #
106
+ # @api public
107
+ #
108
+ # @example
109
+ # config.json_adapter = :oj
110
+ #
111
+ # @return [Symbol, nil] the JSON adapter to use
112
+ #
113
+ def json_adapter=(adapter)
114
+ MultiJson.use(adapter)
115
+ end
116
+
104
117
  # Initializes a new Configuration instance
105
118
  #
106
119
  # @api public
@@ -162,15 +175,24 @@ module JSONRPC
162
175
  # end
163
176
  # end
164
177
  #
178
+ # @example Register a procedure without validation
179
+ # config.procedure('ping')
180
+ #
165
181
  # @param method_name [String, Symbol] the name of the procedure
166
182
  # @param allow_positional_arguments [Boolean] whether the procedure accepts positional arguments
167
183
  #
168
- # @yield A block that defines the validation contract using Dry::Validation DSL
184
+ # @yield [optional] A block that defines the validation contract using Dry::Validation DSL
169
185
  #
170
186
  # @return [Procedure] the registered procedure
171
187
  #
172
- def procedure(method_name, allow_positional_arguments: false, &)
173
- contract_class = Class.new(Dry::Validation::Contract, &)
188
+ def procedure(method_name, allow_positional_arguments: false, &block)
189
+ contract_class = if block
190
+ Class.new(Dry::Validation::Contract, &block)
191
+ else
192
+ Class.new(Dry::Validation::Contract) do
193
+ params {} # rubocop:disable Lint/EmptyBlock
194
+ end
195
+ end
174
196
  contract_class.class_eval { import_predicates_as_macros }
175
197
  contract = contract_class.new
176
198
 
data/lib/jsonrpc/error.rb CHANGED
@@ -128,7 +128,7 @@ module JSONRPC
128
128
  # @return [String] the error as a JSON string
129
129
  #
130
130
  def to_json(*)
131
- to_h.to_json(*)
131
+ MultiJson.dump(to_h, *)
132
132
  end
133
133
 
134
134
  # Converts the error to a complete JSON-RPC response
@@ -324,7 +324,7 @@ module JSONRPC
324
324
 
325
325
  return [] unless status == 200 && !body.empty?
326
326
 
327
- app_responses = JSON.parse(body.join)
327
+ app_responses = MultiJson.load(body.join)
328
328
  app_responses.map do |resp|
329
329
  Response.new(id: resp['id'], result: resp['result'], error: resp['error'])
330
330
  end
@@ -359,7 +359,8 @@ module JSONRPC
359
359
  # @return [Array] Rack response tuple [status, headers, body]
360
360
  #
361
361
  def json_response(status, body)
362
- [status, { 'content-type' => 'application/json' }, [body.is_a?(String) ? body : JSON.generate(body)]]
362
+ json_body = body.is_a?(String) ? body : MultiJson.dump(body)
363
+ [status, { 'content-type' => 'application/json' }, [json_body]]
363
364
  end
364
365
 
365
366
  # Reads and returns the request body from the Rack environment
@@ -108,7 +108,7 @@ module JSONRPC
108
108
  # @return [String] the notification as a JSON string
109
109
  #
110
110
  def to_json(*)
111
- to_h.to_json(*)
111
+ MultiJson.dump(to_h, *)
112
112
  end
113
113
 
114
114
  private
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
-
5
3
  module JSONRPC
6
4
  # JSON-RPC 2.0 Parser for converting raw JSON into JSONRPC objects
7
5
  #
@@ -37,17 +35,21 @@ module JSONRPC
37
35
  # @raise [InvalidRequestError] if the request structure is invalid
38
36
  #
39
37
  def parse(json)
40
- begin
41
- data = JSON.parse(json)
42
- rescue JSON::ParserError => e
43
- raise ParseError.new(data: { details: e.message })
44
- end
38
+ data = MultiJson.load(json)
45
39
 
46
40
  if data.is_a?(Array)
47
41
  parse_batch(data)
48
42
  else
49
43
  parse_single(data)
50
44
  end
45
+ rescue MultiJson::ParseError => e
46
+ raise ParseError.new(
47
+ data: {
48
+ details: e.message,
49
+ adapter: MultiJson.adapter.name,
50
+ input_preview: json[0..100]
51
+ }
52
+ )
51
53
  end
52
54
 
53
55
  private
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # This constraint allows Rails routes to be matched based on JSON-RPC
5
+ # batch requests, enabling batch-specific routing to dedicated controllers.
6
+ #
7
+ # @example Using in Rails routes
8
+ # post '/', to: 'jsonrpc#handle_batch', constraints: JSONRPC::BatchConstraint.new
9
+ #
10
+ # @api private
11
+ #
12
+ class BatchConstraint
13
+ # Check if the request is a JSON-RPC batch request
14
+ #
15
+ # @param request [ActionDispatch::Request] The Rails request object
16
+ # @return [Boolean] true if the request is a batch request, false otherwise
17
+ #
18
+ def matches?(request)
19
+ jsonrpc_batch = request.env['jsonrpc.batch']
20
+
21
+ # Return true if we have a batch request in the environment
22
+ !jsonrpc_batch.nil?
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # Extension module for ActionDispatch::Routing::Mapper
5
+ #
6
+ # @api private
7
+ #
8
+ module MapperExtension
9
+ # Define JSON-RPC routes with a DSL
10
+ #
11
+ # @param path [String] the path to handle JSON-RPC requests on
12
+ #
13
+ # @example Define JSON-RPC routes
14
+ # jsonrpc '/api/v1' do
15
+ # # Handle batch requests
16
+ # batch to: 'batch#handle'
17
+ #
18
+ # method 'user.create', to: 'users#create'
19
+ # method 'user.get', to: 'users#show'
20
+ #
21
+ # namespace 'posts' do
22
+ # method 'create', to: 'posts#create'
23
+ # method 'list', to: 'posts#index'
24
+ # end
25
+ # end
26
+ #
27
+ # @return [void]
28
+ #
29
+ def jsonrpc(path = '/', &)
30
+ dsl = JSONRPC::RoutesDsl.new(self, path)
31
+ dsl.instance_eval(&)
32
+ end
33
+ end
34
+ end
@@ -16,7 +16,7 @@ module JSONRPC
16
16
  # @param jsonrpc_method_name [String] The JSON-RPC method name to match against
17
17
  #
18
18
  def initialize(jsonrpc_method_name)
19
- @jsonrpc_method_name = jsonrpc_method_name
19
+ @jsonrpc_method_name = jsonrpc_method_name.to_s
20
20
  end
21
21
 
22
22
  # Check if the request matches the configured method name
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # DSL context for defining JSON-RPC routes within a jsonrpc block
5
+ #
6
+ # @example Simple method routing
7
+ # jsonrpc '/api/v1' do
8
+ # method 'ping', to: 'system#ping'
9
+ # method 'status', to: 'system#status'
10
+ # end
11
+ #
12
+ # @example Single-level namespace
13
+ # jsonrpc '/api/v1' do
14
+ # namespace 'users' do
15
+ # method 'create', to: 'users#create' # becomes users.create
16
+ # method 'list', to: 'users#index' # becomes users.list
17
+ # end
18
+ # end
19
+ #
20
+ # @example Nested namespaces (smart home control system)
21
+ # jsonrpc '/' do
22
+ # # Handle batch requests with dedicated controller
23
+ # batch to: 'batch#handle'
24
+ #
25
+ # method 'on', to: 'main#on'
26
+ # method 'off', to: 'main#off'
27
+ #
28
+ # namespace 'lights' do
29
+ # method 'on', to: 'lights#on' # becomes lights.on
30
+ # method 'off', to: 'lights#off' # becomes lights.off
31
+ # end
32
+ #
33
+ # namespace 'climate' do
34
+ # method 'on', to: 'climate#on' # becomes climate.on
35
+ # method 'off', to: 'climate#off' # becomes climate.off
36
+ #
37
+ # namespace 'fan' do
38
+ # method 'on', to: 'fan#on' # becomes climate.fan.on
39
+ # method 'off', to: 'fan#off' # becomes climate.fan.off
40
+ # end
41
+ # end
42
+ # end
43
+ #
44
+ # @api private
45
+ #
46
+ class RoutesDsl
47
+ # Initialize a new routes DSL context
48
+ #
49
+ # @param mapper [ActionDispatch::Routing::Mapper] the Rails route mapper
50
+ # @param path_prefix [String] the base path for JSON-RPC requests
51
+ #
52
+ def initialize(mapper, path_prefix = '/')
53
+ @mapper = mapper
54
+ @path_prefix = path_prefix
55
+ @namespace_stack = []
56
+ end
57
+
58
+ # Define a JSON-RPC method route
59
+ #
60
+ # @param jsonrpc_method [String] the JSON-RPC method name
61
+ # @param to [String] the Rails controller action (e.g., 'users#create')
62
+ #
63
+ # @example Map a JSON-RPC method to controller action
64
+ # method 'user.create', to: 'users#create'
65
+ #
66
+ # @example Method within a namespace
67
+ # namespace 'posts' do
68
+ # method 'create', to: 'posts#create' # becomes posts.create
69
+ # end
70
+ #
71
+ # @return [void]
72
+ #
73
+ def method(jsonrpc_method, to:)
74
+ full_method_name = build_full_method_name(jsonrpc_method)
75
+ constraint = JSONRPC::MethodConstraint.new(full_method_name)
76
+
77
+ @mapper.post @path_prefix, {
78
+ to: to,
79
+ constraints: constraint
80
+ }
81
+ end
82
+
83
+ # Define a route for handling JSON-RPC batch requests
84
+ #
85
+ # @param to [String] the Rails controller action (e.g., 'batches#handle')
86
+ #
87
+ # @example Map batch requests to a controller action
88
+ # batch to: 'batches#handle'
89
+ #
90
+ # @return [void]
91
+ def batch(to:)
92
+ constraint = JSONRPC::BatchConstraint.new
93
+
94
+ @mapper.post @path_prefix, {
95
+ to: to,
96
+ constraints: constraint
97
+ }
98
+ end
99
+
100
+ # Create a namespace for grouping related JSON-RPC methods
101
+ #
102
+ # Namespaces can be nested to create hierarchical method names.
103
+ # Each level of nesting adds a dot-separated prefix to the method names.
104
+ #
105
+ # @param name [String] the namespace name
106
+ #
107
+ # @example Single-level namespace
108
+ # namespace 'posts' do
109
+ # method 'create', to: 'posts#create' # becomes posts.create
110
+ # method 'delete', to: 'posts#delete' # becomes posts.list
111
+ # end
112
+ #
113
+ # @example Nested namespaces
114
+ # namespace 'climate' do
115
+ # method 'on', to: 'climate#on' # becomes climate.on
116
+ # method 'off', to: 'climate#off' # becomes climate.off
117
+ #
118
+ # namespace 'fan' do
119
+ # method 'on', to: 'fan#on' # becomes climate.fan.on
120
+ # method 'off', to: 'fan#off' # becomes climate.fan.off
121
+ # end
122
+ # end
123
+ #
124
+ # @return [void]
125
+ #
126
+ def namespace(name, &)
127
+ @namespace_stack.push(name)
128
+ instance_eval(&)
129
+ @namespace_stack.pop
130
+ end
131
+
132
+ private
133
+
134
+ # Build the full method name including namespaces
135
+ #
136
+ # @param method_name [String] the base method name
137
+ # @return [String] the full method name with namespace prefixes
138
+ #
139
+ def build_full_method_name(method_name)
140
+ if @namespace_stack.any?
141
+ "#{@namespace_stack.join(".")}.#{method_name}"
142
+ else
143
+ method_name
144
+ end
145
+ end
146
+ end
147
+ end